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 && (
+
+
+
+ )}
+
+
+ >
+ )}
+
+ );
+};
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) => (
+
+ ))}
+
+ );
+};