diff --git a/overseerr-api.yml b/overseerr-api.yml index e06550c14..33d80c3e3 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4229,6 +4229,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 e5f4b965d..fcf2f69b8 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'; @@ -267,6 +268,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 f9017d1d6..a55fa5be1 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,7 +68,44 @@ 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.find((a) => a.type == LinkedAccountType.Plex), + }, { name: 'Jellyfin', action: () => setShowJellyfinModal(true), @@ -82,7 +126,7 @@ const UserLinkedAccountsSettings = () => { setError(intl.formatMessage(messages.deleteFailed)); } - revalidateUser(); + await revalidateUser(); }; if (