diff --git a/seerr-api.yml b/seerr-api.yml index bf9d882712..7d850210ff 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -3984,6 +3984,85 @@ paths: required: - username - password + /auth/jellyfin/quickconnect/initiate: + post: + summary: Initiate Jellyfin Quick Connect + description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server. + security: [] + tags: + - auth + responses: + '200': + description: Quick Connect session initiated + content: + application/json: + schema: + type: object + properties: + code: + type: string + example: '123456' + secret: + type: string + example: 'abc123def456' + '500': + description: Failed to initiate Quick Connect + /auth/jellyfin/quickconnect/check: + get: + summary: Check Quick Connect authorization status + description: Checks if the Quick Connect code has been authorized by the user. + security: [] + tags: + - auth + parameters: + - in: query + name: secret + required: true + schema: + type: string + description: The secret returned from the initiate endpoint + responses: + '200': + description: Authorization status returned + content: + application/json: + schema: + type: object + properties: + authenticated: + type: boolean + example: false + '404': + description: Quick Connect session not found or expired + /auth/jellyfin/quickconnect/authenticate: + post: + summary: Authenticate with Quick Connect + description: Completes the Quick Connect authentication flow and creates a user session. + security: [] + tags: + - auth + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + secret: + type: string + required: + - secret + responses: + '200': + description: Successfully authenticated + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '403': + description: Quick Connect not authorized or access denied + '500': + description: Authentication failed /auth/local: post: summary: Sign in using a local account @@ -4913,6 +4992,38 @@ paths: description: Unlink request invalid '404': description: User does not exist + /user/{userId}/settings/linked-accounts/jellyfin/quickconnect: + post: + summary: Link Jellyfin/Emby account with Quick Connect + description: Links a Jellyfin/Emby account to the user's profile using Quick Connect authentication + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + secret: + type: string + required: + - secret + responses: + '204': + description: Account successfully linked + '401': + description: Unauthorized + '422': + description: Account already linked + '500': + description: Server error /user/{userId}/settings/notifications: get: summary: Get notification settings for a user diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index c5f79258b0..817a22f180 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -44,6 +44,23 @@ export interface JellyfinLoginResponse { AccessToken: string; } +export interface QuickConnectInitiateResponse { + Secret: string; + Code: string; + DateAdded: string; +} + +export interface QuickConnectStatusResponse { + Authenticated: boolean; + Secret: string; + Code: string; + DeviceId: string; + DeviceName: string; + AppName: string; + AppVersion: string; + DateAdded: string; +} + export interface JellyfinUserListResponse { users: JellyfinUserResponse[]; } @@ -216,6 +233,62 @@ class JellyfinAPI extends ExternalAPI { } } + public async initiateQuickConnect(): Promise { + try { + const response = await this.post( + '/QuickConnect/Initiate' + ); + + return response; + } catch (e) { + logger.error( + `Something went wrong while initiating Quick Connect: ${e.message}`, + { label: 'Jellyfin API', error: e.response?.status } + ); + + throw new ApiError(e.response?.status, ApiErrorCode.Unknown); + } + } + + public async checkQuickConnect( + secret: string + ): Promise { + try { + const response = await this.get( + '/QuickConnect/Connect', + { params: { secret } } + ); + + return response; + } catch (e) { + logger.error( + `Something went wrong while getting Quick Connect status: ${e.message}`, + { label: 'Jellyfin API', error: e.response?.status } + ); + + throw new ApiError(e.response?.status, ApiErrorCode.Unknown); + } + } + + public async authenticateQuickConnect( + secret: string + ): Promise { + try { + const response = await this.post( + '/Users/AuthenticateWithQuickConnect', + { Secret: secret } + ); + return response; + } catch (e) { + logger.error( + `Something went wrong while authenticating with Quick Connect: ${e.message}`, + { label: 'Jellyfin API', error: e.response?.status } + ); + + throw new ApiError(e.response?.status, ApiErrorCode.Unknown); + } + } + public setUserId(userId: string): void { this.userId = userId; return; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 9f670f3c80..d4f5a0d334 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -594,6 +594,189 @@ authRoutes.post('/jellyfin', async (req, res, next) => { } }); +authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => { + try { + const hostname = getHostname(); + const jellyfinServer = new JellyfinAPI( + hostname ?? '', + undefined, + undefined + ); + + const response = await jellyfinServer.initiateQuickConnect(); + + return res.status(200).json({ + code: response.Code, + secret: response.Secret, + }); + } catch (error) { + logger.error('Error initiating Jellyfin quick connect', { + label: 'Auth', + errorMessage: error.message, + }); + return next({ + status: 500, + message: 'Failed to initiate quick connect.', + }); + } +}); + +authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => { + const secret = req.query.secret as string; + + if ( + !secret || + typeof secret !== 'string' || + secret.length < 8 || + secret.length > 128 || + !/^[A-Fa-f0-9]+$/.test(secret) + ) { + return next({ + status: 400, + message: 'Invalid secret format', + }); + } + + try { + const hostname = getHostname(); + const jellyfinServer = new JellyfinAPI( + hostname ?? '', + undefined, + undefined + ); + + const response = await jellyfinServer.checkQuickConnect(secret); + + return res.status(200).json({ authenticated: response.Authenticated }); + } catch (e) { + return next({ + status: e.statusCode || 500, + message: 'Failed to check Quick Connect status', + }); + } +}); + +authRoutes.post( + '/jellyfin/quickconnect/authenticate', + async (req, res, next) => { + const settings = getSettings(); + const userRepository = getRepository(User); + const body = req.body as { secret?: string }; + + if ( + !body.secret || + typeof body.secret !== 'string' || + body.secret.length < 8 || + body.secret.length > 128 || + !/^[A-Fa-f0-9]+$/.test(body.secret) + ) { + return next({ + status: 400, + message: 'Secret required', + }); + } + + if ( + settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED || + !(await userRepository.count()) + ) { + return next({ + status: 403, + message: 'Quick Connect is not available during initial setup.', + }); + } + + try { + const hostname = getHostname(); + const jellyfinServer = new JellyfinAPI( + hostname ?? '', + undefined, + undefined + ); + + const account = await jellyfinServer.authenticateQuickConnect( + body.secret + ); + + let user = await userRepository.findOne({ + where: { jellyfinUserId: account.User.Id }, + }); + + const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).toString( + 'base64' + ); + + if (user) { + logger.info('Quick Connect sign-in from existing user', { + label: 'API', + ip: req.ip, + jellyfinUsername: account.User.Name, + userId: user.id, + }); + + user.jellyfinAuthToken = account.AccessToken; + user.jellyfinDeviceId = deviceId; + user.avatar = getUserAvatarUrl(user); + await userRepository.save(user); + } else if (!settings.main.newPlexLogin) { + logger.warn( + 'Failed Quick Connect sign-in attempt by unimported Jellyfin user', + { + label: 'API', + ip: req.ip, + jellyfinUserId: account.User.Id, + jellyfinUsername: account.User.Name, + } + ); + return next({ + status: 403, + message: 'Access denied.', + }); + } else { + logger.info( + 'Quick Connect sign-in from new Jellyfin user; creating new Seerr user', + { + label: 'API', + ip: req.ip, + jellyfinUsername: account.User.Name, + } + ); + + user = new User({ + email: account.User.Name, + jellyfinUsername: account.User.Name, + jellyfinUserId: account.User.Id, + jellyfinDeviceId: deviceId, + permissions: settings.main.defaultPermissions, + userType: + settings.main.mediaServerType === MediaServerType.JELLYFIN + ? UserType.JELLYFIN + : UserType.EMBY, + }); + user.avatar = getUserAvatarUrl(user); + await userRepository.save(user); + } + + // Set session + if (req.session) { + req.session.userId = user.id; + } + + return res.status(200).json(user?.filter() ?? {}); + } catch (e) { + logger.error('Quick Connect authentication failed', { + label: 'Auth', + error: e.message, + ip: req.ip, + }); + return next({ + status: e.statusCode || 500, + message: ApiErrorCode.InvalidCredentials, + }); + } + } +); + authRoutes.post('/local', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 50ea7c5a5f..4cf69dde47 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -543,6 +543,81 @@ userSettingsRoutes.delete<{ id: string }>( } ); +userSettingsRoutes.post<{ secret: string }>( + '/linked-accounts/jellyfin/quickconnect', + isOwnProfile(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return res.status(401).json({ code: ApiErrorCode.Unauthorized }); + } + + const secret = req.body.secret; + if ( + !secret || + typeof secret !== 'string' || + secret.length < 8 || + secret.length > 128 || + !/^[A-Fa-f0-9]+$/.test(secret) + ) { + return res.status(400).json({ message: 'Invalid secret format' }); + } + + if ( + settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY + ) { + return res + .status(500) + .json({ message: 'Jellyfin/Emby login is disabled' }); + } + + const hostname = getHostname(); + const jellyfinServer = new JellyfinAPI(hostname); + + try { + const account = await jellyfinServer.authenticateQuickConnect(secret); + + if ( + await userRepository.exist({ + where: { jellyfinUserId: account.User.Id }, + }) + ) { + return res.status(422).json({ + message: 'The specified account is already linked to a Seerr user', + }); + } + + const user = req.user; + const deviceId = Buffer.from( + `BOT_seerr_qc_link_${account.User.Id}` + ).toString('base64'); + + user.userType = + settings.main.mediaServerType === MediaServerType.EMBY + ? UserType.EMBY + : UserType.JELLYFIN; + user.jellyfinUserId = account.User.Id; + user.jellyfinUsername = account.User.Name; + user.jellyfinAuthToken = account.AccessToken; + user.jellyfinDeviceId = deviceId; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + logger.error('Failed to link account with Quick Connect.', { + label: 'API', + ip: req.ip, + error: e, + }); + + return res.status(500).send(); + } + } +); + userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 1cbd81f8dc..8f72f93a42 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -1,12 +1,17 @@ import Button from '@app/components/Common/Button'; import SensitiveInput from '@app/components/Common/SensitiveInput'; +import JellyfinQuickConnectModal from '@app/components/Login/JellyfinQuickConnectModal'; import useSettings from '@app/hooks/useSettings'; import defineMessages from '@app/utils/defineMessages'; -import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; +import { + ArrowLeftOnRectangleIcon, + QrCodeIcon, +} from '@heroicons/react/24/outline'; import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType, ServerType } from '@server/constants/server'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; +import { useCallback, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import * as Yup from 'yup'; @@ -25,6 +30,8 @@ const messages = defineMessages('components.Login', { signingin: 'Signing In…', signin: 'Sign In', forgotpassword: 'Forgot Password?', + quickconnect: 'Quick Connect', + quickconnecterror: 'Quick Connect failed. Please try again.', }); interface JellyfinLoginProps { @@ -32,13 +39,11 @@ interface JellyfinLoginProps { serverType?: MediaServerType; } -const JellyfinLogin: React.FC = ({ - revalidate, - serverType, -}) => { +const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => { const toasts = useToasts(); const intl = useIntl(); const settings = useSettings(); + const [showQuickConnect, setShowQuickConnect] = useState(false); const mediaServerFormatValues = { mediaServerName: @@ -49,6 +54,16 @@ const JellyfinLogin: React.FC = ({ : 'Media Server', }; + const handleQuickConnectError = useCallback( + (error: string) => { + toasts.addToast(error, { + autoDismiss: true, + appearance: 'error', + }); + }, + [toasts] + ); + const LoginSchema = Yup.object().shape({ username: Yup.string().required( intl.formatMessage(messages.validationusernamerequired) @@ -194,6 +209,30 @@ const JellyfinLogin: React.FC = ({ ); }} + +
+ +
+ + {showQuickConnect && ( + setShowQuickConnect(false)} + onAuthenticated={() => { + setShowQuickConnect(false); + revalidate(); + }} + onError={handleQuickConnectError} + mediaServerName={mediaServerFormatValues.mediaServerName} + /> + )} ); }; diff --git a/src/components/Login/JellyfinQuickConnectModal.tsx b/src/components/Login/JellyfinQuickConnectModal.tsx new file mode 100644 index 0000000000..381433a3fc --- /dev/null +++ b/src/components/Login/JellyfinQuickConnectModal.tsx @@ -0,0 +1,154 @@ +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import Modal from '@app/components/Common/Modal'; +import { useQuickConnect } from '@app/hooks/useQuickConnect'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import axios from 'axios'; +import { useCallback } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Login.JellyfinQuickConnectModal', { + title: 'Quick Connect', + subtitle: 'Sign in with Quick Connect', + instructions: 'Enter this code in your {mediaServerName} app', + waitingForAuth: 'Waiting for authorization...', + expired: 'Code Expired', + expiredMessage: 'This Quick Connect code has expired. Please try again.', + error: 'Error', + cancel: 'Cancel', + tryAgain: 'Try Again', +}); + +interface JellyfinQuickConnectModalProps { + onClose: () => void; + onAuthenticated: () => void; + onError: (error: string) => void; + mediaServerName: string; +} + +const JellyfinQuickConnectModal = ({ + onClose, + onAuthenticated, + onError, + mediaServerName, +}: JellyfinQuickConnectModalProps) => { + const intl = useIntl(); + + const authenticate = useCallback( + async (secret: string) => { + await axios.post('/api/v1/auth/jellyfin/quickconnect/authenticate', { + secret, + }); + onAuthenticated(); + onClose(); + }, + [onAuthenticated, onClose] + ); + + const { + code, + isLoading, + hasError, + isExpired, + errorMessage, + initiateQuickConnect, + cleanup, + } = useQuickConnect({ + show: true, + onSuccess: () => { + onAuthenticated(); + onClose(); + }, + onError, + authenticate, + }); + + const handleClose = () => { + cleanup(); + onClose(); + }; + + return ( + + + {isLoading && ( +
+ +
+ )} + + {!isLoading && !hasError && !isExpired && ( +
+

+ {intl.formatMessage(messages.instructions, { + mediaServerName, + })} +

+ +
+
+ + {code} + +
+
+ +
+
+ +
+ {intl.formatMessage(messages.waitingForAuth)} +
+
+ )} + + {hasError && ( +
+
+

+ {intl.formatMessage(messages.error)} +

+

{errorMessage}

+
+
+ )} + + {isExpired && ( +
+
+

+ {intl.formatMessage(messages.expired)} +

+

+ {intl.formatMessage(messages.expiredMessage)} +

+
+
+ )} +
+
+ ); +}; + +export default JellyfinQuickConnectModal; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx index f4c570ae73..dfaa5c41af 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx @@ -1,9 +1,11 @@ import Alert from '@app/components/Common/Alert'; +import Button from '@app/components/Common/Button'; import Modal from '@app/components/Common/Modal'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; +import { QrCodeIcon } from '@heroicons/react/24/outline'; import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; @@ -27,6 +29,7 @@ const messages = defineMessages( 'Unable to connect to {mediaServerName} using your credentials', errorExists: 'This account is already linked to a {applicationName} user', errorUnknown: 'An unknown error occurred', + quickConnect: 'Use Quick Connect', } ); @@ -34,13 +37,15 @@ interface LinkJellyfinModalProps { show: boolean; onClose: () => void; onSave: () => void; + onSwitchToQuickConnect: () => void; } -const LinkJellyfinModal: React.FC = ({ +const LinkJellyfinModal = ({ show, onClose, onSave, -}) => { + onSwitchToQuickConnect, +}: LinkJellyfinModalProps) => { const intl = useIntl(); const settings = useSettings(); const { user } = useUser(); @@ -167,6 +172,20 @@ const LinkJellyfinModal: React.FC = ({
{errors.password}
)} +
+ +
); diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal.tsx new file mode 100644 index 0000000000..d3844028cc --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal.tsx @@ -0,0 +1,175 @@ +import Alert from '@app/components/Common/Alert'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import Modal from '@app/components/Common/Modal'; +import { useQuickConnect } from '@app/hooks/useQuickConnect'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import { MediaServerType } from '@server/constants/server'; +import axios from 'axios'; +import { useCallback } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal', + { + title: 'Link {mediaServerName} Account', + subtitle: 'Quick Connect', + instructions: 'Enter this code in your {mediaServerName} app', + waitingForAuth: 'Waiting for authorization...', + expired: 'Code Expired', + expiredMessage: 'This Quick Connect code has expired. Please try again.', + error: 'Error', + usePassword: 'Use Password Instead', + tryAgain: 'Try Again', + errorExists: 'This account is already linked', + } +); + +interface LinkJellyfinQuickConnectModalProps { + show: boolean; + onClose: () => void; + onSave: () => void; + onSwitchToPassword: () => void; +} + +const LinkJellyfinQuickConnectModal = ({ + show, + onClose, + onSave, + onSwitchToPassword, +}: LinkJellyfinQuickConnectModalProps) => { + const intl = useIntl(); + const settings = useSettings(); + const { user } = useUser(); + + const mediaServerName = + settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN + ? 'Jellyfin' + : 'Emby'; + + const authenticate = useCallback( + async (secret: string) => { + await axios.post( + `/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin/quickconnect`, + { secret } + ); + onSave(); + onClose(); + }, + [user, onSave, onClose] + ); + + const { + code, + isLoading, + hasError, + isExpired, + errorMessage, + initiateQuickConnect, + cleanup, + } = useQuickConnect({ + show: true, + onSuccess: () => { + onSave(); + onClose(); + }, + authenticate, + }); + + const handleSwitchToPassword = () => { + cleanup(); + onClose(); + onSwitchToPassword(); + }; + + return ( + + + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {isLoading && ( +
+ +
+ )} + + {!isLoading && !hasError && !isExpired && ( +
+

+ {intl.formatMessage(messages.instructions, { mediaServerName })} +

+ +
+
+ + {code} + +
+
+ +
+
+ +
+ {intl.formatMessage(messages.waitingForAuth)} +
+
+ )} + + {hasError && ( +
+
+

+ {intl.formatMessage(messages.error)} +

+

{errorMessage}

+
+
+ )} + + {isExpired && ( +
+
+

+ {intl.formatMessage(messages.expired)} +

+

+ {intl.formatMessage(messages.expiredMessage)} +

+
+
+ )} +
+
+ ); +}; + +export default LinkJellyfinQuickConnectModal; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx index ae7e634255..5e835a3734 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -5,6 +5,7 @@ import Alert from '@app/components/Common/Alert'; import ConfirmButton from '@app/components/Common/ConfirmButton'; import Dropdown from '@app/components/Common/Dropdown'; import PageTitle from '@app/components/Common/PageTitle'; +import LinkJellyfinQuickConnectModal from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal'; import useSettings from '@app/hooks/useSettings'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -63,6 +64,8 @@ const UserLinkedAccountsSettings = () => { user ? `/api/v1/user/${user?.id}/settings/password` : null ); const [showJellyfinModal, setShowJellyfinModal] = useState(false); + const [showJellyfinQuickConnectModal, setShowJellyfinQuickConnectModal] = + useState(false); const [error, setError] = useState(null); const applicationName = settings.currentSettings.applicationTitle; @@ -263,6 +266,23 @@ const UserLinkedAccountsSettings = () => { setShowJellyfinModal(false); revalidateUser(); }} + onSwitchToQuickConnect={() => { + setShowJellyfinModal(false); + setShowJellyfinQuickConnectModal(true); + }} + /> + + setShowJellyfinQuickConnectModal(false)} + onSave={() => { + setShowJellyfinQuickConnectModal(false); + revalidateUser(); + }} + onSwitchToPassword={() => { + setShowJellyfinQuickConnectModal(false); + setShowJellyfinModal(true); + }} /> ); diff --git a/src/hooks/useQuickConnect.ts b/src/hooks/useQuickConnect.ts new file mode 100644 index 0000000000..642ff2f159 --- /dev/null +++ b/src/hooks/useQuickConnect.ts @@ -0,0 +1,183 @@ +import defineMessages from '@app/utils/defineMessages'; +import axios from 'axios'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('hooks.useQuickConnect', { + errorMessage: 'Failed to initiate Quick Connect. Please try again.', +}); + +interface UseQuickConnectOptions { + show: boolean; + onSuccess: () => void; + onError?: (error: string) => void; + authenticate: (secret: string) => Promise; +} + +export const useQuickConnect = ({ + show, + onSuccess, + onError, + authenticate, +}: UseQuickConnectOptions) => { + const intl = useIntl(); + const [code, setCode] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isExpired, setIsExpired] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const pollingInterval = useRef(); + const isMounted = useRef(true); + const hasInitiated = useRef(false); + const errorCount = useRef(0); + + useEffect(() => { + isMounted.current = true; + const currentPollingInterval = pollingInterval.current; + + return () => { + isMounted.current = false; + if (currentPollingInterval) { + clearInterval(currentPollingInterval); + } + }; + }, []); + + useEffect(() => { + if (!show) { + hasInitiated.current = false; + } + }, [show]); + + const authenticateWithQuickConnect = useCallback( + async (secret: string) => { + try { + await authenticate(secret); + if (!isMounted.current) return; + onSuccess(); + } catch (error) { + if (!isMounted.current) return; + + const errMsg = + error?.response?.data?.message || + intl.formatMessage(messages.errorMessage); + setErrorMessage(errMsg); + setHasError(true); + onError?.(errMsg); + + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + } + }, + [authenticate, intl, onError, onSuccess] + ); + + const startPolling = useCallback( + (secret: string) => { + pollingInterval.current = setInterval(async () => { + try { + const response = await axios.get( + '/api/v1/auth/jellyfin/quickconnect/check', + { + params: { secret }, + } + ); + + errorCount.current = 0; + + if (!isMounted.current) { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + return; + } + + if (response.data.authenticated) { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + await authenticateWithQuickConnect(secret); + } + } catch (error) { + if (!isMounted.current) return; + + if (error?.response?.status === 404) { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + setIsExpired(true); + } else { + errorCount.current++; + if (errorCount.current >= 5) { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + setHasError(true); + const errorMessage = intl.formatMessage(messages.errorMessage); + setErrorMessage(errorMessage); + onError?.(errorMessage); + } + } + } + }, 2000); + }, + [authenticateWithQuickConnect, intl, onError] + ); + + const initiateQuickConnect = useCallback(async () => { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + + setIsLoading(true); + setHasError(false); + setIsExpired(false); + setErrorMessage(null); + + try { + const response = await axios.post( + '/api/v1/auth/jellyfin/quickconnect/initiate' + ); + + if (!isMounted.current) return; + + setCode(response.data.code); + setIsLoading(false); + startPolling(response.data.secret); + } catch (error) { + if (!isMounted.current) return; + + setHasError(true); + setIsLoading(false); + const errMessage = intl.formatMessage(messages.errorMessage); + setErrorMessage(errMessage); + onError?.(errMessage); + } + }, [startPolling, onError, intl]); + + useEffect(() => { + if (show && !hasInitiated.current) { + hasInitiated.current = true; + initiateQuickConnect(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [show]); + + const cleanup = useCallback(() => { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + }, []); + + return { + code, + isLoading, + hasError, + isExpired, + errorMessage, + initiateQuickConnect, + cleanup, + }; +}; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 34963834d2..864ae6ce7d 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -239,6 +239,17 @@ "components.Layout.VersionStatus.outofdate": "Out of Date", "components.Layout.VersionStatus.streamdevelop": "Seerr Develop", "components.Layout.VersionStatus.streamstable": "Seerr Stable", + "components.Login.JellyfinQuickConnectModal.authorizationFailed": "Quick Connect authorization failed.", + "components.Login.JellyfinQuickConnectModal.cancel": "Cancel", + "components.Login.JellyfinQuickConnectModal.error": "Error", + "components.Login.JellyfinQuickConnectModal.errorMessage": "Failed to initiate Quick Connect. Please try again.", + "components.Login.JellyfinQuickConnectModal.expired": "Code Expired", + "components.Login.JellyfinQuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.", + "components.Login.JellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app", + "components.Login.JellyfinQuickConnectModal.subtitle": "Sign in with Quick Connect", + "components.Login.JellyfinQuickConnectModal.title": "Quick Connect", + "components.Login.JellyfinQuickConnectModal.tryAgain": "Try Again", + "components.Login.JellyfinQuickConnectModal.waitingForAuth": "Waiting for authorization...", "components.Login.adminerror": "You must use an admin account to sign in.", "components.Login.back": "Go back", "components.Login.credentialerror": "The username or password is incorrect.", @@ -257,6 +268,8 @@ "components.Login.orsigninwith": "Or sign in with", "components.Login.password": "Password", "components.Login.port": "Port", + "components.Login.quickconnect": "Quick Connect", + "components.Login.quickconnecterror": "Quick Connect failed. Please try again.", "components.Login.save": "Add", "components.Login.saving": "Adding…", "components.Login.servertype": "Server Type", @@ -1383,11 +1396,23 @@ "components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred", "components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password", "components.UserProfile.UserSettings.LinkJellyfinModal.passwordRequired": "You must provide a password", + "components.UserProfile.UserSettings.LinkJellyfinModal.quickConnect": "Use Quick Connect", "components.UserProfile.UserSettings.LinkJellyfinModal.save": "Link", "components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…", "components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account", "components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username", "components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.error": "Error", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.errorExists": "This account is already linked", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.errorMessage": "Failed to initiate Quick Connect. Please try again.", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.expired": "Code Expired", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.subtitle": "Quick Connect", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.title": "Link {mediaServerName} Account", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.tryAgain": "Try Again", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.usePassword": "Use Password Instead", + "components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.waitingForAuth": "Waiting for authorization...", "components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type", "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin", "components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",