From 4aad410957c9e89fb389836d47d6e437b2a25c14 Mon Sep 17 00:00:00 2001
From: schroda <50052685+schroda@users.noreply.github.com>
Date: Sun, 27 Jul 2025 00:03:51 +0200
Subject: [PATCH] Add "ui login" support
---
package.json | 2 +
public/locales/en.json | 24 ++-
src/App.tsx | 181 ++++++++++++------
src/assets/SuwayomiLogo.tsx | 31 +++
src/base/AppRoute.constants.ts | 12 +-
src/base/Base.types.ts | 1 +
src/base/components/AvatarSpinner.tsx | 30 +++
.../components/lists/cards/ListCardAvatar.tsx | 54 +++---
src/base/contexts/AppContext.tsx | 57 +++---
src/features/authentication/AuthManager.ts | 93 +++++++++
.../authentication/SessionContext.tsx | 42 ++++
.../authentication/components/AuthGuard.tsx | 36 ++++
.../components/SplashScreen.tsx | 46 +++++
.../authentication/screens/LoginPage.tsx | 143 ++++++++++++++
src/features/settings/Settings.constants.ts | 23 ++-
src/features/settings/Settings.types.ts | 3 +
.../components/ServerAddressSetting.tsx | 33 ++++
.../settings/screens/ServerSettings.tsx | 99 +++++++---
.../components/cards/SettingsTrackerCard.tsx | 14 +-
.../components/cards/TrackerActiveCard.tsx | 14 +-
.../components/cards/TrackerUntrackedCard.tsx | 14 +-
src/index.tsx | 7 +-
src/lib/dayjs/Setup.ts | 2 +
.../graphql/fragments/SettingsFragments.ts | 3 +
src/lib/graphql/generated/apollo-helpers.ts | 127 ++++++++++--
src/lib/graphql/generated/graphql.ts | 174 ++++++++++++++++-
src/lib/graphql/mutations/UserMutation.ts | 26 +++
src/lib/requests/RequestManager.ts | 32 +++-
src/lib/requests/client/BaseClient.ts | 48 +++++
src/lib/requests/client/GraphQLClient.ts | 65 ++++++-
src/lib/requests/client/RestClient.ts | 25 ++-
tools/scripts/codegenFormatter.ts | 2 +
yarn.lock | 10 +
33 files changed, 1270 insertions(+), 203 deletions(-)
create mode 100644 src/assets/SuwayomiLogo.tsx
create mode 100644 src/base/components/AvatarSpinner.tsx
create mode 100644 src/features/authentication/AuthManager.ts
create mode 100644 src/features/authentication/SessionContext.tsx
create mode 100644 src/features/authentication/components/AuthGuard.tsx
create mode 100644 src/features/authentication/components/SplashScreen.tsx
create mode 100644 src/features/authentication/screens/LoginPage.tsx
create mode 100644 src/features/settings/components/ServerAddressSetting.tsx
create mode 100644 src/lib/graphql/mutations/UserMutation.ts
diff --git a/package.json b/package.json
index b65ec0ae..f65f59c8 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"i18next": "25.3.6",
"i18next-browser-languagedetector": "8.2.0",
"i18next-http-backend": "3.0.2",
+ "js-cookie": "^3.0.5",
"jsonrepair": "3.13.0",
"material-ui-popup-state": "5.3.6",
"mui-nested-menu": "4.0.1",
@@ -88,6 +89,7 @@
"@graphql-codegen/typescript-apollo-client-helpers": "3.0.1",
"@graphql-codegen/typescript-operations": "4.6.1",
"@types/apollo-upload-client": "18.0.0",
+ "@types/js-cookie": "^3.0.6",
"@types/node": "24.3.0",
"@types/react": "19.1.10",
"@types/react-beautiful-dnd": "13.1.8",
diff --git a/public/locales/en.json b/public/locales/en.json
index 560befd6..4ce82d96 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -116,9 +116,9 @@
},
"label": {
"action": {
+ "all": "Mark all as read",
"current": "Mark as read",
- "previous": "Mark previous as read",
- "all": "Mark all as read"
+ "previous": "Mark previous as read"
},
"confirmation_one": "You are about to mark one chapter as read",
"confirmation_other": "You are about to mark {{count}} chapters as read",
@@ -521,6 +521,11 @@
}
},
"time": {
+ "days": {
+ "day_one": "Day",
+ "day_other": "Days",
+ "value": "{{count}} $t(global.time.days.day, lowercase)"
+ },
"hour_short": "h",
"minutes": {
"minute_one": "Minute",
@@ -1297,6 +1302,11 @@
}
},
"auth": {
+ "jwt": {
+ "access_token_expiry": "JWT access token expiry",
+ "audience": "JWT audience claim",
+ "refresh_token_expiry": "JWT refresh token expiry"
+ },
"label": {
"password": "$t(global.label.password)",
"title": "Authentication Mode",
@@ -1317,12 +1327,18 @@
"title": "None"
}
},
- "simpleLogin": {
+ "simple_login": {
"label": {
- "description": "A simple login page will be presented to login.",
+ "description": "The login will be handled by the server.",
"info": "When you enable this, you may need to refresh this tab for the login page to appear.",
"title": "Simple Login"
}
+ },
+ "ui_login": {
+ "label": {
+ "description": "The login will be handled by the client.",
+ "title": "UI Login"
+ }
}
}
},
diff --git a/src/App.tsx b/src/App.tsx
index 855c7fde..aed7a47b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -8,7 +8,7 @@
import CssBaseline from '@mui/material/CssBaseline';
import React, { useLayoutEffect } from 'react';
-import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
+import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { loadErrorMessages, loadDevMessages } from '@apollo/client/dev';
import { loadable } from 'react-lazily/loadable';
import Box from '@mui/material/Box';
@@ -27,6 +27,10 @@ import { AppRoutes } from '@/base/AppRoute.constants.ts';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { BrowseTab } from '@/features/browse/Browse.types.ts';
+import { useSessionContext } from '@/features/authentication/SessionContext.tsx';
+import { LoginPage } from '@/features/authentication/screens/LoginPage.tsx';
+import { AuthGuard } from '@/features/authentication/components/AuthGuard.tsx';
+import { SearchParam } from '@/base/Base.types.ts';
const { Browse } = loadable(() => import('@/features/browse/screens/Browse.tsx'), lazyLoadFallback);
const { DownloadQueue } = loadable(() => import('@/features/downloads/screens/DownloadQueue.tsx'), lazyLoadFallback);
@@ -82,7 +86,7 @@ const { More } = loadable(() => import('@/features/settings/screens/More.tsx'),
const { Reader } = loadable(() => import('@/features/reader/screens/Reader.tsx'), lazyLoadFallback);
const { HistorySettings } = loadable(() => import('@/features/history/screens/HistorySettings.tsx'), lazyLoadFallback);
-if (process.env.NODE_ENV !== 'production') {
+if (import.meta.env.DEV) {
// Adds messages only in a dev environment
loadDevMessages();
loadErrorMessages();
@@ -105,15 +109,39 @@ const ScrollToTop = () => {
* and thus, data of existing chapters/mangas in the cache get outdated
*/
const BackgroundSubscriptions = () => {
- // load the full download status once on startup to fill the cache
+ // Listen to session changes
+ const { isAuthRequired, accessToken } = useSessionContext();
+
+ const skipConnection = isAuthRequired == null || (!!isAuthRequired && !accessToken);
+
+ // Load the full download status once on startup to fill the cache
requestManager.useGetDownloadStatus({ nextFetchPolicy: 'standby' });
- requestManager.useDownloadSubscription();
- requestManager.useUpdaterSubscription();
- requestManager.useWebUIUpdateSubscription();
+ requestManager.useDownloadSubscription({ skip: skipConnection });
+ requestManager.useUpdaterSubscription({ skip: skipConnection });
+ requestManager.useWebUIUpdateSubscription({ skip: skipConnection });
return null;
};
+const PrivateRoutes = () => {
+ const { isAuthRequired, accessToken, refreshToken } = useSessionContext();
+
+ const isAuthenticated = !isAuthRequired || (isAuthRequired && (accessToken || refreshToken));
+ if (!isAuthenticated) {
+ return (
+
+ );
+ }
+
+ return ;
+};
+
const MainApp = () => {
const { navBarWidth, appBarHeight, bottomBarHeight } = useNavBarContext();
const isMobileWidth = MediaQuery.useIsMobileWidth();
@@ -139,60 +167,87 @@ const MainApp = () => {
>
- {/* General Routes */}
- } />
- } />
- {isMobileWidth && } />}
- } />
-
- } />
- } />
- } />
-
- } />
+
+ } />
+
+
+ }>
+ {/* General Routes */}
+ }
+ />
+ }
+ />
+ {isMobileWidth && } />}
+ } />
+
+ } />
}
+ path={AppRoutes.settings.childRoutes.categories.match}
+ element={}
/>
+ }
+ />
+
+ } />
+ }
+ />
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
- {/* Manga Routes */}
+ {/* Manga Routes */}
-
- {/* TODO: deprecated - "source" and "extension" page got merged into "browse" */}
- } />
- } />
- } />
- } />
+
+ {/* TODO: deprecated - "source" and "extension" page got merged into "browse" */}
+ } />
+ } />
+ } />
+ } />
+
+
+ }
+ />
+ } />
+
+ } />
+
+
+ } />
+
+ } />
+ } />
+ {!hideHistory && } />}
+ } />
+
+ } />
+ } />
+
+ } />
-
- } />
- } />
-
- } />
-
-
- } />
-
- } />
- } />
- {!hideHistory && } />}
- } />
-
- } />
- } />
-
- } />
@@ -214,14 +269,16 @@ export const App: React.FC = () => (
-
-
-
+
+
+
+
+
+
+ } />
+ } />
+
-
- } />
- } />
-
-
+
);
diff --git a/src/assets/SuwayomiLogo.tsx b/src/assets/SuwayomiLogo.tsx
new file mode 100644
index 00000000..7b5b4fa5
--- /dev/null
+++ b/src/assets/SuwayomiLogo.tsx
@@ -0,0 +1,31 @@
+/*
+ * 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 SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
+
+export const SuwayomiLogo = ({
+ circleRingColor = '#35d4d5',
+ ...props
+}: SvgIconProps & { circleRingColor?: string }) => (
+
+
+
+);
diff --git a/src/base/AppRoute.constants.ts b/src/base/AppRoute.constants.ts
index 2b773140..f6d4b5d3 100644
--- a/src/base/AppRoute.constants.ts
+++ b/src/base/AppRoute.constants.ts
@@ -38,13 +38,23 @@ export const AppRoutes = {
matchAll: {
match: '*',
},
+ authentication: {
+ match: 'auth',
+ path: '/auth',
+ childRoutes: {
+ login: {
+ match: 'login',
+ path: '/auth/login',
+ },
+ },
+ },
about: {
match: 'about',
path: '/about',
},
settings: {
- path: '/settings',
match: 'settings',
+ path: '/settings',
childRoutes: {
categories: {
match: 'categories',
diff --git a/src/base/Base.types.ts b/src/base/Base.types.ts
index 977cd61e..3b5cf5b6 100644
--- a/src/base/Base.types.ts
+++ b/src/base/Base.types.ts
@@ -71,4 +71,5 @@ export enum ScrollDirection {
export enum SearchParam {
TAB = 'tab',
QUERY = 'query',
+ REDIRECT = 'redirect',
}
diff --git a/src/base/components/AvatarSpinner.tsx b/src/base/components/AvatarSpinner.tsx
new file mode 100644
index 00000000..6ed4e9ac
--- /dev/null
+++ b/src/base/components/AvatarSpinner.tsx
@@ -0,0 +1,30 @@
+/*
+ * 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 Avatar, { AvatarProps } from '@mui/material/Avatar';
+import { SpinnerImage, SpinnerImageProps } from '@/base/components/SpinnerImage.tsx';
+
+export const AvatarSpinner = ({
+ iconUrl,
+ alt,
+ slots,
+}: {
+ iconUrl: string;
+ alt: string;
+ slots?: { avatarProps?: Partial; spinnerImageProps?: Partial };
+}) => (
+
+
+
+);
diff --git a/src/base/components/lists/cards/ListCardAvatar.tsx b/src/base/components/lists/cards/ListCardAvatar.tsx
index 0aa243a1..77fea4be 100644
--- a/src/base/components/lists/cards/ListCardAvatar.tsx
+++ b/src/base/components/lists/cards/ListCardAvatar.tsx
@@ -6,36 +6,28 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import Avatar, { AvatarProps } from '@mui/material/Avatar';
-import { SpinnerImage, SpinnerImageProps } from '@/base/components/SpinnerImage.tsx';
+import { ComponentProps } from 'react';
+import { AvatarSpinner } from '@/base/components/AvatarSpinner.tsx';
-export const ListCardAvatar = ({
- iconUrl,
- alt,
- slots,
-}: {
- iconUrl: string;
- alt: string;
- slots?: { avatarProps?: Partial; spinnerImageProps?: Partial };
-}) => (
-
- ) => {
+ const { slots } = props;
+
+ return (
+
-
-);
+ );
+};
diff --git a/src/base/contexts/AppContext.tsx b/src/base/contexts/AppContext.tsx
index 397c388a..dc1405e6 100644
--- a/src/base/contexts/AppContext.tsx
+++ b/src/base/contexts/AppContext.tsx
@@ -19,37 +19,40 @@ import { SnackbarWithDescription } from '@/base/components/feedback/SnackbarWith
import { AppPageHistoryContextProvider } from '@/base/contexts/AppPageHistoryContext.tsx';
import { AppThemeContextProvider } from '@/features/theme/AppThemeContext.tsx';
import { NavBarContextProvider } from '@/features/navigation-bar/NavbarContext.tsx';
+import { SessionContextProvider } from '@/features/authentication/SessionContext.tsx';
interface Props {
children: React.ReactNode;
}
export const AppContext: React.FC = ({ children }) => (
-
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
);
diff --git a/src/features/authentication/AuthManager.ts b/src/features/authentication/AuthManager.ts
new file mode 100644
index 00000000..380e946b
--- /dev/null
+++ b/src/features/authentication/AuthManager.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 { AppStorage } from '@/lib/storage/AppStorage.ts';
+import { useSessionStorage } from '@/base/hooks/useStorage.tsx';
+
+export class AuthManager {
+ static readonly AUTH_REQUIRED_KEY = 'auth-required';
+
+ static readonly REFRESH_TOKEN_KEY = 'auth-refresh-token';
+
+ static readonly REACT_SESSION_REFRESH_KEY = 'auth-react-session-refresh';
+
+ private static accessToken: string | null = null;
+
+ static isAuthRequired(): boolean | null {
+ return AppStorage.session.getItemParsed(AuthManager.AUTH_REQUIRED_KEY, null);
+ }
+
+ static setAuthRequired(value: boolean | null): void {
+ AppStorage.session.setItem(AuthManager.AUTH_REQUIRED_KEY, value);
+ }
+
+ static useIsAuthRequired(): boolean | null {
+ const [value] = useSessionStorage(AuthManager.AUTH_REQUIRED_KEY, null);
+
+ return value;
+ }
+
+ static getAccessToken(): string | null {
+ return AuthManager.accessToken;
+ }
+
+ static getRefreshToken(): string | null {
+ return AppStorage.session.getItemParsed(AuthManager.REFRESH_TOKEN_KEY, null);
+ }
+
+ static getTokens(): { accessToken: string | null; refreshToken: string | null } {
+ return {
+ accessToken: AuthManager.getAccessToken(),
+ refreshToken: AuthManager.getRefreshToken(),
+ };
+ }
+
+ static setAccessToken(token: string): void {
+ AuthManager.accessToken = token;
+ AuthManager.refreshReactSessionContext();
+ }
+
+ static setRefreshToken(token: string): void {
+ AppStorage.session.setItem(AuthManager.REFRESH_TOKEN_KEY, token);
+ AuthManager.refreshReactSessionContext();
+ }
+
+ static setTokens(accessToken: string, refreshToken: string): void {
+ AuthManager.setAccessToken(accessToken);
+ AuthManager.setRefreshToken(refreshToken);
+ }
+
+ static removeAccessToken(): void {
+ AuthManager.accessToken = null;
+ AuthManager.refreshReactSessionContext();
+ }
+
+ static removeRefreshToken(): void {
+ AppStorage.session.setItem(AuthManager.REFRESH_TOKEN_KEY, undefined);
+ AuthManager.refreshReactSessionContext();
+ }
+
+ static removeTokens(): void {
+ AuthManager.removeAccessToken();
+ AuthManager.removeRefreshToken();
+ }
+
+ private static getNextReactSessionContextId(): number {
+ const id = AppStorage.session.getItemParsed(AuthManager.REACT_SESSION_REFRESH_KEY, 0);
+
+ return (id + 1) % Number.MAX_SAFE_INTEGER;
+ }
+
+ static refreshReactSessionContext(): void {
+ AppStorage.session.setItem(AuthManager.REACT_SESSION_REFRESH_KEY, AuthManager.getNextReactSessionContextId());
+ }
+
+ static useListenToReactSessionContextRefreshEvent(): void {
+ useSessionStorage(AuthManager.REACT_SESSION_REFRESH_KEY, 0);
+ }
+}
diff --git a/src/features/authentication/SessionContext.tsx b/src/features/authentication/SessionContext.tsx
new file mode 100644
index 00000000..2c0b2da0
--- /dev/null
+++ b/src/features/authentication/SessionContext.tsx
@@ -0,0 +1,42 @@
+/*
+ * 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 { createContext, ReactNode, useContext, useMemo } from 'react';
+import { AuthManager } from '@/features/authentication/AuthManager.ts';
+
+interface TSessionContext {
+ isAuthRequired: boolean | null;
+ accessToken: string | null;
+ refreshToken: string | null;
+}
+
+const SessionContext = createContext({
+ isAuthRequired: null,
+ accessToken: null,
+ refreshToken: null,
+});
+
+export const SessionContextProvider = ({ children }: { children: ReactNode }) => {
+ AuthManager.useListenToReactSessionContextRefreshEvent();
+
+ const isAuthRequired = AuthManager.useIsAuthRequired();
+ const { accessToken, refreshToken } = AuthManager.getTokens();
+
+ const value = useMemo(
+ () => ({
+ isAuthRequired,
+ accessToken,
+ refreshToken,
+ }),
+ [isAuthRequired, accessToken, refreshToken],
+ );
+
+ return {children};
+};
+
+export const useSessionContext = () => useContext(SessionContext);
diff --git a/src/features/authentication/components/AuthGuard.tsx b/src/features/authentication/components/AuthGuard.tsx
new file mode 100644
index 00000000..38efc91d
--- /dev/null
+++ b/src/features/authentication/components/AuthGuard.tsx
@@ -0,0 +1,36 @@
+/*
+ * 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 { ReactNode, useEffect } from 'react';
+import { useSessionContext } from '@/features/authentication/SessionContext.tsx';
+import { SplashScreen } from '@/features/authentication/components/SplashScreen.tsx';
+import { requestManager } from '@/lib/requests/RequestManager.ts';
+import { AuthManager } from '@/features/authentication/AuthManager.ts';
+
+export const AuthGuard = ({ children }: { children: ReactNode }) => {
+ const { isAuthRequired } = useSessionContext();
+
+ requestManager.useGetAbout({
+ skip: isAuthRequired !== null,
+ onCompleted: () => AuthManager.setAuthRequired(false),
+ });
+
+ useEffect(() => {
+ const onUnload = () => AuthManager.setAuthRequired(null);
+
+ window.addEventListener('beforeunload', onUnload);
+
+ return () => window.removeEventListener('beforeunload', onUnload);
+ }, []);
+
+ if (isAuthRequired === null) {
+ return ;
+ }
+
+ return children;
+};
diff --git a/src/features/authentication/components/SplashScreen.tsx b/src/features/authentication/components/SplashScreen.tsx
new file mode 100644
index 00000000..11bf02ff
--- /dev/null
+++ b/src/features/authentication/components/SplashScreen.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 { useTheme } from '@mui/material/styles';
+import Stack from '@mui/material/Stack';
+import { SuwayomiLogo } from '@/assets/SuwayomiLogo.tsx';
+import { ServerAddressSetting } from '@/features/settings/components/ServerAddressSetting.tsx';
+
+export const SplashScreen = () => {
+ const theme = useTheme();
+
+ return (
+
+
+ {import.meta.env.DEV && (
+
+
+
+ )}
+
+ );
+};
diff --git a/src/features/authentication/screens/LoginPage.tsx b/src/features/authentication/screens/LoginPage.tsx
new file mode 100644
index 00000000..516044a4
--- /dev/null
+++ b/src/features/authentication/screens/LoginPage.tsx
@@ -0,0 +1,143 @@
+/*
+ * 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 { useEffect, useState } from 'react';
+import TextField from '@mui/material/TextField';
+import Stack from '@mui/material/Stack';
+import Button from '@mui/material/Button';
+import { Navigate, useNavigate } from 'react-router-dom';
+import { useTheme } from '@mui/material/styles';
+import { StringParam, useQueryParam } from 'use-query-params';
+import { PasswordTextField } from '@/base/components/inputs/PasswordTextField.tsx';
+import { requestManager } from '@/lib/requests/RequestManager.ts';
+import { makeToast } from '@/base/utils/Toast.ts';
+import { getErrorMessage } from '@/lib/HelperFunctions.ts';
+import { AuthManager } from '@/features/authentication/AuthManager.ts';
+import { SuwayomiLogo } from '@/assets/SuwayomiLogo.tsx';
+import { useSessionContext } from '@/features/authentication/SessionContext.tsx';
+import { AppRoutes } from '@/base/AppRoute.constants.ts';
+import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
+import { SearchParam } from '@/base/Base.types.ts';
+import { ServerAddressSetting } from '@/features/settings/components/ServerAddressSetting.tsx';
+
+export const LoginPage = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { setOverride } = useNavBarContext();
+ const navigate = useNavigate();
+ const { isAuthRequired, accessToken, refreshToken } = useSessionContext();
+
+ const [redirect] = useQueryParam(SearchParam.REDIRECT, StringParam);
+ const [loginUser, { loading: isLoading }] = requestManager.useLoginUser();
+
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+
+ const isAuthenticated = !isAuthRequired || (!!isAuthRequired && !!accessToken && !!refreshToken);
+
+ const doLogin = async () => {
+ try {
+ const { data } = await loginUser({ variables: { username, password } });
+
+ if (data) {
+ AuthManager.setTokens(data.login.accessToken, data.login.refreshToken);
+ navigate(redirect ?? AppRoutes.root.path);
+ }
+ } catch (e) {
+ makeToast(t('tracking.action.login.label.failure', { name: 'Suwayomi' }), 'error', getErrorMessage(e));
+ }
+ };
+
+ useEffect(() => {
+ setOverride({ status: true, value: null });
+
+ return () => setOverride({ status: false, value: null });
+ }, []);
+
+ if (isAuthenticated) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ setUsername(e.target.value)}
+ />
+ setPassword(e.target.value)}
+ />
+ {import.meta.env.DEV && }
+
+
+
+
+
+ );
+};
diff --git a/src/features/settings/Settings.constants.ts b/src/features/settings/Settings.constants.ts
index aaadf277..fb0ddf46 100644
--- a/src/features/settings/Settings.constants.ts
+++ b/src/features/settings/Settings.constants.ts
@@ -6,6 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
+import dayjs from 'dayjs';
import { DEFAULT_DEVICE } from '@/features/device/services/Device.ts';
import { DEFAULT_SORT_SETTINGS } from '@/features/migration/Migration.constants.ts';
import { GlobalUpdateSkipEntriesSettings, MetadataServerSettings } from '@/features/settings/Settings.types.ts';
@@ -90,9 +91,13 @@ const AUTH_MODES_TO_TRANSLATION_KEY: { [mode in AuthMode]: SelectSettingValueDis
description: 'settings.server.auth.mode.option.basicAuth.label.description',
},
[AuthMode.SimpleLogin]: {
- text: 'settings.server.auth.mode.option.simpleLogin.label.title',
- description: 'settings.server.auth.mode.option.simpleLogin.label.description',
- disclaimer: 'settings.server.auth.mode.option.simpleLogin.label.info',
+ text: 'settings.server.auth.mode.option.simple_login.label.title',
+ description: 'settings.server.auth.mode.option.simple_login.label.description',
+ disclaimer: 'settings.server.auth.mode.option.simple_login.label.info',
+ },
+ [AuthMode.UiLogin]: {
+ text: 'settings.server.auth.mode.option.ui_login.label.title',
+ description: 'settings.server.auth.mode.option.ui_login.label.description',
},
};
export const AUTH_MODES_SELECT_VALUES: SelectSettingValue[] = AUTH_MODES.map((mode) => [
@@ -183,3 +188,15 @@ export const WEB_UI_UPDATE_INTERVAL = {
min: 1,
max: 23,
};
+
+export const JWT_ACCESS_TOKEN_EXPIRY = {
+ default: dayjs.duration(5, 'minute').asMinutes(),
+ min: dayjs.duration(1, 'minute').asMinutes(),
+ max: dayjs.duration(4, 'hour').asMinutes(),
+};
+
+export const JWT_REFRESH_TOKEN_EXPIRY = {
+ default: dayjs.duration(60, 'day').asDays(),
+ min: dayjs.duration(1, 'day').asDays(),
+ max: dayjs.duration(1, 'year').asDays(),
+};
diff --git a/src/features/settings/Settings.types.ts b/src/features/settings/Settings.types.ts
index a233a634..030abe60 100644
--- a/src/features/settings/Settings.types.ts
+++ b/src/features/settings/Settings.types.ts
@@ -55,6 +55,9 @@ export type ServerSettingsType = Pick<
| 'authMode'
| 'authUsername'
| 'authPassword'
+ | 'jwtAudience'
+ | 'jwtTokenExpiry'
+ | 'jwtRefreshExpiry'
| 'flareSolverrEnabled'
| 'flareSolverrTimeout'
| 'flareSolverrUrl'
diff --git a/src/features/settings/components/ServerAddressSetting.tsx b/src/features/settings/components/ServerAddressSetting.tsx
new file mode 100644
index 00000000..794f58fa
--- /dev/null
+++ b/src/features/settings/components/ServerAddressSetting.tsx
@@ -0,0 +1,33 @@
+/*
+ * 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 { TextSetting } from '@/base/components/settings/text/TextSetting.tsx';
+import { requestManager } from '@/lib/requests/RequestManager.ts';
+import { useLocalStorage } from '@/base/hooks/useStorage.tsx';
+
+export const ServerAddressSetting = () => {
+ const { t } = useTranslation();
+
+ const [serverAddress, setServerAddress] = useLocalStorage('serverBaseURL', window.location.origin);
+
+ const handleServerAddressChange = (address: string) => {
+ const serverBaseUrl = address.replaceAll(/(\/)+$/g, '');
+ setServerAddress(serverBaseUrl);
+ requestManager.reset();
+ };
+
+ return (
+
+ );
+};
diff --git a/src/features/settings/screens/ServerSettings.tsx b/src/features/settings/screens/ServerSettings.tsx
index 19f96c1b..dd854699 100644
--- a/src/features/settings/screens/ServerSettings.tsx
+++ b/src/features/settings/screens/ServerSettings.tsx
@@ -15,8 +15,8 @@ import ListItemText from '@mui/material/ListItemText';
import Switch from '@mui/material/Switch';
import ListSubheader from '@mui/material/ListSubheader';
import { t as translate } from 'i18next';
+import dayjs from 'dayjs';
import { requestManager } from '@/lib/requests/RequestManager.ts';
-import { useLocalStorage } from '@/base/hooks/useStorage.tsx';
import { TextSetting } from '@/base/components/settings/text/TextSetting.tsx';
import { NumberSetting } from '@/base/components/settings/NumberSetting.tsx';
import { SelectSetting } from '@/base/components/settings/SelectSetting.tsx';
@@ -33,7 +33,13 @@ import { ServerSettings as GqlServerSettings, ServerSettingsType } from '@/featu
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
import { AuthMode, SortOrder } from '@/lib/graphql/generated/graphql';
-import { AUTH_MODES_SELECT_VALUES } from '@/features/settings/Settings.constants.ts';
+import {
+ AUTH_MODES_SELECT_VALUES,
+ JWT_ACCESS_TOKEN_EXPIRY,
+ JWT_REFRESH_TOKEN_EXPIRY,
+} from '@/features/settings/Settings.constants.ts';
+import { ServerAddressSetting } from '@/features/settings/components/ServerAddressSetting.tsx';
+import { AuthManager } from '@/features/authentication/AuthManager.ts';
const extractServerSettings = (settings: GqlServerSettings): ServerSettingsType => ({
ip: settings.ip,
@@ -52,6 +58,9 @@ const extractServerSettings = (settings: GqlServerSettings): ServerSettingsType
authMode: settings.authMode,
authUsername: settings.authUsername,
authPassword: settings.authPassword,
+ jwtAudience: settings.jwtAudience,
+ jwtTokenExpiry: settings.jwtTokenExpiry,
+ jwtRefreshExpiry: settings.jwtRefreshExpiry,
flareSolverrEnabled: settings.flareSolverrEnabled,
flareSolverrTimeout: settings.flareSolverrTimeout,
flareSolverrUrl: settings.flareSolverrUrl,
@@ -99,24 +108,18 @@ export const ServerSettings = () => {
});
const [mutateSettings] = requestManager.useUpdateServerSettings();
- const [serverAddress, setServerAddress] = useLocalStorage(
- 'serverBaseURL',
- import.meta.env.VITE_SERVER_URL_DEFAULT,
- );
-
- const handleServerAddressChange = (address: string) => {
- const serverBaseUrl = address.replaceAll(/(\/)+$/g, '');
- setServerAddress(serverBaseUrl);
- requestManager.reset();
- };
-
- const updateSetting = (
+ const updateSetting = async (
setting: Setting,
value: ServerSettingsType[Setting],
+ onCompletion?: (success: boolean) => void,
) => {
- mutateSettings({ variables: { input: { settings: { [setting]: value } } } }).catch((e) =>
- makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
- );
+ try {
+ await mutateSettings({ variables: { input: { settings: { [setting]: value } } } });
+ onCompletion?.(true);
+ } catch (e) {
+ makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e));
+ onCompletion?.(false);
+ }
};
const localSettings = useMemo(
@@ -128,12 +131,7 @@ export const ServerSettings = () => {
}
>
-
+
{
),
- [serverAddress, serverInformAvailableUpdate],
+ [serverInformAvailableUpdate],
);
const loading = areMetadataServerSettingsLoading || areServerSettingsLoading;
@@ -187,8 +185,8 @@ export const ServerSettings = () => {
}
const serverSettings = extractServerSettings(data!.settings);
- const authModeDisabled = !serverSettings.authUsername.trim() || !serverSettings.authPassword.trim();
-
+ const authModeDisabled = !serverSettings.authUsername?.trim() || !serverSettings.authPassword?.trim();
+ console.log('asdf', serverSettings.jwtTokenExpiry, serverSettings.jwtRefreshExpiry);
return (
{localSettings}
@@ -271,7 +269,19 @@ export const ServerSettings = () => {
settingName={t('settings.server.auth.label.title')}
value={serverSettings.authMode}
values={AUTH_MODES_SELECT_VALUES}
- handleChange={(mode) => updateSetting('authMode', mode)}
+ handleChange={(mode) => {
+ updateSetting('authMode', mode, (success) => {
+ if (!success) {
+ return;
+ }
+
+ if (mode !== AuthMode.UiLogin) {
+ AuthManager.removeTokens();
+ }
+
+ AuthManager.setAuthRequired(mode === AuthMode.UiLogin);
+ });
+ }}
disabled={authModeDisabled}
/>
{
validate={(value) => serverSettings.authMode === AuthMode.None || !!value.trim()}
handleChange={(authPassword) => updateSetting('authPassword', authPassword)}
/>
+ {serverSettings.authMode === AuthMode.UiLogin && (
+ <>
+ updateSetting('jwtAudience', audience)}
+ />
+
+ updateSetting('jwtTokenExpiry', dayjs.duration(expiry, 'minute').toISOString())
+ }
+ showSlider
+ />
+
+ updateSetting('jwtRefreshExpiry', dayjs.duration(expiry, 'day').toISOString())
+ }
+ showSlider
+ />
+ >
+ )}
{
const { t } = useTranslation();
@@ -86,11 +86,15 @@ export const SettingsTrackerCard = ({ tracker }: { tracker: TTrackerSearch }) =>
<>
onClick(popupState.open)}>
-
diff --git a/src/features/tracker/components/cards/TrackerActiveCard.tsx b/src/features/tracker/components/cards/TrackerActiveCard.tsx
index 3f415497..a6fcd66d 100644
--- a/src/features/tracker/components/cards/TrackerActiveCard.tsx
+++ b/src/features/tracker/components/cards/TrackerActiveCard.tsx
@@ -8,7 +8,6 @@
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
-import Avatar from '@mui/material/Avatar';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -44,6 +43,7 @@ import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
import { TrackRecordType } from '@/lib/graphql/generated/graphql.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { TTrackerBind, TTrackRecordBind } from '@/features/tracker/Tracker.types.ts';
+import { AvatarSpinner } from '@/base/components/AvatarSpinner.tsx';
const TrackerActiveLink = ({ children, url }: { children: React.ReactNode; url: string }) => (
@@ -203,11 +203,15 @@ const TrackerActiveHeader = ({
}
>
-
diff --git a/src/features/tracker/components/cards/TrackerUntrackedCard.tsx b/src/features/tracker/components/cards/TrackerUntrackedCard.tsx
index 0c840f06..9e7926ad 100644
--- a/src/features/tracker/components/cards/TrackerUntrackedCard.tsx
+++ b/src/features/tracker/components/cards/TrackerUntrackedCard.tsx
@@ -8,7 +8,6 @@
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
-import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import Stack from '@mui/material/Stack';
@@ -16,6 +15,7 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
import { CARD_STYLING } from '@/features/tracker/Tracker.constants.ts';
import { TTrackerBase } from '@/features/tracker/Tracker.types.ts';
+import { AvatarSpinner } from '@/base/components/AvatarSpinner.tsx';
export const TrackerUntrackedCard = ({
tracker,
@@ -35,11 +35,15 @@ export const TrackerUntrackedCard = ({
gap: 3,
}}
>
-