From b7ddae1922957444894228d6f51d0428928b1838 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 21 Jul 2024 10:17:49 -0400 Subject: [PATCH] feat(linked-accounts): add support for linking/unlinking jellyfin accounts --- overseerr-api.yml | 48 +++++ server/api/jellyfin.ts | 8 +- server/entity/User.ts | 16 +- server/routes/user/usersettings.ts | 140 +++++++++++++++ src/components/Common/Dropdown/index.tsx | 111 ++++++++++++ .../LinkJellyfinModal.tsx | 169 ++++++++++++++++++ .../UserLinkedAccountsSettings/index.tsx | 127 +++++++++++-- src/i18n/locale/en.json | 13 ++ .../[userId]/settings/linked-accounts.tsx | 16 ++ src/types/error.ts | 11 ++ 10 files changed, 638 insertions(+), 21 deletions(-) create mode 100644 src/components/Common/Dropdown/index.tsx 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 3cb42284c..e06550c14 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4229,6 +4229,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 42864045b..2329576af 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -97,7 +97,11 @@ class JellyfinAPI extends ExternalAPI { private userId?: string; private jellyfinHost: 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}"`; @@ -116,7 +120,7 @@ class JellyfinAPI extends ExternalAPI { ); this.jellyfinHost = jellyfinHost; - this.authToken = authToken; + this.authToken = authToken ?? undefined; } public async login( 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 53eed9ef9..e5f4b965d 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,3 +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'; @@ -9,9 +13,24 @@ import { Permission } from '@server/lib/permissions'; 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 ( @@ -248,6 +267,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/Common/Dropdown/index.tsx b/src/components/Common/Dropdown/index.tsx new file mode 100644 index 000000000..ec08e9ca4 --- /dev/null +++ b/src/components/Common/Dropdown/index.tsx @@ -0,0 +1,111 @@ +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, +} from 'react'; + +interface DropdownItemProps extends AnchorHTMLAttributes { + buttonType?: 'primary' | 'ghost'; +} + +const DropdownItem = ({ + children, + buttonType = 'primary', + ...props +}: DropdownItemProps) => { + let styleClass = 'button-md text-white'; + + switch (buttonType) { + case 'ghost': + styleClass += + ' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white'; + break; + default: + styleClass += + ' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white'; + } + return ( + + + {children} + + + ); +}; + +interface 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); + + const styleClasses = { + mainButtonClasses: 'button-md text-white border', + dropdownSideButtonClasses: 'button-md border', + dropdownClasses: 'button-md', + }; + + switch (buttonType) { + case 'ghost': + 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 ( + + + {text} + {children && (dropdownIcon ? dropdownIcon : )} + + {children && ( + + + {children} + + + )} + + ); +}; +export default withProperties(Dropdown, { Item: DropdownItem }); diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx new file mode 100644 index 000000000..a8b05fb41 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinModal.tsx @@ -0,0 +1,169 @@ +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', + 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..f9017d1d6 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 +162,18 @@ const UserLinkedAccountsSettings = () => { {acct.username}
    +
    + { + deleteRequest( + acct.type == LinkedAccountType.Plex ? 'plex' : 'jellyfin' + ); + }} + confirmText={intl.formatMessage(globalMessages.areyousure)} + > + + {intl.formatMessage(globalMessages.delete)} +
  • ))}
@@ -88,6 +184,15 @@ const UserLinkedAccountsSettings = () => {
)} + + setShowJellyfinModal(false)} + onSave={() => { + setShowJellyfinModal(false); + revalidateUser(); + }} + /> ); }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 6654fdb4c..85c4f5a6b 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1153,6 +1153,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", @@ -1187,9 +1198,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; + } +}