From d22bfaa55ca70012a73fde3b8b31f1237b3606da Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Fri, 16 Feb 2024 19:55:54 +0100 Subject: [PATCH] Implement tracker login and logout --- src/App.tsx | 4 + .../tracker/SettingsTrackerCard.tsx | 150 ++++++++++++++++++ src/i18n/locale/en.json | 31 ++++ src/lib/graphql/generated/graphql.ts | 26 +++ src/lib/graphql/mutations/TrackerMutation.ts | 47 ++++++ src/lib/graphql/queries/TrackerQuery.ts | 29 ++++ src/lib/requests/RequestManager.ts | 38 +++++ src/screens/Settings.tsx | 7 + src/screens/TrackerOAuthLogin.tsx | 52 ++++++ src/screens/settings/TrackingSettings.tsx | 52 ++++++ 10 files changed, 436 insertions(+) create mode 100644 src/components/tracker/SettingsTrackerCard.tsx create mode 100644 src/lib/graphql/mutations/TrackerMutation.ts create mode 100644 src/lib/graphql/queries/TrackerQuery.ts create mode 100644 src/screens/TrackerOAuthLogin.tsx create mode 100644 src/screens/settings/TrackingSettings.tsx diff --git a/src/App.tsx b/src/App.tsx index 0eb03723..fc22e4e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,8 @@ import { BrowseSettings } from '@/screens/settings/BrowseSettings.tsx'; import { WebUISettings } from '@/screens/settings/WebUISettings.tsx'; import { Migrate } from '@/screens/Migrate.tsx'; import { DeviceSetting } from '@/components/settings/DeviceSetting.tsx'; +import { TrackingSettings } from '@/screens/settings/TrackingSettings.tsx'; +import { TrackerOAuthLogin } from '@/screens/TrackerOAuthLogin.tsx'; if (process.env.NODE_ENV !== 'production') { // Adds messages only in a dev environment @@ -104,6 +106,7 @@ export const App: React.FC = () => ( } /> } /> } /> + } /> {/* Manga Routes */} @@ -127,6 +130,7 @@ export const App: React.FC = () => ( } /> } /> + } /> diff --git a/src/components/tracker/SettingsTrackerCard.tsx b/src/components/tracker/SettingsTrackerCard.tsx new file mode 100644 index 00000000..4cc42fbc --- /dev/null +++ b/src/components/tracker/SettingsTrackerCard.tsx @@ -0,0 +1,150 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import PopupState, { bindDialog, bindTrigger } from 'material-ui-popup-state'; +import ListItemButton from '@mui/material/ListItemButton'; +import { Chip, ListItemAvatar, ListItemSecondaryAction } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import ListItemText from '@mui/material/ListItemText'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import TextField from '@mui/material/TextField'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import { PasswordTextField } from '@/components/atoms/PasswordTextField.tsx'; +import { makeToast } from '@/components/util/Toast.tsx'; +import { GetTrackersQuery } from '@/lib/graphql/generated/graphql.ts'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; + +export const SettingsTrackerCard = ({ tracker }: { tracker: GetTrackersQuery['trackers']['nodes'][number] }) => { + const { t } = useTranslation(); + + const [loginTrackerCredentials, { loading: isCredentialLoginInProgress }] = + requestManager.useLoginToTrackerCredentials(); + const [logoutFromTracker] = requestManager.useLogoutFromTracker(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const isOAuthLogin = !tracker.isLoggedIn && !!tracker.authUrl; + + const handleLogout = async () => { + try { + await logoutFromTracker({ variables: { trackerId: tracker.id } }); + } catch (e) { + makeToast(t('tracking.action.logout.label.failure', { name: tracker.name }), 'error'); + } + }; + + const handleLogin = async () => { + if (isOAuthLogin) { + const state = { + redirectUrl: `${window.location.origin}/tracker/login/oauth`, + clientName: 'Suwayomi-WebUI', + trackerId: tracker.id, + trackerName: tracker.name, + }; + + window.open(`${tracker.authUrl}&state=${JSON.stringify(state)}`, '_self'); + return; + } + + try { + await loginTrackerCredentials({ variables: { input: { trackerId: tracker.id, username, password } } }); + } catch (e) { + makeToast(t('tracking.action.login.label.failure', { name: tracker.name }), 'error'); + } + }; + + return ( + + {(popupState) => ( + <> + { + if (!isOAuthLogin) { + popupState.open(); + return; + } + + handleLogin(); + }} + > + + + + + {tracker.isLoggedIn && ( + + + + )} + + + + {t( + tracker.isLoggedIn + ? 'tracking.settings.dialog.title.log_out' + : 'tracking.settings.dialog.title.log_in', + { name: tracker.name }, + )} + + {!isOAuthLogin && !tracker.isLoggedIn && ( + + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + )} + + + + + + + )} + + ); +}; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 21e08a8a..455bf887 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -302,6 +302,8 @@ "edit": "Edit", "filter": "Filter", "latest": "Latest", + "log_in": "Log in", + "log_out": "Log out", "migrate": "Migrate", "ok": "Ok", "open_site": "Open Site", @@ -374,6 +376,7 @@ "links": "Links", "load_in_progress": "Still loading required data…", "loading": "Loading…", + "logged_in": "Logged in", "never": "Never", "none": "None", "other": "Other", @@ -1045,6 +1048,34 @@ "title_one": "Source", "title_other": "Sources" }, + "tracking": { + "action": { + "login": { + "label": { + "failure": "Could not log in to {{name}}", + "progress": "Logging in to {{name}}…" + } + }, + "logout": { + "label": { + "failure": "Could not log out from {{name}}" + } + } + }, + "settings": { + "dialog": { + "title": { + "log_in": "Log in to {{name}}", + "log_out": "Log out from {{name}}" + } + }, + "title": { + "settings": "Tracking settings", + "trackers": "Trackers" + } + }, + "title": "Tracking" + }, "updates": { "error": { "label": { diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 2fd2ebae..a5579b94 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -2904,6 +2904,27 @@ export type UpdateSourcePreferencesMutationVariables = Exact<{ export type UpdateSourcePreferencesMutation = { __typename?: 'Mutation', updateSourcePreference: { __typename?: 'UpdateSourcePreferencePayload', clientMutationId?: string | null, source: { __typename?: 'SourceType', id: any, preferences: Array<{ __typename?: 'CheckBoxPreference', summary?: string | null, key: string, type: 'CheckBoxPreference', CheckBoxCheckBoxCurrentValue?: boolean | null, CheckBoxDefault: boolean, CheckBoxTitle: string } | { __typename?: 'EditTextPreference', text?: string | null, summary?: string | null, key: string, dialogTitle?: string | null, dialogMessage?: string | null, type: 'EditTextPreference', EditTextPreferenceCurrentValue?: string | null, EditTextPreferenceDefault?: string | null, EditTextPreferenceTitle?: string | null } | { __typename?: 'ListPreference', summary?: string | null, key: string, entryValues: Array, entries: Array, type: 'ListPreference', ListPreferenceCurrentValue?: string | null, ListPreferenceDefault?: string | null, ListPreferenceTitle?: string | null } | { __typename?: 'MultiSelectListPreference', dialogMessage?: string | null, dialogTitle?: string | null, summary?: string | null, key: string, entryValues: Array, entries: Array, type: 'MultiSelectListPreference', MultiSelectListPreferenceTitle?: string | null, MultiSelectListPreferenceDefault?: Array | null, MultiSelectListPreferenceCurrentValue?: Array | null } | { __typename?: 'SwitchPreference', summary?: string | null, key: string, type: 'SwitchPreference', SwitchPreferenceCurrentValue?: boolean | null, SwitchPreferenceDefault: boolean, SwitchPreferenceTitle: string }> } } }; +export type TrackerLoginOauthMutationVariables = Exact<{ + input: LoginTrackerOAuthInput; +}>; + + +export type TrackerLoginOauthMutation = { __typename?: 'Mutation', loginTrackerOAuth: { __typename?: 'LoginTrackerOAuthPayload', tracker: { __typename?: 'TrackerType', id: number, isLoggedIn: boolean, authUrl?: string | null } } }; + +export type TrackerLoginCredentialsMutationVariables = Exact<{ + input: LoginTrackerCredentialsInput; +}>; + + +export type TrackerLoginCredentialsMutation = { __typename?: 'Mutation', loginTrackerCredentials: { __typename?: 'LoginTrackerCredentialsPayload', isLoggedIn: boolean, tracker: { __typename?: 'TrackerType', id: number, isLoggedIn: boolean, authUrl?: string | null } } }; + +export type TrackerLogoutMutationVariables = Exact<{ + trackerId: Scalars['Int']['input']; +}>; + + +export type TrackerLogoutMutation = { __typename?: 'Mutation', logoutTracker: { __typename?: 'LogoutTrackerPayload', clientMutationId?: string | null, tracker: { __typename?: 'TrackerType', id: number, isLoggedIn: boolean, authUrl?: string | null } } }; + export type UpdateCategoryMangasMutationVariables = Exact<{ input: UpdateCategoryMangaInput; }>; @@ -3129,6 +3150,11 @@ export type GetMigratableSourcesQueryVariables = Exact<{ [key: string]: never; } export type GetMigratableSourcesQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', nodes: Array<{ __typename?: 'MangaType', sourceId: any, source?: { __typename?: 'SourceType', id: any, name: string, lang: string, iconUrl: string } | null }> } }; +export type GetTrackersQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetTrackersQuery = { __typename?: 'Query', trackers: { __typename?: 'TrackerNodeList', totalCount: number, pageInfo: { __typename?: 'PageInfo', endCursor?: any | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: any | null }, nodes: Array<{ __typename?: 'TrackerType', id: number, name: string, authUrl?: string | null, icon: string, isLoggedIn: boolean }> } }; + export type GetUpdateStatusQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/src/lib/graphql/mutations/TrackerMutation.ts b/src/lib/graphql/mutations/TrackerMutation.ts new file mode 100644 index 00000000..71b2fca4 --- /dev/null +++ b/src/lib/graphql/mutations/TrackerMutation.ts @@ -0,0 +1,47 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import gql from 'graphql-tag'; + +export const TRACKER_LOGIN_OAUTH = gql` + mutation TRACKER_LOGIN_OAUTH($input: LoginTrackerOAuthInput!) { + loginTrackerOAuth(input: $input) { + tracker { + id + isLoggedIn + authUrl + } + } + } +`; + +export const TRACKER_LOGIN_CREDENTIALS = gql` + mutation TRACKER_LOGIN_CREDENTIALS($input: LoginTrackerCredentialsInput!) { + loginTrackerCredentials(input: $input) { + isLoggedIn + tracker { + id + isLoggedIn + authUrl + } + } + } +`; + +export const TRACKER_LOGOUT = gql` + mutation TRACKER_LOGOUT($trackerId: Int!) { + logoutTracker(input: { trackerId: $trackerId }) { + clientMutationId + tracker { + id + isLoggedIn + authUrl + } + } + } +`; diff --git a/src/lib/graphql/queries/TrackerQuery.ts b/src/lib/graphql/queries/TrackerQuery.ts new file mode 100644 index 00000000..2e60bac6 --- /dev/null +++ b/src/lib/graphql/queries/TrackerQuery.ts @@ -0,0 +1,29 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import gql from 'graphql-tag'; +import { PAGE_INFO } from '@/lib/graphql/Fragments.ts'; + +export const GET_TRACKERS = gql` + ${PAGE_INFO} + query GET_TRACKERS { + trackers { + totalCount + pageInfo { + ...PAGE_INFO + } + nodes { + id + name + authUrl + icon + isLoggedIn + } + } + } +`; diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 3507bcbc..efc636ab 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -177,6 +177,14 @@ import { GetMangaToMigrateQueryVariables, GetMangaToMigrateToFetchMutation, GetMangaToMigrateToFetchMutationVariables, + GetTrackersQuery, + GetTrackersQueryVariables, + TrackerLogoutMutation, + TrackerLogoutMutationVariables, + TrackerLoginOauthMutation, + TrackerLoginOauthMutationVariables, + TrackerLoginCredentialsMutation, + TrackerLoginCredentialsMutationVariables, } from '@/lib/graphql/generated/graphql.ts'; import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts'; import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts'; @@ -257,6 +265,12 @@ import { WEBUI_UPDATE_SUBSCRIPTION } from '@/lib/graphql/subscriptions/ServerInf import { GET_DOWNLOAD_STATUS } from '@/lib/graphql/queries/DownloaderQuery.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; import { Queue, QueuePriority } from '@/lib/Queue.ts'; +import { GET_TRACKERS } from '@/lib/graphql/queries/TrackerQuery.ts'; +import { + TRACKER_LOGIN_CREDENTIALS, + TRACKER_LOGIN_OAUTH, + TRACKER_LOGOUT, +} from '@/lib/graphql/mutations/TrackerMutation.ts'; enum GQLMethod { QUERY = 'QUERY', @@ -2359,6 +2373,30 @@ export class RequestManager { ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCES, undefined, options); } + + public useGetTrackerList( + options?: QueryHookOptions, + ): AbortableApolloUseQueryResponse { + return this.doRequest(GQLMethod.USE_QUERY, GET_TRACKERS, undefined, options); + } + + public useLogoutFromTracker( + options?: MutationHookOptions, + ): AbortableApolloUseMutationResponse { + return this.doRequest(GQLMethod.USE_MUTATION, TRACKER_LOGOUT, undefined, options); + } + + public useLoginToTrackerOauth( + options?: MutationHookOptions, + ): AbortableApolloUseMutationResponse { + return this.doRequest(GQLMethod.USE_MUTATION, TRACKER_LOGIN_OAUTH, undefined, options); + } + + public useLoginToTrackerCredentials( + options?: MutationHookOptions, + ): AbortableApolloUseMutationResponse { + return this.doRequest(GQLMethod.USE_MUTATION, TRACKER_LOGIN_CREDENTIALS, undefined, options); + } } export const requestManager = new RequestManager(); diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index 135eb062..c32e9e99 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -28,6 +28,7 @@ import WebIcon from '@mui/icons-material/Web'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined'; import DevicesIcon from '@mui/icons-material/Devices'; +import SyncIcon from '@mui/icons-material/Sync'; import { langCodeToName } from '@/util/language'; import { useLocalStorage } from '@/util/useLocalStorage'; import { ListItemLink } from '@/components/util/ListItemLink'; @@ -94,6 +95,12 @@ export function Settings() { + + + + + + diff --git a/src/screens/TrackerOAuthLogin.tsx b/src/screens/TrackerOAuthLogin.tsx new file mode 100644 index 00000000..d481a263 --- /dev/null +++ b/src/screens/TrackerOAuthLogin.tsx @@ -0,0 +1,52 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { makeToast } from '@/components/util/Toast.tsx'; + +export const TrackerOAuthLogin = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const url = new URL(window.location.href); + const { trackerId, trackerName }: { trackerId: number; trackerName: string } = JSON.parse( + url.searchParams.get('state') ?? '{}', + ); + + const [loginTrackerOAuth, { loading: isLoginInProgress }] = requestManager.useLoginToTrackerOauth(); + + useEffect(() => { + const login = async () => { + try { + await loginTrackerOAuth({ + variables: { + input: { + callbackUrl: window.location.href, + trackerId, + }, + }, + }); + } catch (e) { + makeToast(t('tracking.action.login.label.failure', { name: trackerName }), 'error'); + } + + navigate('/settings/trackingSettings', { replace: true }); + }; + + login(); + }, [trackerId]); + + if (isLoginInProgress) { + return t('tracking.action.login.label.progress', { name: trackerName }); + } + + return null; +}; diff --git a/src/screens/settings/TrackingSettings.tsx b/src/screens/settings/TrackingSettings.tsx new file mode 100644 index 00000000..e3ac6fdb --- /dev/null +++ b/src/screens/settings/TrackingSettings.tsx @@ -0,0 +1,52 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { useTranslation } from 'react-i18next'; +import { useContext, useEffect } from 'react'; +import List from '@mui/material/List'; + +import ListSubheader from '@mui/material/ListSubheader'; +import { NavBarContext } from '@/components/context/NavbarContext.tsx'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { EmptyView } from '@/components/util/EmptyView.tsx'; +import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx'; +import { SettingsTrackerCard } from '@/components/tracker/SettingsTrackerCard.tsx'; + +export const TrackingSettings = () => { + const { t } = useTranslation(); + const { setTitle } = useContext(NavBarContext); + + useEffect(() => { + setTitle(t('tracking.settings.title.settings')); + }, [t]); + + const { data, loading, error } = requestManager.useGetTrackerList(); + const trackers = data?.trackers.nodes ?? []; + + if (error) { + return ; + } + + if (loading) { + return ; + } + + return ( + + {t('tracking.settings.title.trackers')} + + } + > + {trackers.map((tracker) => ( + + ))} + + ); +};