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, }} > -