From 64f05bcad6956f7e8cbe3fdf5f430af1f30ddd6d Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 22 Feb 2025 11:16:25 -0500 Subject: [PATCH] feat: add linked accounts page (#883) * feat(linked-accounts): create page and display linked media server accounts * feat(dropdown): add new shared Dropdown component Adds a shared component for plain dropdown menus, based on the headlessui Menu component. Updates the `ButtonWithDropdown` component to use the same inner components, ensuring that the only difference between the two components is the trigger button, and both use the same components for the actual dropdown menu. * refactor(modal): add support for configuring button props * feat(linked-accounts): add support for linking/unlinking jellyfin accounts * feat(linked-accounts): support linking/unlinking plex accounts * fix(linked-accounts): probibit unlinking accounts in certain cases Prevents the primary administrator from unlinking their media server account (which would break sync). Additionally, prevents users without a configured local email and password from unlinking their accounts, which would render them unable to log in. * feat(linked-accounts): support linking/unlinking emby accounts * style(dropdown): improve style class application * fix(server): improve error handling and API spec * style(usersettings): improve syntax & performance of user password checks * style(linkedaccounts): use applicationName in page description * fix(linkedaccounts): resolve typo * refactor(app): remove RequestError class --- overseerr-api.yml | 98 +++++++ server/api/jellyfin.ts | 6 +- server/api/plexapi.ts | 4 +- server/constants/error.ts | 1 + server/entity/User.ts | 28 +- server/routes/user/usersettings.ts | 274 ++++++++++++++++- src/assets/services/jellyfin-icon.svg | 24 ++ .../Common/ButtonWithDropdown/index.tsx | 132 ++------- src/components/Common/Dropdown/index.tsx | 117 ++++++++ src/components/Common/Modal/index.tsx | 16 +- .../LinkJellyfinModal.tsx | 188 ++++++++++++ .../UserLinkedAccountsSettings/index.tsx | 276 ++++++++++++++++++ .../UserProfile/UserSettings/index.tsx | 6 + src/hooks/useUser.ts | 4 +- src/i18n/locale/en.json | 20 ++ .../profile/settings/linked-accounts.tsx | 13 + .../[userId]/settings/linked-accounts.tsx | 16 + 17 files changed, 1095 insertions(+), 128 deletions(-) create mode 100644 src/assets/services/jellyfin-icon.svg create mode 100644 src/components/Common/Dropdown/index.tsx create mode 100644 src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx create mode 100644 src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx create mode 100644 src/pages/profile/settings/linked-accounts.tsx create mode 100644 src/pages/users/[userId]/settings/linked-accounts.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 641ce5d7c..a713a5a19 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4423,6 +4423,104 @@ paths: responses: '204': description: User password updated + /user/{userId}/settings/linked-accounts/plex: + post: + summary: Link the provided Plex account to the current user + description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Plex account for a user + description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/linked-accounts/jellyfin: + post: + summary: Link the provided Jellyfin account to the current user + description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + password: + type: string + example: 'supersecret' + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Jellyfin account for a user + description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist /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 d0c4d7c74..27a7cf40f 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -95,7 +95,11 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem { class JellyfinAPI extends ExternalAPI { private userId?: string; - constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { + constructor( + jellyfinHost: string, + authToken?: string | null, + deviceId?: string | null + ) { let authHeaderVal: string; if (authToken) { authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`; diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 10d5d1d2a..977d367b1 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -92,7 +92,7 @@ class PlexAPI { plexSettings, timeout, }: { - plexToken?: string; + plexToken?: string | null; plexSettings?: PlexSettings; timeout?: number; }) { @@ -107,7 +107,7 @@ class PlexAPI { port: settingsPlex.port, https: settingsPlex.useSsl, timeout: timeout, - token: plexToken, + token: plexToken ?? undefined, authenticator: { authenticate: ( _plexApi, diff --git a/server/constants/error.ts b/server/constants/error.ts index 664f02c9c..daa02f1a1 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -7,5 +7,6 @@ export enum ApiErrorCode { NoAdminUser = 'NO_ADMIN_USER', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', + Unauthorized = 'UNAUTHORIZED', Unknown = 'UNKNOWN', } diff --git a/server/entity/User.ts b/server/entity/User.ts index c8753bfe9..91b667403 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -56,11 +56,11 @@ export class User { }) public email: string; - @Column({ nullable: true }) - public plexUsername?: string; + @Column({ type: 'varchar', nullable: true }) + public plexUsername?: string | null; - @Column({ nullable: true }) - public jellyfinUsername?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinUsername?: string | null; @Column({ nullable: true }) public username?: string; @@ -77,20 +77,20 @@ export class User { @Column({ type: 'integer', default: UserType.PLEX }) public userType: UserType; - @Column({ nullable: true, select: true }) - public plexId?: number; + @Column({ type: 'integer', nullable: true, select: true }) + public plexId?: number | null; - @Column({ nullable: true }) - public jellyfinUserId?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinUserId?: string | null; - @Column({ nullable: true, select: false }) - public jellyfinDeviceId?: string; + @Column({ type: 'varchar', nullable: true, select: false }) + public jellyfinDeviceId?: string | null; - @Column({ nullable: true, select: false }) - public jellyfinAuthToken?: string; + @Column({ type: 'varchar', nullable: true, select: false }) + public jellyfinAuthToken?: string | null; - @Column({ nullable: true, select: false }) - public plexToken?: string; + @Column({ type: 'varchar', nullable: true, select: false }) + public plexToken?: string | null; @Column({ type: 'integer', default: 0 }) public permissions = 0; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 24ca976ba..6ee0f8937 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,4 +1,7 @@ +import JellyfinAPI from '@server/api/jellyfin'; +import PlexTvAPI from '@server/api/plextv'; import { ApiErrorCode } from '@server/constants/error'; +import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; @@ -12,9 +15,23 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { ApiError } from '@server/types/error'; +import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; +import net from 'net'; import { canMakePermissionsChange } from '.'; +const isOwnProfile = (): Middleware => { + return (req, res, next) => { + if (req.user?.id !== Number(req.params.id)) { + return next({ + status: 403, + message: "You do not have permission to view this user's settings.", + }); + } + next(); + }; +}; + const isOwnProfileOrAdmin = (): Middleware => { const authMiddleware: Middleware = (req, res, next) => { if ( @@ -183,9 +200,8 @@ userSettingsRoutes.post< status: e.statusCode, message: e.errorCode, }); - } else { - return next({ status: 500, message: e.message }); } + return next({ status: 500, message: e.message }); } }); @@ -290,6 +306,260 @@ userSettingsRoutes.post< } }); +userSettingsRoutes.post<{ authToken: string }>( + '/linked-accounts/plex', + isOwnProfile(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return res.status(404).json({ code: ApiErrorCode.Unauthorized }); + } + // Make sure Plex login is enabled + if (settings.main.mediaServerType !== MediaServerType.PLEX) { + return res.status(500).json({ message: 'Plex login is disabled' }); + } + + // First we need to use this auth token to get the user's email from plex.tv + const plextv = new PlexTvAPI(req.body.authToken); + const account = await plextv.getUser(); + + // Do not allow linking of an already linked account + if (await userRepository.exist({ where: { plexId: account.id } })) { + return res.status(422).json({ + message: 'This Plex account is already linked to a Jellyseerr user', + }); + } + + const user = req.user; + + // Emails do not match + if (user.email !== account.email) { + return res.status(422).json({ + message: + 'This Plex account is registered under a different email address.', + }); + } + + // valid plex user found, link to current user + user.userType = UserType.PLEX; + user.plexId = account.id; + user.plexUsername = account.username; + user.plexToken = account.authToken; + await userRepository.save(user); + + return res.status(204).send(); + } +); + +userSettingsRoutes.delete<{ id: string }>( + '/linked-accounts/plex', + isOwnProfileOrAdmin(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + // Make sure Plex login is enabled + if (settings.main.mediaServerType !== MediaServerType.PLEX) { + return res.status(500).json({ message: 'Plex login is disabled' }); + } + + try { + const user = await userRepository + .createQueryBuilder('user') + .addSelect('user.password') + .where({ + id: Number(req.params.id), + }) + .getOne(); + + if (!user) { + return res.status(404).json({ message: 'User not found.' }); + } + + if (user.id === 1) { + return res.status(400).json({ + message: + 'Cannot unlink media server accounts for the primary administrator.', + }); + } + + if (!user.email || !user.password) { + return res.status(400).json({ + message: 'User does not have a local email or password set.', + }); + } + + user.userType = UserType.LOCAL; + user.plexId = null; + user.plexUsername = null; + user.plexToken = null; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + return res.status(500).json({ message: e.message }); + } + } +); + +userSettingsRoutes.post<{ username: string; password: string }>( + '/linked-accounts/jellyfin', + isOwnProfile(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return res.status(401).json({ code: ApiErrorCode.Unauthorized }); + } + // Make sure jellyfin login is enabled + if ( + settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY + ) { + return res + .status(500) + .json({ message: 'Jellyfin/Emby login is disabled' }); + } + + // Do not allow linking of an already linked account + if ( + await userRepository.exist({ + where: { jellyfinUsername: req.body.username }, + }) + ) { + return res.status(422).json({ + message: 'The specified account is already linked to a Jellyseerr user', + }); + } + + const hostname = getHostname(); + const deviceId = Buffer.from( + `BOT_overseerr_${req.user.username ?? ''}` + ).toString('base64'); + + const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId); + + const ip = req.ip; + let clientIp: string | undefined; + if (ip) { + if (net.isIPv4(ip)) { + clientIp = ip; + } else if (net.isIPv6(ip)) { + clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; + } + } + + try { + const account = await jellyfinserver.login( + req.body.username, + req.body.password, + clientIp + ); + + // Do not allow linking of an already linked account + if ( + await userRepository.exist({ + where: { jellyfinUserId: account.User.Id }, + }) + ) { + return res.status(422).json({ + message: + 'The specified account is already linked to a Jellyseerr user', + }); + } + + const user = req.user; + + // valid jellyfin user found, link to current user + 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 to user.', { + label: 'API', + ip: req.ip, + error: e, + }); + if ( + e instanceof ApiError && + e.errorCode === ApiErrorCode.InvalidCredentials + ) { + return res.status(401).json({ code: e.errorCode }); + } + + return res.status(500).send(); + } + } +); + +userSettingsRoutes.delete<{ id: string }>( + '/linked-accounts/jellyfin', + isOwnProfileOrAdmin(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + // Make sure jellyfin login is enabled + if ( + settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY + ) { + return res + .status(500) + .json({ message: 'Jellyfin/Emby login is disabled' }); + } + + try { + const user = await userRepository + .createQueryBuilder('user') + .addSelect('user.password') + .where({ + id: Number(req.params.id), + }) + .getOne(); + + if (!user) { + return res.status(404).json({ message: 'User not found.' }); + } + + if (user.id === 1) { + return res.status(400).json({ + message: + 'Cannot unlink media server accounts for the primary administrator.', + }); + } + + if (!user.email || !user.password) { + return res.status(400).json({ + message: 'User does not have a local email or password set.', + }); + } + + user.userType = UserType.LOCAL; + user.jellyfinUserId = null; + user.jellyfinUsername = null; + user.jellyfinAuthToken = null; + user.jellyfinDeviceId = null; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + return res.status(500).json({ message: e.message }); + } + } +); + userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), diff --git a/src/assets/services/jellyfin-icon.svg b/src/assets/services/jellyfin-icon.svg new file mode 100644 index 000000000..d4d7f0172 --- /dev/null +++ b/src/assets/services/jellyfin-icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + icon-transparent + + + + + diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index bf98cdae9..36c793646 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -1,77 +1,29 @@ -import useClickOutside from '@app/hooks/useClickOutside'; +import Dropdown from '@app/components/Common/Dropdown'; import { withProperties } from '@app/utils/typeHelpers'; -import { Transition } from '@headlessui/react'; +import { Menu } from '@headlessui/react'; import { ChevronDownIcon } from '@heroicons/react/24/solid'; -import type { - AnchorHTMLAttributes, - ButtonHTMLAttributes, - RefObject, -} from 'react'; -import { Fragment, useRef, useState } from 'react'; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react'; -interface DropdownItemProps extends AnchorHTMLAttributes { - buttonType?: 'primary' | 'ghost'; -} - -const DropdownItem = ({ - children, - buttonType = 'primary', - ...props -}: DropdownItemProps) => { - let styleClass = 'button-md text-white'; - - switch (buttonType) { - case 'ghost': - styleClass += - ' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white'; - break; - default: - styleClass += - ' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white'; - } - return ( - - {children} - - ); -}; - -interface ButtonWithDropdownProps { +type ButtonWithDropdownProps = { text: React.ReactNode; dropdownIcon?: React.ReactNode; buttonType?: 'primary' | 'ghost'; -} -interface ButtonProps - extends ButtonHTMLAttributes, - ButtonWithDropdownProps { - as?: 'button'; -} -interface AnchorProps - extends AnchorHTMLAttributes, - ButtonWithDropdownProps { - as: 'a'; -} +} & ( + | ({ as?: 'button' } & ButtonHTMLAttributes) + | ({ as: 'a' } & AnchorHTMLAttributes) +); const ButtonWithDropdown = ({ - as, text, children, dropdownIcon, className, buttonType = 'primary', ...props -}: ButtonProps | AnchorProps) => { - const [isOpen, setIsOpen] = useState(false); - const buttonRef = useRef(null); - useClickOutside(buttonRef, () => setIsOpen(false)); - +}: ButtonWithDropdownProps) => { const styleClasses = { mainButtonClasses: 'button-md text-white border', dropdownSideButtonClasses: 'button-md border', - dropdownClasses: 'button-md', }; switch (buttonType) { @@ -79,72 +31,40 @@ const ButtonWithDropdown = ({ styleClasses.mainButtonClasses += ' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses; - styleClasses.dropdownClasses += - ' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur'; break; default: styleClasses.mainButtonClasses += ' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue'; styleClasses.dropdownSideButtonClasses += ' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue'; - styleClasses.dropdownClasses += ' bg-indigo-600 p-1'; } + const TriggerElement = props.as ?? 'button'; + return ( - - {as === 'a' ? ( - } - {...(props as AnchorHTMLAttributes)} - > - {text} - - ) : ( - - )} + + )} + > + {text} + {children && ( - - -
-
-
{children}
-
-
-
+ + {children}
)} - +
); }; -export default withProperties(ButtonWithDropdown, { Item: DropdownItem }); +export default withProperties(ButtonWithDropdown, { Item: Dropdown.Item }); diff --git a/src/components/Common/Dropdown/index.tsx b/src/components/Common/Dropdown/index.tsx new file mode 100644 index 000000000..74ce79f2e --- /dev/null +++ b/src/components/Common/Dropdown/index.tsx @@ -0,0 +1,117 @@ +import { withProperties } from '@app/utils/typeHelpers'; +import { Menu, Transition } from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/24/solid'; +import { + Fragment, + useRef, + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + type HTMLAttributes, +} from 'react'; + +interface DropdownItemProps extends AnchorHTMLAttributes { + buttonType?: 'primary' | 'ghost'; +} + +const DropdownItem = ({ + children, + buttonType = 'primary', + ...props +}: DropdownItemProps) => { + return ( + + + {children} + + + ); +}; + +type DropdownItemsProps = HTMLAttributes & { + dropdownType: 'primary' | 'ghost'; +}; + +const DropdownItems = ({ + children, + className, + dropdownType, + ...props +}: DropdownItemsProps) => { + return ( + + +
{children}
+
+
+ ); +}; + +interface DropdownProps extends ButtonHTMLAttributes { + text: React.ReactNode; + dropdownIcon?: React.ReactNode; + buttonType?: 'primary' | 'ghost'; +} + +const Dropdown = ({ + text, + children, + dropdownIcon, + className, + buttonType = 'primary', + ...props +}: DropdownProps) => { + const buttonRef = useRef(null); + + return ( + + + {text} + {children && (dropdownIcon ? dropdownIcon : )} + + {children && ( + {children} + )} + + ); +}; +export default withProperties(Dropdown, { + Item: DropdownItem, + Items: DropdownItems, +}); diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 8cebf06f7..ca7be6543 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -29,11 +29,16 @@ interface ModalProps { secondaryDisabled?: boolean; tertiaryDisabled?: boolean; tertiaryButtonType?: ButtonType; + okButtonProps?: React.ButtonHTMLAttributes; + cancelButtonProps?: React.ButtonHTMLAttributes; + secondaryButtonProps?: React.ButtonHTMLAttributes; + tertiaryButtonProps?: React.ButtonHTMLAttributes; disableScrollLock?: boolean; backgroundClickable?: boolean; loading?: boolean; backdrop?: string; children?: React.ReactNode; + dialogClass?: string; } const Modal = React.forwardRef( @@ -61,6 +66,11 @@ const Modal = React.forwardRef( loading = false, onTertiary, backdrop, + dialogClass, + okButtonProps, + cancelButtonProps, + secondaryButtonProps, + tertiaryButtonProps, }, parentRef ) => { @@ -106,7 +116,7 @@ const Modal = React.forwardRef( ( className="ml-3" disabled={okDisabled} data-testid="modal-ok-button" + {...okButtonProps} > {okText ? okText : 'Ok'} @@ -200,6 +211,7 @@ const Modal = React.forwardRef( className="ml-3" disabled={secondaryDisabled} data-testid="modal-secondary-button" + {...secondaryButtonProps} > {secondaryText} @@ -210,6 +222,7 @@ const Modal = React.forwardRef( onClick={onTertiary} className="ml-3" disabled={tertiaryDisabled} + {...tertiaryButtonProps} > {tertiaryText} @@ -220,6 +233,7 @@ const Modal = React.forwardRef( onClick={onCancel} className="ml-3 sm:ml-0" data-testid="modal-cancel-button" + {...cancelButtonProps} > {cancelText ? cancelText diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx new file mode 100644 index 000000000..4872e7c15 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx @@ -0,0 +1,188 @@ +import Alert from '@app/components/Common/Alert'; +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 { Field, Form, Formik } from 'formik'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import * as Yup from 'yup'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.LinkJellyfinModal', + { + title: 'Link {mediaServerName} Account', + description: + 'Enter your {mediaServerName} credentials to link your account with {applicationName}.', + username: 'Username', + password: 'Password', + usernameRequired: 'You must provide a username', + passwordRequired: 'You must provide a password', + saving: 'Adding…', + save: 'Link', + errorUnauthorized: + 'Unable to connect to {mediaServerName} using your credentials', + errorExists: 'This account is already linked to a {applicationName} user', + errorUnknown: 'An unknown error occurred', + } +); + +interface LinkJellyfinModalProps { + show: boolean; + onClose: () => void; + onSave: () => void; +} + +const LinkJellyfinModal: React.FC = ({ + show, + onClose, + onSave, +}) => { + const intl = useIntl(); + const settings = useSettings(); + const { user } = useUser(); + const [error, setError] = useState(null); + + const JellyfinLoginSchema = Yup.object().shape({ + username: Yup.string().required( + intl.formatMessage(messages.usernameRequired) + ), + password: Yup.string().required( + intl.formatMessage(messages.passwordRequired) + ), + }); + + const applicationName = settings.currentSettings.applicationTitle; + const mediaServerName = + settings.currentSettings.mediaServerType === MediaServerType.EMBY + ? 'Emby' + : 'Jellyfin'; + + return ( + + { + try { + setError(null); + const res = await fetch( + `/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + }), + } + ); + if (!res.ok) { + if (res.status === 401) { + setError( + intl.formatMessage(messages.errorUnauthorized, { + mediaServerName, + }) + ); + } else if (res.status === 422) { + setError( + intl.formatMessage(messages.errorExists, { applicationName }) + ); + } else { + setError(intl.formatMessage(messages.errorUnknown)); + } + } else { + onSave(); + } + } catch (e) { + setError(intl.formatMessage(messages.errorUnknown)); + } + }} + > + {({ errors, touched, handleSubmit, isSubmitting, isValid }) => { + return ( + { + setError(null); + onClose(); + }} + okButtonType="primary" + okButtonProps={{ type: 'submit', form: 'link-jellyfin-account' }} + okText={ + isSubmitting + ? intl.formatMessage(messages.saving) + : intl.formatMessage(messages.save) + } + okDisabled={isSubmitting || !isValid} + onOk={() => handleSubmit()} + title={intl.formatMessage(messages.title, { mediaServerName })} + dialogClass="sm:max-w-lg" + > + + + ); + }} + + + ); +}; + +export default LinkJellyfinModal; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx new file mode 100644 index 000000000..c83a15790 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -0,0 +1,276 @@ +import EmbyLogo from '@app/assets/services/emby-icon-only.svg'; +import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg'; +import PlexLogo from '@app/assets/services/plex.svg'; +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 useSettings from '@app/hooks/useSettings'; +import { Permission, UserType, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import PlexOAuth from '@app/utils/plex'; +import { TrashIcon } from '@heroicons/react/24/solid'; +import { MediaServerType } from '@server/constants/server'; +import { useRouter } from 'next/router'; +import { useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; +import LinkJellyfinModal from './LinkJellyfinModal'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserLinkedAccountsSettings', + { + linkedAccounts: 'Linked Accounts', + linkedAccountsHint: + 'These external accounts are linked to your {applicationName} account.', + noLinkedAccounts: + 'You do not have any external accounts linked to your account.', + noPermissionDescription: + "You do not have permission to modify this user's linked accounts.", + plexErrorUnauthorized: 'Unable to connect to Plex using your credentials', + plexErrorExists: 'This account is already linked to a Plex user', + errorUnknown: 'An unknown error occurred', + deleteFailed: 'Unable to delete linked account.', + } +); + +const plexOAuth = new PlexOAuth(); + +enum LinkedAccountType { + Plex = 'Plex', + Jellyfin = 'Jellyfin', + Emby = 'Emby', +} + +type LinkedAccount = { + type: LinkedAccountType; + username: string; +}; + +const UserLinkedAccountsSettings = () => { + const intl = useIntl(); + const settings = useSettings(); + const router = useRouter(); + const { user: currentUser } = useUser(); + const { + user, + hasPermission, + revalidate: revalidateUser, + } = useUser({ id: Number(router.query.userId) }); + const { data: passwordInfo } = useSWR<{ hasPassword: boolean }>( + user ? `/api/v1/user/${user?.id}/settings/password` : null + ); + const [showJellyfinModal, setShowJellyfinModal] = useState(false); + const [error, setError] = useState(null); + + const applicationName = settings.currentSettings.applicationTitle; + + const accounts: LinkedAccount[] = useMemo(() => { + const accounts: LinkedAccount[] = []; + if (!user) return accounts; + if (user.userType === UserType.PLEX && user.plexUsername) + accounts.push({ + type: LinkedAccountType.Plex, + username: user.plexUsername, + }); + if (user.userType === UserType.EMBY && user.jellyfinUsername) + accounts.push({ + type: LinkedAccountType.Emby, + username: user.jellyfinUsername, + }); + if (user.userType === UserType.JELLYFIN && user.jellyfinUsername) + accounts.push({ + type: LinkedAccountType.Jellyfin, + username: user.jellyfinUsername, + }); + return accounts; + }, [user]); + + const linkPlexAccount = async () => { + setError(null); + try { + const authToken = await plexOAuth.login(); + const res = await fetch( + `/api/v1/user/${user?.id}/settings/linked-accounts/plex`, + { + method: 'POST', + body: JSON.stringify({ authToken }), + } + ); + if (!res.ok) { + if (res.status === 401) { + setError(intl.formatMessage(messages.plexErrorUnauthorized)); + } else if (res.status === 422) { + setError(intl.formatMessage(messages.plexErrorExists)); + } else { + setError(intl.formatMessage(messages.errorUnknown)); + } + } else { + await revalidateUser(); + } + } catch (e) { + setError(intl.formatMessage(messages.errorUnknown)); + } + }; + + const linkable = [ + { + name: 'Plex', + action: () => { + plexOAuth.preparePopup(); + setTimeout(() => linkPlexAccount(), 1500); + }, + hide: + settings.currentSettings.mediaServerType !== MediaServerType.PLEX || + accounts.some((a) => a.type === LinkedAccountType.Plex), + }, + { + name: 'Jellyfin', + action: () => setShowJellyfinModal(true), + hide: + settings.currentSettings.mediaServerType !== MediaServerType.JELLYFIN || + accounts.some((a) => a.type === LinkedAccountType.Jellyfin), + }, + { + name: 'Emby', + action: () => setShowJellyfinModal(true), + hide: + settings.currentSettings.mediaServerType !== MediaServerType.EMBY || + accounts.some((a) => a.type === LinkedAccountType.Emby), + }, + ].filter((l) => !l.hide); + + const deleteRequest = async (account: string) => { + try { + const res = await fetch( + `/api/v1/user/${user?.id}/settings/linked-accounts/${account}`, + { method: 'DELETE' } + ); + if (!res.ok) throw new Error(); + } catch { + setError(intl.formatMessage(messages.deleteFailed)); + } + + await revalidateUser(); + }; + + if ( + currentUser?.id !== user?.id && + hasPermission(Permission.ADMIN) && + currentUser?.id !== 1 + ) { + return ( + <> +
+

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

+
+ + + ); + } + + const enableMediaServerUnlink = user?.id !== 1 && passwordInfo?.hasPassword; + + return ( + <> + +
+
+

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

+
+ {intl.formatMessage(messages.linkedAccountsHint, { + applicationName, + })} +
+
+ {currentUser?.id === user?.id && !!linkable.length && ( +
+ + {linkable.map(({ name, action }) => ( + + {name} + + ))} + +
+ )} +
+ {error && } + {accounts.length ? ( +
    + {accounts.map((acct, i) => ( +
  • +
    + {acct.type === LinkedAccountType.Plex ? ( +
    + +
    + ) : acct.type === LinkedAccountType.Emby ? ( + + ) : ( + + )} +
    +
    +
    + {acct.type} +
    +
    + {acct.username} +
    +
    +
    + {enableMediaServerUnlink && ( + { + deleteRequest( + acct.type === LinkedAccountType.Plex ? 'plex' : 'jellyfin' + ); + }} + confirmText={intl.formatMessage(globalMessages.areyousure)} + > + + {intl.formatMessage(globalMessages.delete)} + + )} +
  • + ))} +
+ ) : ( +
+

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

+
+ )} + + setShowJellyfinModal(false)} + onSave={() => { + setShowJellyfinModal(false); + revalidateUser(); + }} + /> + + ); +}; + +export default UserLinkedAccountsSettings; diff --git a/src/components/UserProfile/UserSettings/index.tsx b/src/components/UserProfile/UserSettings/index.tsx index 72d237b97..2072285c2 100644 --- a/src/components/UserProfile/UserSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/index.tsx @@ -18,6 +18,7 @@ import useSWR from 'swr'; const messages = defineMessages('components.UserProfile.UserSettings', { menuGeneralSettings: 'General', menuChangePass: 'Password', + menuLinkedAccounts: 'Linked Accounts', menuNotifications: 'Notifications', menuPermissions: 'Permissions', unauthorizedDescription: @@ -63,6 +64,11 @@ const UserSettings = ({ children }: UserSettingsProps) => { currentUser?.id !== user?.id && hasPermission(Permission.ADMIN, user?.permissions ?? 0)), }, + { + text: intl.formatMessage(messages.menuLinkedAccounts), + route: '/settings/linked-accounts', + regex: /\/settings\/linked-accounts/, + }, { text: intl.formatMessage(messages.menuNotifications), route: data?.emailEnabled diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index f60b402c1..2a14ad1d5 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -11,8 +11,8 @@ export type { PermissionCheckOptions }; export interface User { id: number; warnings: string[]; - plexUsername?: string; - jellyfinUsername?: string; + plexUsername?: string | null; + jellyfinUsername?: string | null; username?: string; displayName: string; email: string; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index e38c70c64..2b0b3681c 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1275,6 +1275,17 @@ "components.UserProfile.ProfileHeader.profile": "View Profile", "components.UserProfile.ProfileHeader.settings": "Edit Settings", "components.UserProfile.ProfileHeader.userid": "User ID: {userid}", + "components.UserProfile.UserSettings.LinkJellyfinModal.description": "Enter your {mediaServerName} credentials to link your account with {applicationName}.", + "components.UserProfile.UserSettings.LinkJellyfinModal.errorExists": "This account is already linked to a {applicationName} user", + "components.UserProfile.UserSettings.LinkJellyfinModal.errorUnauthorized": "Unable to connect to {mediaServerName} using your credentials", + "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.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.UserGeneralSettings.accounttype": "Account Type", "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin", "components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language", @@ -1315,6 +1326,14 @@ "components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID", "components.UserProfile.UserSettings.UserGeneralSettings.validationemailformat": "Valid email required", "components.UserProfile.UserSettings.UserGeneralSettings.validationemailrequired": "Email required", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.deleteFailed": "Unable to delete linked account.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.errorUnknown": "An unknown error occurred", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccounts": "Linked Accounts", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccountsHint": "These external accounts are linked to your {applicationName} account.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.noLinkedAccounts": "You do not have any external accounts linked to your account.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.noPermissionDescription": "You do not have permission to modify this user's linked accounts.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorExists": "This account is already linked to a Plex user", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorUnauthorized": "Unable to connect to Plex using your credentials", "components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default", "components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID", "components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The multi-digit ID number associated with your user account", @@ -1377,6 +1396,7 @@ "components.UserProfile.UserSettings.UserPermissions.unauthorizedDescription": "You cannot modify your own permissions.", "components.UserProfile.UserSettings.menuChangePass": "Password", "components.UserProfile.UserSettings.menuGeneralSettings": "General", + "components.UserProfile.UserSettings.menuLinkedAccounts": "Linked Accounts", "components.UserProfile.UserSettings.menuNotifications": "Notifications", "components.UserProfile.UserSettings.menuPermissions": "Permissions", "components.UserProfile.UserSettings.unauthorizedDescription": "You do not have permission to modify this user's settings.", diff --git a/src/pages/profile/settings/linked-accounts.tsx b/src/pages/profile/settings/linked-accounts.tsx new file mode 100644 index 000000000..cd7521099 --- /dev/null +++ b/src/pages/profile/settings/linked-accounts.tsx @@ -0,0 +1,13 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserLinkedAccountsSettings from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings'; +import type { NextPage } from 'next'; + +const UserSettingsLinkedAccountsPage: NextPage = () => { + return ( + + + + ); +}; + +export default UserSettingsLinkedAccountsPage; diff --git a/src/pages/users/[userId]/settings/linked-accounts.tsx b/src/pages/users/[userId]/settings/linked-accounts.tsx new file mode 100644 index 000000000..51b4ff24f --- /dev/null +++ b/src/pages/users/[userId]/settings/linked-accounts.tsx @@ -0,0 +1,16 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserLinkedAccountsSettings from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const UserLinkedAccountsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserLinkedAccountsPage;