From 4b46b32c1eb04438dcd0a7aa645a9be2b7efad4e 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 1642872b3..968d2e889 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1240,6 +1240,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", @@ -1299,6 +1302,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 8bfba4dacca9af89c4630ccf2989edad471c48be 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 | 76 ++-------- src/components/Common/Dropdown/index.tsx | 133 ++++++++++++++++++ 2 files changed, 142 insertions(+), 67 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 b0d314d1a..981937cb0 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -1,39 +1,8 @@ -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 } from 'react'; -import { Fragment, useRef, useState } 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} - - ); -}; +import type { ButtonHTMLAttributes } from 'react'; interface ButtonWithDropdownProps extends ButtonHTMLAttributes { @@ -50,14 +19,9 @@ const ButtonWithDropdown = ({ buttonType = 'primary', ...props }: ButtonWithDropdownProps) => { - const [isOpen, setIsOpen] = useState(false); - const buttonRef = useRef(null); - useClickOutside(buttonRef, () => setIsOpen(false)); - const styleClasses = { mainButtonClasses: 'button-md text-white border', dropdownSideButtonClasses: 'button-md border', - dropdownClasses: 'button-md', }; switch (buttonType) { @@ -65,60 +29,38 @@ 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'; } return ( - + {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 71ee4d7cd769a78cd7109e0e720b31e6cec6a232 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 4930bd85d..748842528 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 ) => { @@ -102,7 +112,7 @@ const Modal = React.forwardRef( ( className="ml-3" disabled={okDisabled} data-testid="modal-ok-button" + {...okButtonProps} > {okText ? okText : 'Ok'} @@ -196,6 +207,7 @@ const Modal = React.forwardRef( className="ml-3" disabled={secondaryDisabled} data-testid="modal-secondary-button" + {...secondaryButtonProps} > {secondaryText} @@ -206,6 +218,7 @@ const Modal = React.forwardRef( onClick={onTertiary} className="ml-3" disabled={tertiaryDisabled} + {...tertiaryButtonProps} > {tertiaryText} @@ -216,6 +229,7 @@ const Modal = React.forwardRef( onClick={onCancel} className="ml-3 sm:ml-0" data-testid="modal-cancel-button" + {...cancelButtonProps} > {cancelText ? cancelText From c664b40d2092fd04c3b2c6affc337f18e4453a02 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 | 138 ++++++++++++++ .../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, 530 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 96a4520a7..3c41f2c3d 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4343,6 +4343,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 f65503477..e0bd07ed0 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 11cbd666f..7b42d0a0d 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,4 +1,7 @@ +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'; import { UserSettings } from '@server/entity/UserSettings'; @@ -11,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 ( @@ -267,6 +284,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 f1e830066..2c4b241e0 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 968d2e889..8246e9741 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1205,6 +1205,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", @@ -1240,9 +1251,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 97c3935b4ec9d45315e3945337ea739dafde09ff 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 3c41f2c3d..dd130bf1e 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4343,6 +4343,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 f6b8f3cb0..e994c1dcb 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 7b42d0a0d..0d2c4cc3c 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'; @@ -284,6 +285,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 2c4b241e0..2d3ef37f1 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 8246e9741..8de080853 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1252,10 +1252,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 2b6c8c3f49fc041d534e1ce4b529b7bca9979f9a 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 0d2c4cc3c..302c7407d 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -347,6 +347,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; @@ -467,6 +489,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 4a32662d27a8ed0b41ef736b8f38c025441e24f4 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 302c7407d..96a746ce4 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -336,8 +336,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) }, @@ -393,8 +399,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 @@ -404,8 +413,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', }); } @@ -440,15 +448,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; @@ -457,7 +467,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, @@ -478,8 +488,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" >