diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 50ea7c5a..416ce831 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -543,6 +543,72 @@ 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 }); + } + + 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( + req.body.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/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx index f4c570ae..dfaa5c41 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 00000000..36100a13 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal.tsx @@ -0,0 +1,269 @@ +import Alert from '@app/components/Common/Alert'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +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 { MediaServerType } from '@server/constants/server'; +import axios from 'axios'; +import { useCallback, useEffect, useRef, useState } 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', + errorMessage: 'Failed to initiate Quick Connect. Please try again.', + 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 [code, setCode] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isExpired, setIsExpired] = useState(false); + const [error, setError] = useState(null); + const pollingInterval = useRef(); + const isMounted = useRef(true); + const hasInitiated = useRef(false); + + const mediaServerName = + settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN + ? 'Jellyfin' + : 'Emby'; + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + }; + }, []); + + const linkWithQuickConnect = useCallback( + async (secret: string) => { + try { + await axios.post( + `/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin/quickconnect`, + { secret } + ); + if (!isMounted.current) return; + + onSave(); + onClose(); + } catch (error) { + if (!isMounted.current) return; + + let errorMessage = intl.formatMessage(messages.errorMessage); + if (error?.response?.status === 422) { + errorMessage = intl.formatMessage(messages.errorExists); + } + + setError(errorMessage); + setHasError(true); + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + } + }, + [user, onSave, onClose, intl] + ); + + const startPolling = useCallback( + (secret: string) => { + pollingInterval.current = setInterval(async () => { + try { + const response = await axios.get( + `/api/v1/auth/jellyfin/quickconnect/check`, + { + params: { secret }, + } + ); + + if (!isMounted.current) { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + return; + } + + if (response.data.authenticated) { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + await linkWithQuickConnect(secret); + } + } catch (error) { + if (!isMounted.current) return; + + if (error?.response?.status === 404) { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + setIsExpired(true); + } + } + }, 2000); + }, + [linkWithQuickConnect] + ); + + const initiateQuickConnect = useCallback(async () => { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + + setIsLoading(true); + setHasError(false); + setIsExpired(false); + setError(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); + setError(intl.formatMessage(messages.errorMessage)); + } + }, [startPolling, intl]); + + useEffect(() => { + if (show && !hasInitiated.current) { + hasInitiated.current = true; + initiateQuickConnect(); + } + }, [show, initiateQuickConnect]); + + const handleSwitchToPassword = () => { + if (pollingInterval.current) { + clearInterval(pollingInterval.current); + } + onClose(); + onSwitchToPassword(); + }; + + return ( + + + {error && ( +
+ {error} +
+ )} + + {isLoading && ( +
+ +
+ )} + + {!isLoading && !hasError && !isExpired && ( +
+

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

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

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

+

{error}

+
+
+ )} + + {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 ae7e6342..5e835a37 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); + }} /> );