From 189dfb76f2f71c752bc7fcdb913b7ddc9ece8327 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Fri, 5 Jul 2024 16:10:23 -0400 Subject: [PATCH 01/13] feat(linked-accounts): create page and display linked media server accounts --- src/assets/services/jellyfin-icon.svg | 24 +++++ .../UserLinkedAccountsSettings/index.tsx | 95 +++++++++++++++++++ .../UserProfile/UserSettings/index.tsx | 6 ++ src/i18n/locale/en.json | 4 + .../profile/settings/linked-accounts.tsx | 13 +++ 5 files changed, 142 insertions(+) create mode 100644 src/assets/services/jellyfin-icon.svg create mode 100644 src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx create mode 100644 src/pages/profile/settings/linked-accounts.tsx 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/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx new file mode 100644 index 000000000..92848ef80 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -0,0 +1,95 @@ +import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg'; +import PlexLogo from '@app/assets/services/plex.svg'; +import PageTitle from '@app/components/Common/PageTitle'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserLinkedAccountsSettings', + { + linkedAccounts: 'Linked Accounts', + linkedAccountsHint: + 'These external accounts are linked to your Jellyseerr account.', + noLinkedAccounts: + 'You do not have any external accounts linked to your account.', + } +); + +const enum LinkedAccountType { + Plex, + Jellyfin, +} + +type LinkedAccount = { + type: LinkedAccountType; + username: string; +}; + +const UserLinkedAccountsSettings = () => { + const intl = useIntl(); + const { user } = useUser(); + + const accounts: LinkedAccount[] = [ + ...(user?.plexUsername + ? [{ type: LinkedAccountType.Plex, username: user?.plexUsername }] + : []), + ...(user?.jellyfinUsername + ? [{ type: LinkedAccountType.Jellyfin, username: user?.jellyfinUsername }] + : []), + ]; + + return ( + <> + +
+

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

+
+ {intl.formatMessage(messages.linkedAccountsHint)} +
+
+ {accounts.length ? ( + + ) : ( +
+

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

+
+ )} + + ); +}; + +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/i18n/locale/en.json b/src/i18n/locale/en.json index ca4ba3e66..5bce2acb2 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1301,6 +1301,9 @@ "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.linkedAccounts": "Linked Accounts", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccountsHint": "These external accounts are linked to your Jellyseerr account.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.noLinkedAccounts": "You do not have any external accounts linked to your account.", "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", @@ -1363,6 +1366,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; From b6c6245da1f29107d45c2221284926f4580dae36 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 30 Jul 2024 11:30:21 -0400 Subject: [PATCH 02/13] 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. --- .../Common/ButtonWithDropdown/index.tsx | 132 ++++------------- src/components/Common/Dropdown/index.tsx | 133 ++++++++++++++++++ 2 files changed, 159 insertions(+), 106 deletions(-) create mode 100644 src/components/Common/Dropdown/index.tsx 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..2d052398c --- /dev/null +++ b/src/components/Common/Dropdown/index.tsx @@ -0,0 +1,133 @@ +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) => { + 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} + + + ); +}; + +type DropdownItemsProps = HTMLAttributes & { + dropdownType: 'primary' | 'ghost'; +}; + +const DropdownItems = ({ + children, + className, + dropdownType, + ...props +}: DropdownItemsProps) => { + let dropdownClasses: string; + + switch (dropdownType) { + case 'ghost': + dropdownClasses = + 'bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur'; + break; + default: + dropdownClasses = 'bg-indigo-600 p-1'; + } + + 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); + let dropdownButtonClasses = 'button-md text-white border'; + + switch (buttonType) { + case 'ghost': + dropdownButtonClasses += + ' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; + break; + default: + dropdownButtonClasses += + ' 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'; + } + + return ( + + + {text} + {children && (dropdownIcon ? dropdownIcon : )} + + {children && ( + {children} + )} + + ); +}; +export default withProperties(Dropdown, { + Item: DropdownItem, + Items: DropdownItems, +}); From 2597657feee24a06e4e7b6b26a8905a9460ddcef Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 21 Jul 2024 09:38:33 -0400 Subject: [PATCH 03/13] refactor(modal): add support for configuring button props --- src/components/Common/Modal/index.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 From 557b584dcd5e6b5400091ec754cd7958349dc42a Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 21 Jul 2024 10:17:49 -0400 Subject: [PATCH 04/13] feat(linked-accounts): add support for linking/unlinking jellyfin accounts --- overseerr-api.yml | 48 +++++ server/api/jellyfin.ts | 6 +- server/entity/User.ts | 16 +- server/routes/user/usersettings.ts | 137 ++++++++++++++ .../LinkJellyfinModal.tsx | 172 ++++++++++++++++++ .../UserLinkedAccountsSettings/index.tsx | 129 +++++++++++-- src/hooks/useUser.ts | 2 +- src/i18n/locale/en.json | 13 ++ .../[userId]/settings/linked-accounts.tsx | 16 ++ src/types/error.ts | 11 ++ 10 files changed, 529 insertions(+), 21 deletions(-) create mode 100644 src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx create mode 100644 src/pages/users/[userId]/settings/linked-accounts.tsx create mode 100644 src/types/error.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index 06a7523d0..45a116fc2 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4383,6 +4383,54 @@ paths: responses: '204': description: User password updated + /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 + '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/entity/User.ts b/server/entity/User.ts index e4c8314c3..0e5ea7591 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -59,8 +59,8 @@ export class User { @Column({ nullable: true }) public plexUsername?: string; - @Column({ nullable: true }) - public jellyfinUsername?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinUsername?: string | null; @Column({ nullable: true }) public username?: string; @@ -80,14 +80,14 @@ export class User { @Column({ nullable: true, select: true }) public plexId?: number; - @Column({ nullable: true }) - public jellyfinUserId?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinUserId?: string | null; - @Column({ nullable: true }) - public jellyfinDeviceId?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinDeviceId?: string | null; - @Column({ nullable: true }) - public jellyfinAuthToken?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinAuthToken?: string | null; @Column({ nullable: true }) public plexToken?: string; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 24ca976ba..74f268a72 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,4 +1,6 @@ +import JellyfinAPI from '@server/api/jellyfin'; 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 +14,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 ( @@ -290,6 +306,127 @@ userSettingsRoutes.post< } }); +userSettingsRoutes.post<{ username: string; password: string }>( + '/linked-accounts/jellyfin', + isOwnProfile(), + async (req, res, next) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return next({ status: 401, message: 'Unauthorized' }); + } + // Make sure jellyfin login is enabled + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return res.status(500).json({ error: 'Jellyfin 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({ + error: + 'The specified Jellyfin 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; + 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({ + error: + 'The specified Jellyfin account is already linked to a Jellyseerr user', + }); + } + + const user = req.user; + + // valid jellyfin user found, link to current user + user.userType = 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 Jellyfin account to user.', { + label: 'API', + ip: req.ip, + error: e, + }); + if ( + e instanceof ApiError && + (e.errorCode == ApiErrorCode.InvalidCredentials || + e.errorCode == ApiErrorCode.NotAdmin) + ) + return next({ status: 401, message: 'Unauthorized' }); + + return next({ status: 500 }); + } + } +); + +userSettingsRoutes.delete<{ id: string }>( + '/linked-accounts/jellyfin', + isOwnProfileOrAdmin(), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + 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) { + next({ status: 500, message: e.message }); + } + } +); + 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 new file mode 100644 index 000000000..564a95f9c --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx @@ -0,0 +1,172 @@ +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 { RequestError } from '@app/types/error'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +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 Jellyfin Account', + description: + 'Enter your Jellyfin credentials to link your account with Jellyseerr.', + 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 Jellyfin using your credentials', + errorExists: 'This account is already linked to a Jellyseerr 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) + ), + }); + + 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) throw new RequestError(res); + + onSave(); + } catch (e) { + if (e instanceof RequestError && e.status == 401) { + setError(intl.formatMessage(messages.errorUnauthorized)); + } else if (e instanceof RequestError && e.status == 422) { + setError(intl.formatMessage(messages.errorExists)); + } else { + setError(intl.formatMessage(messages.errorServer)); + } + } + }} + > + {({ 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)} + 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 index 92848ef80..b1c9c0ddf 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -1,10 +1,19 @@ 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 { useUser } from '@app/hooks/useUser'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { TrashIcon } from '@heroicons/react/24/solid'; +import { MediaServerType } from '@server/constants/server'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; import { useIntl } from 'react-intl'; +import LinkJellyfinModal from './LinkJellyfinModal'; const messages = defineMessages( 'components.UserProfile.UserSettings.UserLinkedAccountsSettings', @@ -14,6 +23,9 @@ const messages = defineMessages( 'These external accounts are linked to your Jellyseerr 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.", + deleteFailed: 'Unable to delete linked account.', } ); @@ -29,7 +41,16 @@ type LinkedAccount = { const UserLinkedAccountsSettings = () => { const intl = useIntl(); - const { user } = useUser(); + const settings = useSettings(); + const router = useRouter(); + const { user: currentUser } = useUser(); + const { + user, + hasPermission, + revalidate: revalidateUser, + } = useUser({ id: Number(router.query.userId) }); + const [showJellyfinModal, setShowJellyfinModal] = useState(false); + const [error, setError] = useState(null); const accounts: LinkedAccount[] = [ ...(user?.plexUsername @@ -40,6 +61,50 @@ const UserLinkedAccountsSettings = () => { : []), ]; + const linkable = [ + { + name: 'Jellyfin', + action: () => setShowJellyfinModal(true), + hide: + settings.currentSettings.mediaServerType != MediaServerType.JELLYFIN || + accounts.find((a) => a.type == LinkedAccountType.Jellyfin), + }, + ].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)); + } + + revalidateUser(); + }; + + if ( + currentUser?.id !== user?.id && + hasPermission(Permission.ADMIN) && + currentUser?.id !== 1 + ) { + return ( + <> +
+

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

+
+ + + ); + } + return ( <> { user?.displayName, ]} /> -
-

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

-
- {intl.formatMessage(messages.linkedAccountsHint)} -
+
+
+

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

+
+ {intl.formatMessage(messages.linkedAccountsHint)} +
+
+ {currentUser?.id == user?.id && !!linkable.length && ( +
+ + {linkable.map(({ name, action }) => ( + + {name} + + ))} + +
+ )}
+ {error && ( + + {error} + + )} {accounts.length ? (
    - {accounts.map((acct) => ( -
  • + {accounts.map((acct, i) => ( +
  • {acct.type == LinkedAccountType.Plex ? (
    @@ -78,6 +164,18 @@ const UserLinkedAccountsSettings = () => { {acct.username}
    +
    + { + deleteRequest( + acct.type == LinkedAccountType.Plex ? 'plex' : 'jellyfin' + ); + }} + confirmText={intl.formatMessage(globalMessages.areyousure)} + > + + {intl.formatMessage(globalMessages.delete)} +
  • ))}
@@ -88,6 +186,15 @@ const UserLinkedAccountsSettings = () => {
)} + + setShowJellyfinModal(false)} + onSave={() => { + setShowJellyfinModal(false); + revalidateUser(); + }} + /> ); }; diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index f60b402c1..c3b16a2b9 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -12,7 +12,7 @@ export interface User { id: number; warnings: string[]; plexUsername?: string; - jellyfinUsername?: string; + jellyfinUsername?: string | null; username?: string; displayName: string; email: string; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 5bce2acb2..9e04bb820 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1261,6 +1261,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 Jellyfin credentials to link your account with Jellyseerr.", + "components.UserProfile.UserSettings.LinkJellyfinModal.errorExists": "This account is already linked to a Jellyseerr user", + "components.UserProfile.UserSettings.LinkJellyfinModal.errorUnauthorized": "Unable to connect to Jellyfin 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 Jellyfin 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", @@ -1301,9 +1312,11 @@ "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.linkedAccounts": "Linked Accounts", "components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccountsHint": "These external accounts are linked to your Jellyseerr 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.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", 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; diff --git a/src/types/error.ts b/src/types/error.ts new file mode 100644 index 000000000..29507b388 --- /dev/null +++ b/src/types/error.ts @@ -0,0 +1,11 @@ +export class RequestError extends Error { + status: number; + res: Response; + + constructor(res: Response) { + const status = res.status; + super(`Request failed with status code ${status}`); + this.status = status; + this.res = res; + } +} From 56a876f80dc0ef7fab6f83456661b6337de4cb4f Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 21 Jul 2024 12:50:14 -0400 Subject: [PATCH 05/13] feat(linked-accounts): support linking/unlinking plex accounts --- overseerr-api.yml | 46 +++++++++++ server/api/plexapi.ts | 4 +- server/entity/User.ts | 12 +-- server/routes/user/usersettings.ts | 76 +++++++++++++++++++ .../UserLinkedAccountsSettings/index.tsx | 48 +++++++++++- src/hooks/useUser.ts | 2 +- src/i18n/locale/en.json | 3 + 7 files changed, 180 insertions(+), 11 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 45a116fc2..6d9888aff 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4383,6 +4383,52 @@ 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 + '404': + description: User does not exist /user/{userId}/settings/linked-accounts/jellyfin: post: summary: Link the provided Jellyfin account to the current user 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/entity/User.ts b/server/entity/User.ts index 0e5ea7591..95e7c6675 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -56,8 +56,8 @@ export class User { }) public email: string; - @Column({ nullable: true }) - public plexUsername?: string; + @Column({ type: 'varchar', nullable: true }) + public plexUsername?: string | null; @Column({ type: 'varchar', nullable: true }) public jellyfinUsername?: string | null; @@ -77,8 +77,8 @@ 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({ type: 'varchar', nullable: true }) public jellyfinUserId?: string | null; @@ -89,8 +89,8 @@ export class User { @Column({ type: 'varchar', nullable: true }) public jellyfinAuthToken?: string | null; - @Column({ nullable: true }) - public plexToken?: string; + @Column({ type: 'varchar', nullable: true }) + 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 74f268a72..1b211b7ca 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,4 +1,5 @@ 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'; @@ -306,6 +307,81 @@ userSettingsRoutes.post< } }); +userSettingsRoutes.post<{ authToken: string }>( + '/linked-accounts/plex', + isOwnProfile(), + async (req, res, next) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return next({ status: 404, message: 'Unauthorized' }); + } + // Make sure Plex login is enabled + if (settings.main.mediaServerType !== MediaServerType.PLEX) { + return res.status(500).json({ error: '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({ + error: '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({ + error: + '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, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + user.userType = UserType.LOCAL; + user.plexId = null; + user.plexUsername = null; + user.plexToken = null; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + userSettingsRoutes.post<{ username: string; password: string }>( '/linked-accounts/jellyfin', isOwnProfile(), diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx index b1c9c0ddf..8b7cb03bd 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -7,7 +7,9 @@ import PageTitle from '@app/components/Common/PageTitle'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; +import { RequestError } from '@app/types/error'; 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'; @@ -25,10 +27,15 @@ const messages = defineMessages( '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(); + const enum LinkedAccountType { Plex, Jellyfin, @@ -61,13 +68,50 @@ const UserLinkedAccountsSettings = () => { : []), ]; + 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) { + throw new RequestError(res); + } + + await revalidateUser(); + } catch (e) { + if (e instanceof RequestError && e.status == 401) { + setError(intl.formatMessage(messages.plexErrorUnauthorized)); + } else if (e instanceof RequestError && e.status == 422) { + setError(intl.formatMessage(messages.plexErrorExists)); + } else { + setError(intl.formatMessage(messages.errorServer)); + } + } + }; + 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.find((a) => a.type == LinkedAccountType.Jellyfin), + accounts.some((a) => a.type == LinkedAccountType.Jellyfin), }, ].filter((l) => !l.hide); @@ -82,7 +126,7 @@ const UserLinkedAccountsSettings = () => { setError(intl.formatMessage(messages.deleteFailed)); } - revalidateUser(); + await revalidateUser(); }; if ( diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index c3b16a2b9..2a14ad1d5 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -11,7 +11,7 @@ export type { PermissionCheckOptions }; export interface User { id: number; warnings: string[]; - plexUsername?: string; + plexUsername?: string | null; jellyfinUsername?: string | null; username?: string; displayName: string; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 9e04bb820..34002324b 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1313,10 +1313,13 @@ "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 Jellyseerr 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", From be07f5cf49d4218088540073f39132661c375b72 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Thu, 1 Aug 2024 09:36:43 -0400 Subject: [PATCH 06/13] 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. --- server/routes/user/usersettings.ts | 44 +++++++++++++++++++ .../UserLinkedAccountsSettings/index.tsx | 36 ++++++++------- 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 1b211b7ca..3b246005b 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -369,6 +369,28 @@ userSettingsRoutes.delete<{ id: string }>( return next({ status: 404, message: 'User not found.' }); } + if (user.id === 1) { + return next({ + status: 400, + message: + 'Cannot unlink media server accounts for the primary administrator.', + }); + } + + const hasPassword = !!( + await userRepository.findOne({ + where: { id: user.id }, + select: ['id', 'password'], + }) + )?.password; + + if (!user.email || !hasPassword) { + return next({ + status: 400, + message: 'User does not have a local email or password set.', + }); + } + user.userType = UserType.LOCAL; user.plexId = null; user.plexUsername = null; @@ -489,6 +511,28 @@ userSettingsRoutes.delete<{ id: string }>( return next({ status: 404, message: 'User not found.' }); } + if (user.id === 1) { + return next({ + status: 400, + message: + 'Cannot unlink media server accounts for the primary administrator.', + }); + } + + const hasPassword = !!( + await userRepository.findOne({ + where: { id: user.id }, + select: ['id', 'password'], + }) + )?.password; + + if (!user.email || !hasPassword) { + return next({ + status: 400, + message: 'User does not have a local email or password set.', + }); + } + user.userType = UserType.LOCAL; user.jellyfinUserId = null; user.jellyfinUsername = null; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx index 8b7cb03bd..9754a1ece 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -15,6 +15,7 @@ import { MediaServerType } from '@server/constants/server'; import { useRouter } from 'next/router'; import { useState } from 'react'; import { useIntl } from 'react-intl'; +import useSWR from 'swr'; import LinkJellyfinModal from './LinkJellyfinModal'; const messages = defineMessages( @@ -56,6 +57,9 @@ const UserLinkedAccountsSettings = () => { 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); @@ -149,6 +153,8 @@ const UserLinkedAccountsSettings = () => { ); } + const enableMediaServerUnlink = user?.id !== 1 && passwordInfo?.hasPassword; + return ( <> { )} - {error && ( - - {error} - - )} + {error && } {accounts.length ? (
    {accounts.map((acct, i) => ( @@ -209,17 +211,19 @@ const UserLinkedAccountsSettings = () => {
    - { - deleteRequest( - acct.type == LinkedAccountType.Plex ? 'plex' : 'jellyfin' - ); - }} - confirmText={intl.formatMessage(globalMessages.areyousure)} - > - - {intl.formatMessage(globalMessages.delete)} - + {enableMediaServerUnlink && ( + { + deleteRequest( + acct.type == LinkedAccountType.Plex ? 'plex' : 'jellyfin' + ); + }} + confirmText={intl.formatMessage(globalMessages.areyousure)} + > + + {intl.formatMessage(globalMessages.delete)} + + )} ))}
From 538c46872d90cee1e14950e3dda4cd2e21453bdb Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Thu, 1 Aug 2024 11:44:28 -0400 Subject: [PATCH 07/13] feat(linked-accounts): support linking/unlinking emby accounts --- server/routes/user/usersettings.ts | 35 ++++++++++--- .../LinkJellyfinModal.tsx | 33 +++++++++---- .../UserLinkedAccountsSettings/index.tsx | 49 ++++++++++++++----- src/i18n/locale/en.json | 8 +-- 4 files changed, 91 insertions(+), 34 deletions(-) diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 3b246005b..092ce2a4b 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -358,8 +358,14 @@ userSettingsRoutes.delete<{ id: string }>( '/linked-accounts/plex', isOwnProfileOrAdmin(), async (req, res, next) => { + const settings = getSettings(); const userRepository = getRepository(User); + // Make sure Plex login is enabled + if (settings.main.mediaServerType !== MediaServerType.PLEX) { + return res.status(500).json({ error: 'Plex login is disabled' }); + } + try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, @@ -415,8 +421,11 @@ userSettingsRoutes.post<{ username: string; password: string }>( return next({ status: 401, message: 'Unauthorized' }); } // Make sure jellyfin login is enabled - if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { - return res.status(500).json({ error: 'Jellyfin login is disabled' }); + if ( + settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY + ) { + return res.status(500).json({ error: 'Jellyfin/Emby login is disabled' }); } // Do not allow linking of an already linked account @@ -426,8 +435,7 @@ userSettingsRoutes.post<{ username: string; password: string }>( }) ) { return res.status(422).json({ - error: - 'The specified Jellyfin account is already linked to a Jellyseerr user', + error: 'The specified account is already linked to a Jellyseerr user', }); } @@ -462,15 +470,17 @@ userSettingsRoutes.post<{ username: string; password: string }>( }) ) { return res.status(422).json({ - error: - 'The specified Jellyfin account is already linked to a Jellyseerr user', + error: 'The specified account is already linked to a Jellyseerr user', }); } const user = req.user; // valid jellyfin user found, link to current user - user.userType = UserType.JELLYFIN; + 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; @@ -479,7 +489,7 @@ userSettingsRoutes.post<{ username: string; password: string }>( return res.status(204).send(); } catch (e) { - logger.error('Failed to link Jellyfin account to user.', { + logger.error('Failed to link account to user.', { label: 'API', ip: req.ip, error: e, @@ -500,8 +510,17 @@ userSettingsRoutes.delete<{ id: string }>( '/linked-accounts/jellyfin', isOwnProfileOrAdmin(), async (req, res, next) => { + 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({ error: 'Jellyfin/Emby login is disabled' }); + } + try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx index 564a95f9c..a9a99cc71 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx @@ -5,6 +5,7 @@ import { useUser } from '@app/hooks/useUser'; import { RequestError } from '@app/types/error'; 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'; @@ -13,17 +14,18 @@ import * as Yup from 'yup'; const messages = defineMessages( 'components.UserProfile.UserSettings.LinkJellyfinModal', { - title: 'Link Jellyfin Account', + title: 'Link {mediaServerName} Account', description: - 'Enter your Jellyfin credentials to link your account with Jellyseerr.', + '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 Jellyfin using your credentials', - errorExists: 'This account is already linked to a Jellyseerr user', + errorUnauthorized: + 'Unable to connect to {mediaServerName} using your credentials', + errorExists: 'This account is already linked to a {applicationName} user', errorUnknown: 'An unknown error occurred', } ); @@ -53,6 +55,12 @@ const LinkJellyfinModal: React.FC = ({ ), }); + const applicationName = settings.currentSettings.applicationTitle; + const mediaServerName = + settings.currentSettings.mediaServerType === MediaServerType.EMBY + ? 'Emby' + : 'Jellyfin'; + return ( = ({ onSave(); } catch (e) { if (e instanceof RequestError && e.status == 401) { - setError(intl.formatMessage(messages.errorUnauthorized)); + setError( + intl.formatMessage(messages.errorUnauthorized, { + mediaServerName, + }) + ); } else if (e instanceof RequestError && e.status == 422) { - setError(intl.formatMessage(messages.errorExists)); + setError( + intl.formatMessage(messages.errorExists, { applicationName }) + ); } else { - setError(intl.formatMessage(messages.errorServer)); + setError(intl.formatMessage(messages.errorUnknown)); } } }} @@ -116,12 +130,13 @@ const LinkJellyfinModal: React.FC = ({ } okDisabled={isSubmitting || !isValid} onOk={() => handleSubmit()} - title={intl.formatMessage(messages.title)} + title={intl.formatMessage(messages.title, { mediaServerName })} dialogClass="sm:max-w-lg" >