diff --git a/overseerr-api.yml b/overseerr-api.yml index 96a4520a7..43e3d3575 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4343,6 +4343,104 @@ paths: responses: '204': description: User password updated + /user/{userId}/settings/linked-accounts/plex: + post: + summary: Link the provided Plex account to the current user + description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Plex account for a user + description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/linked-accounts/jellyfin: + post: + summary: Link the provided Jellyfin account to the current user + description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + password: + type: string + example: 'supersecret' + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Jellyfin account for a user + description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist /user/{userId}/settings/notifications: get: summary: Get notification settings for a user diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 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/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/constants/error.ts b/server/constants/error.ts index 96fafdc97..6a011d6b1 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -6,5 +6,6 @@ export enum ApiErrorCode { NotAdmin = 'NOT_ADMIN', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', + Unauthorized = 'UNAUTHORIZED', Unknown = 'UNKNOWN', } diff --git a/server/entity/User.ts b/server/entity/User.ts index e4c8314c3..95e7c6675 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -56,11 +56,11 @@ export class User { }) public email: string; - @Column({ nullable: true }) - public plexUsername?: string; + @Column({ type: 'varchar', nullable: true }) + public plexUsername?: string | null; - @Column({ nullable: true }) - public jellyfinUsername?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinUsername?: string | null; @Column({ nullable: true }) public username?: string; @@ -77,20 +77,20 @@ export class User { @Column({ type: 'integer', default: UserType.PLEX }) public userType: UserType; - @Column({ nullable: true, select: true }) - public plexId?: number; + @Column({ type: 'integer', nullable: true, select: true }) + public plexId?: number | null; - @Column({ nullable: true }) - public jellyfinUserId?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinUserId?: string | null; - @Column({ nullable: true }) - 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; + @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 11cbd666f..dd1001af7 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,4 +1,8 @@ +import JellyfinAPI from '@server/api/jellyfin'; +import PlexTvAPI from '@server/api/plextv'; import { ApiErrorCode } from '@server/constants/error'; +import { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { UserSettings } from '@server/entity/UserSettings'; @@ -11,9 +15,23 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { ApiError } from '@server/types/error'; +import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; +import net from 'net'; import { canMakePermissionsChange } from '.'; +const isOwnProfile = (): Middleware => { + return (req, res, next) => { + if (req.user?.id !== Number(req.params.id)) { + return next({ + status: 403, + message: "You do not have permission to view this user's settings.", + }); + } + next(); + }; +}; + const isOwnProfileOrAdmin = (): Middleware => { const authMiddleware: Middleware = (req, res, next) => { if ( @@ -160,9 +178,8 @@ userSettingsRoutes.post< status: e.statusCode, message: e.errorCode, }); - } else { - return next({ status: 500, message: e.message }); } + return next({ status: 500, message: e.message }); } }); @@ -267,6 +284,260 @@ userSettingsRoutes.post< } }); +userSettingsRoutes.post<{ authToken: string }>( + '/linked-accounts/plex', + isOwnProfile(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return res.status(404).json({ code: ApiErrorCode.Unauthorized }); + } + // Make sure Plex login is enabled + if (settings.main.mediaServerType !== MediaServerType.PLEX) { + return res.status(500).json({ message: 'Plex login is disabled' }); + } + + // First we need to use this auth token to get the user's email from plex.tv + const plextv = new PlexTvAPI(req.body.authToken); + const account = await plextv.getUser(); + + // Do not allow linking of an already linked account + if (await userRepository.exist({ where: { plexId: account.id } })) { + return res.status(422).json({ + message: 'This Plex account is already linked to a Jellyseerr user', + }); + } + + const user = req.user; + + // Emails do not match + if (user.email !== account.email) { + return res.status(422).json({ + message: + 'This Plex account is registered under a different email address.', + }); + } + + // valid plex user found, link to current user + user.userType = UserType.PLEX; + user.plexId = account.id; + user.plexUsername = account.username; + user.plexToken = account.authToken; + await userRepository.save(user); + + return res.status(204).send(); + } +); + +userSettingsRoutes.delete<{ id: string }>( + '/linked-accounts/plex', + isOwnProfileOrAdmin(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + // Make sure Plex login is enabled + if (settings.main.mediaServerType !== MediaServerType.PLEX) { + return res.status(500).json({ message: 'Plex login is disabled' }); + } + + try { + const user = await userRepository + .createQueryBuilder('user') + .addSelect('user.password') + .where({ + id: Number(req.params.id), + }) + .getOne(); + + if (!user) { + return res.status(404).json({ message: 'User not found.' }); + } + + if (user.id === 1) { + return res.status(400).json({ + message: + 'Cannot unlink media server accounts for the primary administrator.', + }); + } + + if (!user.email || !user.password) { + return res.status(400).json({ + message: 'User does not have a local email or password set.', + }); + } + + user.userType = UserType.LOCAL; + user.plexId = null; + user.plexUsername = null; + user.plexToken = null; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + return res.status(500).json({ message: e.message }); + } + } +); + +userSettingsRoutes.post<{ username: string; password: string }>( + '/linked-accounts/jellyfin', + isOwnProfile(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return res.status(401).json({ code: ApiErrorCode.Unauthorized }); + } + // Make sure jellyfin login is enabled + if ( + settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY + ) { + return res + .status(500) + .json({ message: 'Jellyfin/Emby login is disabled' }); + } + + // Do not allow linking of an already linked account + if ( + await userRepository.exist({ + where: { jellyfinUsername: req.body.username }, + }) + ) { + return res.status(422).json({ + message: 'The specified account is already linked to a Jellyseerr user', + }); + } + + const hostname = getHostname(); + const deviceId = Buffer.from( + `BOT_overseerr_${req.user.username ?? ''}` + ).toString('base64'); + + const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId); + + const ip = req.ip; + let clientIp: string | undefined; + if (ip) { + if (net.isIPv4(ip)) { + clientIp = ip; + } else if (net.isIPv6(ip)) { + clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; + } + } + + try { + const account = await jellyfinserver.login( + req.body.username, + req.body.password, + clientIp + ); + + // Do not allow linking of an already linked account + if ( + await userRepository.exist({ + where: { jellyfinUserId: account.User.Id }, + }) + ) { + return res.status(422).json({ + message: + 'The specified account is already linked to a Jellyseerr user', + }); + } + + const user = req.user; + + // valid jellyfin user found, link to current user + user.userType = + settings.main.mediaServerType === MediaServerType.EMBY + ? UserType.EMBY + : UserType.JELLYFIN; + user.jellyfinUserId = account.User.Id; + user.jellyfinUsername = account.User.Name; + user.jellyfinAuthToken = account.AccessToken; + user.jellyfinDeviceId = deviceId; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + logger.error('Failed to link account to user.', { + label: 'API', + ip: req.ip, + error: e, + }); + if ( + e instanceof ApiError && + e.errorCode === ApiErrorCode.InvalidCredentials + ) { + return res.status(401).json({ code: e.errorCode }); + } + + return res.status(500).send(); + } + } +); + +userSettingsRoutes.delete<{ id: string }>( + '/linked-accounts/jellyfin', + isOwnProfileOrAdmin(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + // Make sure jellyfin login is enabled + if ( + settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY + ) { + return res + .status(500) + .json({ message: 'Jellyfin/Emby login is disabled' }); + } + + try { + const user = await userRepository + .createQueryBuilder('user') + .addSelect('user.password') + .where({ + id: Number(req.params.id), + }) + .getOne(); + + if (!user) { + return res.status(404).json({ message: 'User not found.' }); + } + + if (user.id === 1) { + return res.status(400).json({ + message: + 'Cannot unlink media server accounts for the primary administrator.', + }); + } + + if (!user.email || !user.password) { + return res.status(400).json({ + message: 'User does not have a local email or password set.', + }); + } + + user.userType = UserType.LOCAL; + user.jellyfinUserId = null; + user.jellyfinUsername = null; + user.jellyfinAuthToken = null; + user.jellyfinDeviceId = null; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + return res.status(500).json({ message: e.message }); + } + } +); + userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), diff --git a/src/assets/services/jellyfin-icon.svg b/src/assets/services/jellyfin-icon.svg new file mode 100644 index 000000000..d4d7f0172 --- /dev/null +++ b/src/assets/services/jellyfin-icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + icon-transparent + + + + + diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 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..74ce79f2e --- /dev/null +++ b/src/components/Common/Dropdown/index.tsx @@ -0,0 +1,117 @@ +import { withProperties } from '@app/utils/typeHelpers'; +import { Menu, Transition } from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/24/solid'; +import { + Fragment, + useRef, + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + type HTMLAttributes, +} from 'react'; + +interface DropdownItemProps extends AnchorHTMLAttributes { + buttonType?: 'primary' | 'ghost'; +} + +const DropdownItem = ({ + children, + buttonType = 'primary', + ...props +}: DropdownItemProps) => { + return ( + + + {children} + + + ); +}; + +type DropdownItemsProps = HTMLAttributes & { + dropdownType: 'primary' | 'ghost'; +}; + +const DropdownItems = ({ + children, + className, + dropdownType, + ...props +}: DropdownItemsProps) => { + return ( + + +
{children}
+
+
+ ); +}; + +interface DropdownProps extends ButtonHTMLAttributes { + text: React.ReactNode; + dropdownIcon?: React.ReactNode; + buttonType?: 'primary' | 'ghost'; +} + +const Dropdown = ({ + text, + children, + dropdownIcon, + className, + buttonType = 'primary', + ...props +}: DropdownProps) => { + const buttonRef = useRef(null); + + return ( + + + {text} + {children && (dropdownIcon ? dropdownIcon : )} + + {children && ( + {children} + )} + + ); +}; +export default withProperties(Dropdown, { + Item: DropdownItem, + Items: DropdownItems, +}); diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 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 diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx new file mode 100644 index 000000000..4872e7c15 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx @@ -0,0 +1,188 @@ +import Alert from '@app/components/Common/Alert'; +import Modal from '@app/components/Common/Modal'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import { MediaServerType } from '@server/constants/server'; +import { Field, Form, Formik } from 'formik'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import * as Yup from 'yup'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.LinkJellyfinModal', + { + title: 'Link {mediaServerName} Account', + description: + 'Enter your {mediaServerName} credentials to link your account with {applicationName}.', + username: 'Username', + password: 'Password', + usernameRequired: 'You must provide a username', + passwordRequired: 'You must provide a password', + saving: 'Adding…', + save: 'Link', + errorUnauthorized: + 'Unable to connect to {mediaServerName} using your credentials', + errorExists: 'This account is already linked to a {applicationName} user', + errorUnknown: 'An unknown error occurred', + } +); + +interface LinkJellyfinModalProps { + show: boolean; + onClose: () => void; + onSave: () => void; +} + +const LinkJellyfinModal: React.FC = ({ + show, + onClose, + onSave, +}) => { + const intl = useIntl(); + const settings = useSettings(); + const { user } = useUser(); + const [error, setError] = useState(null); + + const JellyfinLoginSchema = Yup.object().shape({ + username: Yup.string().required( + intl.formatMessage(messages.usernameRequired) + ), + password: Yup.string().required( + intl.formatMessage(messages.passwordRequired) + ), + }); + + const applicationName = settings.currentSettings.applicationTitle; + const mediaServerName = + settings.currentSettings.mediaServerType === MediaServerType.EMBY + ? 'Emby' + : 'Jellyfin'; + + return ( + + { + try { + setError(null); + const res = await fetch( + `/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + }), + } + ); + if (!res.ok) { + if (res.status === 401) { + setError( + intl.formatMessage(messages.errorUnauthorized, { + mediaServerName, + }) + ); + } else if (res.status === 422) { + setError( + intl.formatMessage(messages.errorExists, { applicationName }) + ); + } else { + setError(intl.formatMessage(messages.errorUnknown)); + } + } else { + onSave(); + } + } catch (e) { + setError(intl.formatMessage(messages.errorUnknown)); + } + }} + > + {({ errors, touched, handleSubmit, isSubmitting, isValid }) => { + return ( + { + setError(null); + onClose(); + }} + okButtonType="primary" + okButtonProps={{ type: 'submit', form: 'link-jellyfin-account' }} + okText={ + isSubmitting + ? intl.formatMessage(messages.saving) + : intl.formatMessage(messages.save) + } + okDisabled={isSubmitting || !isValid} + onOk={() => handleSubmit()} + title={intl.formatMessage(messages.title, { mediaServerName })} + dialogClass="sm:max-w-lg" + > + + + ); + }} + + + ); +}; + +export default LinkJellyfinModal; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx new file mode 100644 index 000000000..a54610c32 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -0,0 +1,276 @@ +import EmbyLogo from '@app/assets/services/emby-icon-only.svg'; +import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg'; +import PlexLogo from '@app/assets/services/plex.svg'; +import Alert from '@app/components/Common/Alert'; +import ConfirmButton from '@app/components/Common/ConfirmButton'; +import Dropdown from '@app/components/Common/Dropdown'; +import PageTitle from '@app/components/Common/PageTitle'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, UserType, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import PlexOAuth from '@app/utils/plex'; +import { TrashIcon } from '@heroicons/react/24/solid'; +import { MediaServerType } from '@server/constants/server'; +import { useRouter } from 'next/router'; +import { useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; +import LinkJellyfinModal from './LinkJellyfinModal'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserLinkedAccountsSettings', + { + linkedAccounts: 'Linked Accounts', + linkedAccountsHint: + 'These external accounts are linked to your {applicationName} account.', + noLinkedAccounts: + 'You do not have any external accounts linked to your account.', + noPermissionDescription: + "You do not have permission to modify this user's linked accounts.", + plexErrorUnauthorized: 'Unable to connect to Plex using your credentials', + plexErrorExists: 'This account is already linked to a Plex user', + errorUnknown: 'An unknown error occurred', + deleteFailed: 'Unable to delete linked account.', + } +); + +const plexOAuth = new PlexOAuth(); + +enum LinkedAccountType { + Plex = 'Plex', + Jellyfin = 'Jellyfin', + Emby = 'Emby', +} + +type LinkedAccount = { + type: LinkedAccountType; + username: string; +}; + +const UserLinkedAccountsSettings = () => { + const intl = useIntl(); + const settings = useSettings(); + const router = useRouter(); + const { user: currentUser } = useUser(); + const { + user, + hasPermission, + revalidate: revalidateUser, + } = useUser({ id: Number(router.query.userId) }); + const { data: passwordInfo } = useSWR<{ hasPassword: boolean }>( + user ? `/api/v1/user/${user?.id}/settings/password` : null + ); + const [showJellyfinModal, setShowJellyfinModal] = useState(false); + const [error, setError] = useState(null); + + const applicationName = settings.currentSettings.applicationTitle; + + const accounts: LinkedAccount[] = useMemo(() => { + const accounts: LinkedAccount[] = []; + if (!user) return accounts; + if (user.userType === UserType.PLEX && user.plexUsername) + accounts.push({ + type: LinkedAccountType.Plex, + username: user.plexUsername, + }); + if (user.userType === UserType.EMBY && user.jellyfinUsername) + accounts.push({ + type: LinkedAccountType.Emby, + username: user.jellyfinUsername, + }); + if (user.userType === UserType.JELLYFIN && user.jellyfinUsername) + accounts.push({ + type: LinkedAccountType.Jellyfin, + username: user.jellyfinUsername, + }); + return accounts; + }, [user]); + + const linkPlexAccount = async () => { + setError(null); + try { + const authToken = await plexOAuth.login(); + const res = await fetch( + `/api/v1/user/${user?.id}/settings/linked-accounts/plex`, + { + method: 'POST', + body: JSON.stringify({ authToken }), + } + ); + if (!res.ok) { + if (res.status === 401) { + setError(intl.formatMessage(messages.plexErrorUnauthorized)); + } else if (res.status === 422) { + setError(intl.formatMessage(messages.plexErrorExists)); + } else { + setError(intl.formatMessage(messages.errorServer)); + } + } else { + await revalidateUser(); + } + } catch (e) { + 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.some((a) => a.type === LinkedAccountType.Jellyfin), + }, + { + name: 'Emby', + action: () => setShowJellyfinModal(true), + hide: + settings.currentSettings.mediaServerType !== MediaServerType.EMBY || + accounts.some((a) => a.type === LinkedAccountType.Emby), + }, + ].filter((l) => !l.hide); + + const deleteRequest = async (account: string) => { + try { + const res = await fetch( + `/api/v1/user/${user?.id}/settings/linked-accounts/${account}`, + { method: 'DELETE' } + ); + if (!res.ok) throw new Error(); + } catch { + setError(intl.formatMessage(messages.deleteFailed)); + } + + await revalidateUser(); + }; + + if ( + currentUser?.id !== user?.id && + hasPermission(Permission.ADMIN) && + currentUser?.id !== 1 + ) { + return ( + <> +
+

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

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

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

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

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

+
+ )} + + setShowJellyfinModal(false)} + onSave={() => { + setShowJellyfinModal(false); + revalidateUser(); + }} + /> + + ); +}; + +export default UserLinkedAccountsSettings; diff --git a/src/components/UserProfile/UserSettings/index.tsx b/src/components/UserProfile/UserSettings/index.tsx index 72d237b97..2072285c2 100644 --- a/src/components/UserProfile/UserSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/index.tsx @@ -18,6 +18,7 @@ import useSWR from 'swr'; const messages = defineMessages('components.UserProfile.UserSettings', { menuGeneralSettings: 'General', menuChangePass: 'Password', + menuLinkedAccounts: 'Linked Accounts', menuNotifications: 'Notifications', menuPermissions: 'Permissions', unauthorizedDescription: @@ -63,6 +64,11 @@ const UserSettings = ({ children }: UserSettingsProps) => { currentUser?.id !== user?.id && hasPermission(Permission.ADMIN, user?.permissions ?? 0)), }, + { + text: intl.formatMessage(messages.menuLinkedAccounts), + route: '/settings/linked-accounts', + regex: /\/settings\/linked-accounts/, + }, { text: intl.formatMessage(messages.menuNotifications), route: data?.emailEnabled diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index f1e830066..2d3ef37f1 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -11,8 +11,8 @@ export type { PermissionCheckOptions }; export interface User { id: number; warnings: string[]; - plexUsername?: string; - jellyfinUsername?: string; + plexUsername?: string | null; + jellyfinUsername?: string | null; username?: string; displayName: string; email: string; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 1642872b3..cb267e5d9 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -298,7 +298,6 @@ "components.ManageSlideOver.plays": "{playCount, number} {playCount, plural, one {play} other {plays}}", "components.ManageSlideOver.removearr": "Remove from {arr}", "components.ManageSlideOver.removearr4k": "Remove from 4K {arr}", - "components.RequestList.RequestItem.removearr": "Remove from {arr}", "components.ManageSlideOver.tvshow": "series", "components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MovieDetails.MovieCast.fullcast": "Full Cast", @@ -494,6 +493,7 @@ "components.RequestList.RequestItem.modified": "Modified", "components.RequestList.RequestItem.modifieduserdate": "{date} by {user}", "components.RequestList.RequestItem.profileName": "Profile", + "components.RequestList.RequestItem.removearr": "Remove from {arr}", "components.RequestList.RequestItem.requested": "Requested", "components.RequestList.RequestItem.requesteddate": "Requested", "components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}", @@ -1084,7 +1084,7 @@ "components.Setup.finishing": "Finishing…", "components.Setup.servertype": "Choose Server Type", "components.Setup.setup": "Setup", - "components.Setup.signin": "Sign in to your account", + "components.Setup.signin": "Sign In", "components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details", @@ -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 {mediaServerName} credentials to link your account with {applicationName}.", + "components.UserProfile.UserSettings.LinkJellyfinModal.errorExists": "This account is already linked to a {applicationName} user", + "components.UserProfile.UserSettings.LinkJellyfinModal.errorUnauthorized": "Unable to connect to {mediaServerName} using your credentials", + "components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred", + "components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password", + "components.UserProfile.UserSettings.LinkJellyfinModal.passwordRequired": "You must provide a password", + "components.UserProfile.UserSettings.LinkJellyfinModal.save": "Link", + "components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…", + "components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account", + "components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username", + "components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username", "components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type", "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin", "components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language", @@ -1240,6 +1251,14 @@ "components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID", "components.UserProfile.UserSettings.UserGeneralSettings.validationemailformat": "Valid email required", "components.UserProfile.UserSettings.UserGeneralSettings.validationemailrequired": "Email required", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.deleteFailed": "Unable to delete linked account.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.errorUnknown": "An unknown error occurred", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccounts": "Linked Accounts", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccountsHint": "These external accounts are linked to your {applicationName} account.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.noLinkedAccounts": "You do not have any external accounts linked to your account.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.noPermissionDescription": "You do not have permission to modify this user's linked accounts.", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorExists": "This account is already linked to a Plex user", + "components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorUnauthorized": "Unable to connect to Plex using your credentials", "components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default", "components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID", "components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The multi-digit ID number associated with your user account", @@ -1299,6 +1318,7 @@ "components.UserProfile.UserSettings.UserPermissions.unauthorizedDescription": "You cannot modify your own permissions.", "components.UserProfile.UserSettings.menuChangePass": "Password", "components.UserProfile.UserSettings.menuGeneralSettings": "General", + "components.UserProfile.UserSettings.menuLinkedAccounts": "Linked Accounts", "components.UserProfile.UserSettings.menuNotifications": "Notifications", "components.UserProfile.UserSettings.menuPermissions": "Permissions", "components.UserProfile.UserSettings.unauthorizedDescription": "You do not have permission to modify this user's settings.", diff --git a/src/pages/profile/settings/linked-accounts.tsx b/src/pages/profile/settings/linked-accounts.tsx new file mode 100644 index 000000000..cd7521099 --- /dev/null +++ b/src/pages/profile/settings/linked-accounts.tsx @@ -0,0 +1,13 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserLinkedAccountsSettings from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings'; +import type { NextPage } from 'next'; + +const UserSettingsLinkedAccountsPage: NextPage = () => { + return ( + + + + ); +}; + +export default UserSettingsLinkedAccountsPage; diff --git a/src/pages/users/[userId]/settings/linked-accounts.tsx b/src/pages/users/[userId]/settings/linked-accounts.tsx new file mode 100644 index 000000000..51b4ff24f --- /dev/null +++ b/src/pages/users/[userId]/settings/linked-accounts.tsx @@ -0,0 +1,16 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserLinkedAccountsSettings from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const UserLinkedAccountsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserLinkedAccountsPage;