diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index ac5aaa26..17c8c871 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -133,6 +133,18 @@ components: type: number example: 5 readOnly: true + plexProfileId: + type: string + example: '12345' + readOnly: true + isPlexProfile: + type: boolean + example: true + readOnly: true + mainPlexUserId: + type: number + example: 1 + readOnly: true required: - id - email @@ -194,6 +206,27 @@ components: trustProxy: type: boolean example: true + PlexProfile: + type: object + properties: + id: + type: string + example: '12345' + title: + type: string + example: 'Family Member' + username: + type: string + example: 'family_member' + thumb: + type: string + example: 'https://plex.tv/users/avatar.jpg' + isMainUser: + type: boolean + example: false + protected: + type: boolean + example: true PlexLibrary: type: object properties: @@ -3658,17 +3691,17 @@ paths: /auth/plex: post: summary: Sign in using a Plex token - description: Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions. + description: | + Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. + + If the user does not exist, and there are no other users, then a user will be created with full admin privileges. + If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions. + + If the Plex account has multiple profiles, the response will include a `status` field with value `REQUIRES_PROFILE`, + along with the available profiles and the main user ID. security: [] tags: - auth - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/User' requestBody: required: true content: @@ -3678,8 +3711,155 @@ paths: properties: authToken: type: string + profileId: + type: string + description: Optional. If provided, will attempt to authenticate as this specific Plex profile. + pin: + type: string + description: Optional 4-digit profile PIN + isSetup: + type: boolean + description: Set to true during initial setup wizard required: - authToken + responses: + '200': + description: OK or profile selection required + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + status: + type: string + enum: [REQUIRES_PROFILE] + example: REQUIRES_PROFILE + mainUserId: + type: number + example: 1 + profiles: + type: array + items: + $ref: '#/components/schemas/PlexProfile' + '401': + description: Invalid Plex token (or incorrect 4-digit PIN) + '403': + description: Access denied + '409': + description: Conflict. E-mail or username already exists + '500': + description: Unexpected server error + + /auth/plex/profile/select: + post: + summary: Select a Plex profile to log in as + description: | + Selects a specific Plex profile to log in as. The profile must be associated with the main user ID provided. + + A session cookie will be generated for the selected profile user. + security: [] + tags: + - auth + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + profileId: + type: string + description: The ID of the Plex profile to log in as + mainUserId: + type: number + description: The ID of the main Plex user account + pin: + type: string + description: Optional 4 digit profile PIN + authToken: + type: string + description: Optional Plex token (when reselecting without /plex step) + + required: + - profileId + - mainUserId + responses: + '200': + description: OK or PIN required + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + status: + type: string + enum: [REQUIRES_PIN] + example: REQUIRES_PIN + profileId: + type: string + example: '3b969e371cc3df20' + profileName: + type: string + example: 'John Doe' + mainUserId: + type: number + example: 1 + '400': + description: Missing required parameters + '401': + description: Invalid Plex token (or incorrect 4-digit PIN) + '403': + description: Access denied + '404': + description: Profile not found + '500': + description: Error selecting profile + + /auth/plex/profiles/{userId}: + get: + summary: Get Plex profiles for a given Jellyseerr user + description: | + Returns the list of available Plex home profiles and their corresponding user accounts + linked to the specified Jellyseerr user. The user must be a Plex-based account. + security: [] + tags: + - auth + parameters: + - in: path + name: userId + required: true + schema: + type: integer + description: The Jellyseerr user ID of the main Plex account + responses: + '200': + description: List of profiles and linked users + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/PlexProfile' + profileUsers: + type: array + items: + $ref: '#/components/schemas/User' + mainUser: + $ref: '#/components/schemas/User' + '400': + description: Invalid user ID format or unsupported user type + '404': + description: User not found + '500': + description: Failed to fetch profiles + /auth/jellyfin: post: summary: Sign in using a Jellyfin username and password diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 2fc4523a..0892f1d4 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -2,10 +2,10 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { randomUUID } from 'node:crypto'; import xml2js from 'xml2js'; import ExternalAPI from './externalapi'; - interface PlexAccountResponse { user: PlexUser; } @@ -31,6 +31,37 @@ interface PlexUser { }; entitlements: string[]; } +interface PlexHomeUser { + $: { + id: string; + uuid: string; + title: string; + username?: string; + email?: string; + thumb: string; + protected?: string; + hasPassword?: string; + admin?: string; + guest?: string; + restricted?: string; + }; +} + +interface PlexHomeUsersResponse { + MediaContainer: { + protected?: string; + User?: PlexHomeUser | PlexHomeUser[]; + }; +} + +export interface PlexProfile { + id: string; + title: string; + username?: string; + thumb: string; + isMainUser?: boolean; + protected?: boolean; +} interface ConnectionResponse { $: { @@ -133,6 +164,16 @@ export interface PlexWatchlistCache { response: WatchlistResponse; } +export interface PlexProfile { + id: string; + uuid?: string; + title: string; + username?: string; + thumb: string; + isMainUser?: boolean; + isManaged?: boolean; +} + class PlexTvAPI extends ExternalAPI { private authToken: string; @@ -225,6 +266,141 @@ class PlexTvAPI extends ExternalAPI { } } + public async getProfiles(): Promise { + try { + // First get the main user + const mainUser = await this.getUser(); + + // Initialize with main user profile + const profiles: PlexProfile[] = [ + { + id: mainUser.uuid, + title: mainUser.username, + username: mainUser.username, + thumb: mainUser.thumb, + isMainUser: true, + protected: false, // Will be updated if we get XML data + }, + ]; + + try { + // Fetch all profiles including PIN protection status + const response = await axios.get( + 'https://clients.plex.tv/api/home/users', + { + headers: { + Accept: 'application/json', + 'X-Plex-Token': this.authToken, + 'X-Plex-Client-Identifier': randomUUID(), + }, + } + ); + + // Parse the XML response + const parsedXML = await xml2js.parseStringPromise(response.data, { + explicitArray: false, + }); + + const container = (parsedXML as PlexHomeUsersResponse).MediaContainer; + const rawUsers = container?.User; + + if (rawUsers) { + // Convert to array if single user + const users: PlexHomeUser[] = Array.isArray(rawUsers) + ? rawUsers + : [rawUsers]; + + // Update main user's protected status + const mainUserInXml = users.find( + (user) => user.$.uuid === mainUser.uuid + ); + if (mainUserInXml) { + profiles[0].protected = mainUserInXml.$.protected === '1'; + } + + // Add managed profiles (non-main profiles) + const managedProfiles = users + .filter((user) => { + // Validate profile data + const { uuid, title, username } = user.$; + const isValid = Boolean(uuid && (title || username)); + + // Log invalid profiles but don't include them + if (!isValid) { + logger.warn('Skipping invalid Plex profile entry', { + label: 'Plex.tv API', + uuid, + title, + username, + }); + } + + // Filter out main user and invalid profiles + return isValid && uuid !== mainUser.uuid; + }) + .map((user) => ({ + id: user.$.uuid, + title: user.$.title ?? 'Unknown', + username: user.$.username || user.$.title || 'Unknown', + thumb: user.$.thumb ?? '', + protected: user.$.protected === '1', + isMainUser: false, + })); + + // Add managed profiles to the results + profiles.push(...managedProfiles); + } + + logger.debug('Successfully parsed Plex profiles', { + label: 'Plex.tv API', + count: profiles.length, + }); + } catch (e) { + // Continue with just the main user profile if we can't get managed profiles + logger.debug('Could not retrieve managed profiles', { + label: 'Plex.tv API', + errorMessage: e.message, + }); + } + + return profiles; + } catch (e) { + logger.error('Failed to retrieve Plex profiles', { + label: 'Plex.tv API', + errorMessage: e.message, + }); + return []; + } + } + + public async validateProfilePin( + profileId: string, + pin: string + ): Promise { + try { + const response = await axios.post( + `https://clients.plex.tv/api/v2/home/users/${profileId}/switch`, + { pin }, + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Plex-Token': this.authToken, + 'X-Plex-Client-Identifier': randomUUID(), + }, + } + ); + + return response.status >= 200 && response.status < 300; + } catch (e) { + logger.error('Failed to validate Plex profile pin', { + label: 'Plex.tv API', + errorMessage: e.message, + }); + return false; + } + } + public async checkUserAccess(userId: number): Promise { const settings = getSettings(); diff --git a/server/constants/error.ts b/server/constants/error.ts index daa02f1a..ce68f8df 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -9,4 +9,6 @@ export enum ApiErrorCode { SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', Unauthorized = 'UNAUTHORIZED', Unknown = 'UNKNOWN', + InvalidPin = 'INVALID_PIN', + NewPlexLoginDisabled = 'NEW_PLEX_LOGIN_DISABLED', } diff --git a/server/entity/User.ts b/server/entity/User.ts index 8a96f396..2a19b8dc 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -91,6 +91,15 @@ export class User { @Column({ type: 'varchar', nullable: true, select: false }) public plexToken?: string | null; + @Column({ type: 'varchar', nullable: true }) + public plexProfileId?: string | null; + + @Column({ type: 'boolean', default: false }) + public isPlexProfile?: boolean; + + @Column({ type: 'integer', nullable: true }) + public mainPlexUserId?: number | null; + @Column({ type: 'integer', default: 0 }) public permissions = 0; diff --git a/server/migration/postgres/1744317469293-AddPlexProfilesSupport.ts b/server/migration/postgres/1744317469293-AddPlexProfilesSupport.ts new file mode 100644 index 00000000..29bc1477 --- /dev/null +++ b/server/migration/postgres/1744317469293-AddPlexProfilesSupport.ts @@ -0,0 +1,31 @@ +// Create a new migration file +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddPlexProfilesSupport1744317469293 implements MigrationInterface { + name = 'AddPlexProfilesSupport1744317469293'; + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('user', [ + new TableColumn({ + name: 'plexProfileId', + type: 'varchar', + isNullable: true, + }), + new TableColumn({ + name: 'isPlexProfile', + type: 'boolean', + default: false, + }), + new TableColumn({ + name: 'mainPlexUserId', + type: 'integer', + isNullable: true, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('user', 'plexProfileId'); + await queryRunner.dropColumn('user', 'isPlexProfile'); + await queryRunner.dropColumn('user', 'mainPlexUserId'); + } +} diff --git a/server/migration/sqlite/1744317469293-AddPlexProfilesSupport.ts b/server/migration/sqlite/1744317469293-AddPlexProfilesSupport.ts new file mode 100644 index 00000000..29bc1477 --- /dev/null +++ b/server/migration/sqlite/1744317469293-AddPlexProfilesSupport.ts @@ -0,0 +1,31 @@ +// Create a new migration file +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddPlexProfilesSupport1744317469293 implements MigrationInterface { + name = 'AddPlexProfilesSupport1744317469293'; + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('user', [ + new TableColumn({ + name: 'plexProfileId', + type: 'varchar', + isNullable: true, + }), + new TableColumn({ + name: 'isPlexProfile', + type: 'boolean', + default: false, + }), + new TableColumn({ + name: 'mainPlexUserId', + type: 'integer', + isNullable: true, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('user', 'plexProfileId'); + await queryRunner.dropColumn('user', 'isPlexProfile'); + await queryRunner.dropColumn('user', 'mainPlexUserId'); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b8e78a2d..2b0c476b 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -18,7 +18,6 @@ import axios from 'axios'; import * as EmailValidator from 'email-validator'; import { Router } from 'express'; import net from 'net'; - const authRoutes = Router(); authRoutes.get('/me', isAuthenticated(), async (req, res) => { @@ -49,7 +48,12 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { authRoutes.post('/plex', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); - const body = req.body as { authToken?: string }; + const body = req.body as { + authToken?: string; + profileId?: string; + pin?: string; + isSetup?: boolean; + }; if (!body.authToken) { return next({ @@ -65,12 +69,97 @@ authRoutes.post('/plex', async (req, res, next) => { ) { return res.status(500).json({ error: 'Plex login is disabled' }); } + try { - // First we need to use this auth token to get the user's email from plex.tv const plextv = new PlexTvAPI(body.authToken); const account = await plextv.getUser(); + const profiles = await plextv.getProfiles(); + const mainUserProfile = profiles.find((p) => p.isMainUser); - // Next let's see if the user already exists + // Special handling for setup process + if (body.isSetup) { + let user = await userRepository + .createQueryBuilder('user') + .where('user.plexId = :id', { id: account.id }) + .orWhere('user.email = :email', { + email: account.email.toLowerCase(), + }) + .getOne(); + + // First user setup - create the admin user + if (!user && !(await userRepository.count())) { + user = new User({ + email: account.email, + plexUsername: account.username, + plexId: account.id, + plexToken: account.authToken, + permissions: Permission.ADMIN, + avatar: account.thumb, + userType: UserType.PLEX, + plexProfileId: mainUserProfile?.id || account.id.toString(), + isPlexProfile: false, + }); + + settings.main.mediaServerType = MediaServerType.PLEX; + await settings.save(); + startJobs(); + + await userRepository.save(user); + } else if (user) { + // Update existing user with latest Plex data + user.plexToken = account.authToken; + user.plexId = account.id; + user.avatar = account.thumb; + user.plexProfileId = mainUserProfile?.id || account.id.toString(); + + await userRepository.save(user); + } + + // Return user directly, bypassing profile selection + if (user && req.session) { + req.session.userId = user.id; + } + return res.status(200).json(user?.filter() ?? {}); + } + + // Validate PIN for main account + if (!body.profileId && mainUserProfile?.protected && body.pin) { + const isPinValid = await plextv.validateProfilePin( + mainUserProfile.id, + body.pin + ); + if (!isPinValid) { + return next({ + status: 403, + error: 'INVALID_PIN.', + }); + } + } + + // Handle direct profile login + if (body.profileId) { + const profileUser = await userRepository.findOne({ + where: { plexProfileId: body.profileId }, + }); + + if (profileUser) { + profileUser.plexToken = body.authToken; + await userRepository.save(profileUser); + + if (req.session) { + req.session.userId = profileUser.id; + } + + return res.status(200).json(profileUser.filter() ?? {}); + } else { + return next({ + status: 400, + message: 'Invalid profile selection.', + }); + } + } + + // Standard Plex authentication flow let user = await userRepository .createQueryBuilder('user') .where('user.plexId = :id', { id: account.id }) @@ -80,6 +169,7 @@ authRoutes.post('/plex', async (req, res, next) => { .getOne(); if (!user && !(await userRepository.count())) { + // First user setup through standard auth flow user = new User({ email: account.email, plexUsername: account.username, @@ -88,6 +178,8 @@ authRoutes.post('/plex', async (req, res, next) => { permissions: Permission.ADMIN, avatar: account.thumb, userType: UserType.PLEX, + plexProfileId: account.id.toString(), + isPlexProfile: false, }); settings.main.mediaServerType = MediaServerType.PLEX; @@ -135,13 +227,15 @@ authRoutes.post('/plex', async (req, res, next) => { } ); } - + // Update existing user user.plexToken = body.authToken; user.plexId = account.id; user.avatar = account.thumb; user.email = account.email; user.plexUsername = account.username; user.userType = UserType.PLEX; + user.plexProfileId = account.id.toString(); + user.isPlexProfile = false; await userRepository.save(user); } else if (!settings.main.newPlexLogin) { @@ -157,19 +251,11 @@ authRoutes.post('/plex', async (req, res, next) => { ); return next({ status: 403, + error: ApiErrorCode.NewPlexLoginDisabled, message: 'Access denied.', }); } else { - logger.info( - 'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user', - { - label: 'API', - ip: req.ip, - email: account.email, - plexId: account.id, - plexUsername: account.username, - } - ); + // Create new user user = new User({ email: account.email, plexUsername: account.username, @@ -178,13 +264,15 @@ authRoutes.post('/plex', async (req, res, next) => { permissions: settings.main.defaultPermissions, avatar: account.thumb, userType: UserType.PLEX, + plexProfileId: account.id.toString(), + isPlexProfile: false, }); await userRepository.save(user); } } else { - logger.warn( - 'Failed sign-in attempt by Plex user without access to the media server', + logger.info( + 'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user', { label: 'API', ip: req.ip, @@ -195,17 +283,70 @@ authRoutes.post('/plex', async (req, res, next) => { ); return next({ status: 403, + error: ApiErrorCode.NewPlexLoginDisabled, message: 'Access denied.', }); } } - // Set logged in session - if (req.session) { - req.session.userId = user.id; + // Create or update profiles for this user + for (const profile of profiles) { + // Skip the main user's profile as it's already handled + if (profile.isMainUser) { + continue; + } + + // Check if this profile already exists in the database + const existingProfileUser = await userRepository.findOne({ + where: { plexProfileId: profile.id }, + }); + + if (existingProfileUser) { + // Update the profile with latest data + existingProfileUser.plexToken = user.plexToken; + existingProfileUser.avatar = profile.thumb; + existingProfileUser.plexUsername = profile.username || profile.title; + existingProfileUser.mainPlexUserId = user.id; + + await userRepository.save(existingProfileUser); + } else if (settings.main.newPlexLogin) { + // Create a new profile user + const emailPrefix = user.email.split('@')[0]; + const domainPart = user.email.includes('@') + ? user.email.split('@')[1] + : 'plex.local'; + + const safeUsername = (profile.username || profile.title) + .replace(/\s+/g, '.') // Replace spaces with dots + .replace(/[^a-zA-Z0-9._-]/g, ''); // Remove any special characters + + const profileUser = new User({ + email: `${emailPrefix}+${safeUsername}@${domainPart}`, + plexUsername: profile.username || profile.title, + plexId: user.plexId, + plexToken: user.plexToken, + permissions: settings.main.defaultPermissions, + avatar: profile.thumb, + userType: UserType.PLEX, + plexProfileId: profile.id.toString(), + isPlexProfile: true, + mainPlexUserId: user.id, + }); + + await userRepository.save(profileUser); + } } - return res.status(200).json(user?.filter() ?? {}); + // Return main user ID and profiles for selection + const mainUserIdToSend = + user?.id && Number(user.id) > 0 ? Number(user.id) : 1; + + // Always return profiles for selection, regardless of PIN protection + return res.status(200).json({ + status: 'REQUIRES_PROFILE', + mainUserId: mainUserIdToSend, + profiles: profiles, + }); } catch (e) { logger.error('Something went wrong authenticating with Plex account', { label: 'API', @@ -219,6 +360,265 @@ authRoutes.post('/plex', async (req, res, next) => { } }); +authRoutes.post('/plex/profile/select', async (req, res, next) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + const profileId = req.body.profileId; + const mainUserIdRaw = req.body.mainUserId; + const pin = req.body.pin; + const authToken = req.body.authToken; + + if (!profileId) { + return next({ + status: 400, + message: 'Profile ID is required.', + }); + } + + let mainUserId = 1; // Default to admin user + + if (mainUserIdRaw) { + try { + mainUserId = + typeof mainUserIdRaw === 'string' + ? parseInt(mainUserIdRaw, 10) + : Number(mainUserIdRaw); + + if (isNaN(mainUserId) || mainUserId <= 0) { + mainUserId = 1; + } + } catch (e) { + mainUserId = 1; + } + } + + try { + const mainUser = await userRepository.findOne({ + where: { id: mainUserId }, + }); + + if (!mainUser) { + return next({ + status: 404, + message: 'Main user not found.', + }); + } + + const tokenToUse = authToken || mainUser.plexToken; + + if (!tokenToUse) { + return next({ + status: 400, + message: 'No valid Plex token available.', + }); + } + + const plextv = new PlexTvAPI(tokenToUse); + + const profiles = await plextv.getProfiles(); + const selectedProfile = profiles.find((p) => p.id === profileId); + + if (!selectedProfile) { + return next({ + status: 404, + message: 'Selected profile not found.', + }); + } + + if ( + profileId === mainUser.plexProfileId || + selectedProfile.isMainUser === true + ) { + // Check if PIN is required and not provided + if (selectedProfile.protected && !pin) { + return res.status(200).json({ + status: 'REQUIRES_PIN', + profileId: profileId, + profileName: + selectedProfile.title || selectedProfile.username || 'Main Account', + mainUserId: mainUserId, + }); + } + + if (selectedProfile.protected && pin) { + const isPinValid = await plextv.validateProfilePin(profileId, pin); + + if (!isPinValid) { + return next({ + status: 401, + message: 'Invalid PIN.', + error: ApiErrorCode.InvalidPin, + }); + } + + try { + await plextv.getUser(); + } catch (e) { + return next({ + status: 401, + message: 'Invalid PIN.', + error: ApiErrorCode.InvalidPin, + }); + } + } + + if (mainUser.plexProfileId !== profileId && selectedProfile.isMainUser) { + mainUser.plexProfileId = profileId; + await userRepository.save(mainUser); + } + + if (req.session) { + req.session.userId = mainUser.id; + } + + return res.status(200).json(mainUser.filter() ?? {}); + } + + if (selectedProfile.protected && !pin) { + return res.status(200).json({ + status: 'REQUIRES_PIN', + profileId: profileId, + profileName: + selectedProfile.title || selectedProfile.username || 'Unknown', + mainUserId: mainUserId, + }); + } + + if (selectedProfile.protected && pin) { + const isPinValid = await plextv.validateProfilePin(profileId, pin); + + if (!isPinValid) { + return next({ + status: 401, + message: 'Invalid PIN.', + error: ApiErrorCode.InvalidPin, + }); + } + } + + let profileUser = await userRepository.findOne({ + where: { plexProfileId: profileId }, + }); + + if (!profileUser) { + if (!settings.main.newPlexLogin) { + return next({ + status: 403, + error: ApiErrorCode.NewPlexLoginDisabled, + message: 'Access denied.', + }); + } + + const allUsers = await userRepository.find(); + const matchingUser = allUsers.find( + (u) => + u.plexProfileId?.includes(profileId) || + profileId.includes(u.plexProfileId || '') + ); + + if (matchingUser) { + profileUser = matchingUser; + } else { + return next({ + status: 404, + message: + 'Profile not found. Please sign in again to set up your profiles.', + }); + } + } + + profileUser.plexToken = tokenToUse; + + profileUser.avatar = selectedProfile.thumb; + profileUser.plexUsername = + selectedProfile.username || selectedProfile.title; + + profileUser.mainPlexUserId = mainUser.id; + profileUser.isPlexProfile = true; + + await userRepository.save(profileUser); + + if (req.session) { + req.session.userId = profileUser.id; + } + + // Return the profile user data + return res.status(200).json(profileUser.filter() ?? {}); + } catch (e) { + return next({ + status: 500, + message: 'Unable to select profile: ' + e.message, + }); + } +}); + +authRoutes.get('/plex/profiles/:userId', async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const userId = parseInt(req.params.userId, 10); + if (isNaN(userId)) { + return next({ + status: 400, + message: 'Invalid user ID format.', + }); + } + + const mainUser = await userRepository.findOne({ + where: { id: userId }, + }); + + if (!mainUser) { + return next({ + status: 404, + message: 'User not found.', + }); + } + + if (mainUser.userType !== UserType.PLEX) { + return next({ + status: 400, + message: 'Only Plex users have profiles.', + }); + } + + if (!mainUser.plexToken) { + return next({ + status: 400, + message: 'User has no valid Plex token.', + }); + } + + const plextv = new PlexTvAPI(mainUser.plexToken); + const profiles = await plextv.getProfiles(); + + const profileUsers = await userRepository.find({ + where: { + mainPlexUserId: mainUser.id, + isPlexProfile: true, + }, + }); + + return res.status(200).json({ + profiles, + profileUsers, + mainUser: mainUser.filter(), + }); + } catch (e) { + logger.error('Failed to fetch Plex profiles', { + label: 'API', + errorMessage: e.message, + ip: req.ip, + }); + + return next({ + status: 500, + message: 'Unable to fetch profiles.', + }); + } +}); + function getUserAvatarUrl(user: User): string { return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`; } diff --git a/src/components/Login/PlexPinEntry.tsx b/src/components/Login/PlexPinEntry.tsx new file mode 100644 index 00000000..82bc8291 --- /dev/null +++ b/src/components/Login/PlexPinEntry.tsx @@ -0,0 +1,142 @@ +import Button from '@app/components/Common/Button'; +import defineMessages from '@app/utils/defineMessages'; +import { ApiErrorCode } from '@server/constants/error'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Login.PlexPinEntry', { + pinRequired: 'PIN Required', + pinDescription: 'Enter the PIN for this profile', + submit: 'Submit', + cancel: 'Cancel', + invalidPin: 'Invalid PIN. Please try again.', + pinCheck: 'Checking PIN...', + accessDenied: 'Access denied.', +}); + +interface PlexPinEntryProps { + profileId: string; + profileName: string; + onSubmit: (pin: string) => Promise; + onCancel: () => void; + error?: string | null; +} + +const PlexPinEntry = ({ + profileName, + onSubmit, + onCancel, + error: externalError, +}: PlexPinEntryProps) => { + const intl = useIntl(); + const [pin, setPin] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [internalError, setInternalError] = useState(null); + + const displayError = externalError || internalError; + + useEffect(() => { + if (externalError) { + setInternalError(null); + } + }, [externalError]); + + const handleSubmit = async () => { + if (!pin || isSubmitting) return; + + setIsSubmitting(true); + setInternalError(null); + + try { + await onSubmit(pin); + } catch (err: any) { + const code = err?.response?.data?.error as string | undefined; + const httpStatus = err?.response?.status; + + let msg: string; + switch (code) { + case ApiErrorCode.InvalidPin: + msg = intl.formatMessage(messages.invalidPin); + break; + case ApiErrorCode.NewPlexLoginDisabled: + msg = intl.formatMessage(messages.accessDenied); + break; + default: + if (httpStatus === 401) { + msg = intl.formatMessage(messages.invalidPin); + } else if (httpStatus === 403) { + msg = intl.formatMessage(messages.accessDenied); + } else { + msg = + err?.response?.data?.message ?? + intl.formatMessage(messages.invalidPin); + } + } + + setInternalError(msg); + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && pin && !isSubmitting) { + handleSubmit(); + } + }; + + return ( +
+

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

+

+ {intl.formatMessage(messages.pinDescription)}{' '} + {profileName} +

+ + {displayError && ( +
+ {displayError} +
+ )} + +
+ setPin(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="• • • •" + maxLength={4} + autoFocus + pattern="[0-9]{4}" + inputMode="numeric" + /> +
+ +
+ + +
+
+ ); +}; + +export default PlexPinEntry; diff --git a/src/components/Login/PlexProfileSelector.tsx b/src/components/Login/PlexProfileSelector.tsx new file mode 100644 index 00000000..2210fab8 --- /dev/null +++ b/src/components/Login/PlexProfileSelector.tsx @@ -0,0 +1,152 @@ +import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; +import PlexPinEntry from '@app/components/Login/PlexPinEntry'; +import defineMessages from '@app/utils/defineMessages'; +import { LockClosedIcon } from '@heroicons/react/24/solid'; +import { PlexProfile } from '@server/api/plextv'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Login.PlexProfileSelector', { + profile: 'Profile', + selectProfile: 'Select Profile', + selectProfileDescription: 'Select which Plex profile you want to use', + selectProfileError: 'Failed to select profile', +}); + +interface PlexProfileSelectorProps { + profiles: PlexProfile[]; + mainUserId: number; + authToken?: string; + onProfileSelected: ( + profileId: string, + pin?: string, + onError?: (msg: string) => void + ) => Promise; +} + +const PlexProfileSelector = ({ + profiles, + mainUserId, + authToken, + onProfileSelected, +}: PlexProfileSelectorProps) => { + const intl = useIntl(); + const [selectedProfileId, setSelectedProfileId] = useState( + null + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [pinError, setPinError] = useState(null); + const [showPinEntry, setShowPinEntry] = useState(false); + const [selectedProfile, setSelectedProfile] = useState( + null + ); + + const handleProfileClick = (profile: PlexProfile) => { + setSelectedProfileId(profile.id); + setSelectedProfile(profile); + + if (profile.protected) { + setShowPinEntry(true); + } else { + onProfileSelected(profile.id, undefined, (msg) => { + setError(msg); + }); + } + }; + + const handlePinSubmit = async (pin: string) => { + if (!selectedProfileId) return; + await onProfileSelected(selectedProfileId, pin, setPinError); + }; + + const handlePinCancel = () => { + setShowPinEntry(false); + setSelectedProfile(null); + setSelectedProfileId(null); + setPinError(null); + }; + + if (showPinEntry && selectedProfile) { + return ( + + ); + } + + return ( +
+

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

+

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

+ + {error && ( +
+ {intl.formatMessage(messages.selectProfileError)} +
+ )} + +
+ {isSubmitting && ( +
+ +
+ )} + +
+ {profiles.map((profile) => ( + + ))} +
+
+
+ ); +}; + +export default PlexProfileSelector; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 907da8f2..bf803794 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -8,11 +8,15 @@ import LanguagePicker from '@app/components/Layout/LanguagePicker'; import JellyfinLogin from '@app/components/Login/JellyfinLogin'; import LocalLogin from '@app/components/Login/LocalLogin'; import PlexLoginButton from '@app/components/Login/PlexLoginButton'; +import PlexPinEntry from '@app/components/Login/PlexPinEntry'; +import PlexProfileSelector from '@app/components/Login/PlexProfileSelector'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { XCircleIcon } from '@heroicons/react/24/solid'; +import { PlexProfile } from '@server/api/plex'; +import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; import { useRouter } from 'next/dist/client/router'; @@ -29,6 +33,9 @@ const messages = defineMessages('components.Login', { signinwithjellyfin: 'Use your {mediaServerName} account', signinwithoverseerr: 'Use your {applicationTitle} account', orsigninwith: 'Or sign in with', + authFailed: 'Authentication failed', + invalidPin: 'Invalid PIN. Please try again.', + accessDenied: 'Access denied.', }); const Login = () => { @@ -39,36 +46,132 @@ const Login = () => { const [error, setError] = useState(''); const [isProcessing, setProcessing] = useState(false); - const [authToken, setAuthToken] = useState(undefined); + const [authToken, setAuthToken] = useState(); const [mediaServerLogin, setMediaServerLogin] = useState( settings.currentSettings.mediaServerLogin ); + const profilesRef = useRef([]); + const [profiles, setProfiles] = useState([]); + const [mainUserId, setMainUserId] = useState(null); + const [showProfileSelector, setShowProfileSelector] = useState(false); + const [showPinEntry, setShowPinEntry] = useState(false); + const [pinProfileId, setPinProfileId] = useState(null); + const [pinProfileName, setPinProfileName] = useState(null); + const [pinError, setPinError] = useState(null); // Effect that is triggered when the `authToken` comes back from the Plex OAuth - // We take the token and attempt to sign in. If we get a success message, we will - // ask swr to revalidate the user which _should_ come back with a valid user. useEffect(() => { const login = async () => { setProcessing(true); try { const response = await axios.post('/api/v1/auth/plex', { authToken }); - if (response.data?.id) { - revalidate(); + switch (response.data?.status) { + case 'REQUIRES_PIN': + setPinProfileId(response.data.profileId); + setPinProfileName(response.data.profileName); + setShowPinEntry(true); + break; + + case 'REQUIRES_PROFILE': + setProfiles(response.data.profiles); + profilesRef.current = response.data.profiles; + + const rawUserId = response.data.mainUserId; + let numericUserId = Number(rawUserId); + + if (!numericUserId || isNaN(numericUserId) || numericUserId <= 0) { + numericUserId = 1; + } + + setMainUserId(numericUserId); + setShowProfileSelector(true); + break; + + default: + if (response.data?.id) { + revalidate(); + } + break; } - } catch (e) { - setError(e.response?.data?.message); + } catch (e: any) { + const httpStatus = e?.response?.status; + const msg = + httpStatus === 403 + ? intl.formatMessage(messages.accessDenied) + : e?.response?.data?.message ?? + intl.formatMessage(messages.authFailed); + + setError(msg); setAuthToken(undefined); + } finally { setProcessing(false); } }; + if (authToken) { login(); } }, [authToken, revalidate]); - // Effect that is triggered whenever `useUser`'s user changes. If we get a new - // valid user, we redirect the user to the home page as the login was successful. + const handleSubmitProfile = async ( + profileId: string, + pin?: string, + onError?: (message: string) => void + ) => { + setProcessing(true); + setError(null); + + try { + const payload = { + profileId, + mainUserId, + ...(pin && { pin }), + ...(authToken && { authToken }), + }; + + const response = await axios.post( + '/api/v1/auth/plex/profile/select', + payload + ); + + if (response.data?.status === 'REQUIRES_PIN') { + setShowPinEntry(true); + setPinError(intl.formatMessage(messages.invalidPin)); + return; + } else { + setShowProfileSelector(false); + setShowPinEntry(false); + setPinError(null); + revalidate(); + } + } catch (e: any) { + const code = e?.response?.data?.error as string | undefined; + const httpStatus = e?.response?.status; + let msg: string; + + switch (code) { + case ApiErrorCode.NewPlexLoginDisabled: + msg = intl.formatMessage(messages.accessDenied); + break; + case ApiErrorCode.InvalidPin: + msg = intl.formatMessage(messages.invalidPin); + break; + default: + if (httpStatus === 401) { + msg = intl.formatMessage(messages.invalidPin); + } else if (httpStatus === 403) { + msg = intl.formatMessage(messages.accessDenied); + } else { + msg = + e?.response?.data?.message ?? + intl.formatMessage(messages.authFailed); + } + } + setError(msg); + } + }; + useEffect(() => { if (user) { router.push('/'); @@ -197,48 +300,77 @@ const Login = () => {
- - { - loginRef.current?.addEventListener( - 'transitionend', - done, - false - ); + {showPinEntry && pinProfileId && pinProfileName ? ( + { + setShowPinEntry(false); + setPinProfileId(null); + setPinProfileName(null); + setPinError(null); + setShowProfileSelector(true); }} - onEntered={() => { - document - .querySelector('#email, #username') - ?.focus(); - }} - classNames={{ - appear: 'opacity-0', - appearActive: 'transition-opacity duration-500 opacity-100', - enter: 'opacity-0', - enterActive: 'transition-opacity duration-500 opacity-100', - exitActive: 'transition-opacity duration-0 opacity-0', - }} - > -
- {isJellyfin && - (mediaServerLogin || - !settings.currentSettings.localLogin) ? ( - - ) : ( - settings.currentSettings.localLogin && ( - - ) - )} -
-
-
+ error={pinError} + /> + ) : showProfileSelector ? ( + + handleSubmitProfile(profileId, pin, onError) + } + /> + ) : ( + + { + loginRef.current?.addEventListener( + 'transitionend', + done, + false + ); + }} + onEntered={() => { + document + .querySelector('#email, #username') + ?.focus(); + }} + classNames={{ + appear: 'opacity-0', + appearActive: + 'transition-opacity duration-500 opacity-100', + enter: 'opacity-0', + enterActive: + 'transition-opacity duration-500 opacity-100', + exitActive: 'transition-opacity duration-0 opacity-0', + }} + > +
+ {isJellyfin && + (mediaServerLogin || + !settings.currentSettings.localLogin) ? ( + + ) : ( + settings.currentSettings.localLogin && ( + + ) + )} +
+
+
+ )} - {additionalLoginOptions.length > 0 && + {!showProfileSelector && + !showPinEntry && + additionalLoginOptions.length > 0 && (loginFormVisible ? (
@@ -253,13 +385,15 @@ const Login = () => { ))} -
- {additionalLoginOptions} -
+ {!showProfileSelector && !showPinEntry && ( +
+ {additionalLoginOptions} +
+ )}
diff --git a/src/components/Setup/SetupLogin.tsx b/src/components/Setup/SetupLogin.tsx index 76821808..f0e98b2b 100644 --- a/src/components/Setup/SetupLogin.tsx +++ b/src/components/Setup/SetupLogin.tsx @@ -34,22 +34,37 @@ const SetupLogin: React.FC = ({ MediaServerType.NOT_CONFIGURED ); const { user, revalidate } = useUser(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); // Effect that is triggered when the `authToken` comes back from the Plex OAuth - // We take the token and attempt to login. If we get a success message, we will - // ask swr to revalidate the user which _shouid_ come back with a valid user. - useEffect(() => { const login = async () => { - const response = await axios.post('/api/v1/auth/plex', { - authToken: authToken, - }); + if (!authToken) return; - if (response.data?.email) { - revalidate(); + setIsLoading(true); + setError(null); + + try { + const response = await axios.post('/api/v1/auth/plex', { + authToken, + isSetup: true, + }); + + if (response.status >= 200 && response.status < 300) { + revalidate(); + } + } catch (err) { + setError( + err.response?.data?.message || + 'Failed to connect to Plex. Please try again.' + ); + } finally { + setIsLoading(false); } }; - if (authToken && mediaServerType == MediaServerType.PLEX) { + + if (authToken && mediaServerType === MediaServerType.PLEX) { login(); } }, [authToken, mediaServerType, revalidate]); @@ -58,7 +73,7 @@ const SetupLogin: React.FC = ({ if (user) { onComplete(); } - }, [user, mediaServerType, onComplete]); + }, [user, onComplete]); return (
@@ -74,14 +89,20 @@ const SetupLogin: React.FC = ({ )}
+ + {error && ( +
{error}
+ )} + {serverType === MediaServerType.PLEX && ( <>
{ + onAuthToken={(token) => { setMediaServerType(MediaServerType.PLEX); - setAuthToken(authToken); + setAuthToken(token); }} />
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 312dc47f..b2e32b6b 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -237,7 +237,19 @@ "components.Layout.VersionStatus.outofdate": "Out of Date", "components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop", "components.Layout.VersionStatus.streamstable": "Jellyseerr Stable", + "components.Login.PlexPinEntry.cancel": "Cancel", + "components.Login.PlexPinEntry.invalidPin": "Invalid PIN. Please try again.", + "components.Login.PlexPinEntry.pinCheck": "Checking PIN...", + "components.Login.PlexPinEntry.pinDescription": "Enter the PIN for this profile", + "components.Login.PlexPinEntry.pinRequired": "PIN Required", + "components.Login.PlexPinEntry.submit": "Submit", + "components.Login.PlexProfileSelector.profile": "Profile", + "components.Login.PlexProfileSelector.selectProfile": "Select Profile", + "components.Login.PlexProfileSelector.selectProfileDescription": "Select which Plex profile you want to use", + "components.Login.PlexProfileSelector.selectProfileError": "Failed to select profile", + "components.Login.accessDenied": "Access denied.", "components.Login.adminerror": "You must use an admin account to sign in.", + "components.Login.authFailed": "Authentication failed", "components.Login.back": "Go back", "components.Login.credentialerror": "The username or password is incorrect.", "components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.", @@ -248,6 +260,7 @@ "components.Login.hostname": "{mediaServerName} URL", "components.Login.initialsignin": "Connect", "components.Login.initialsigningin": "Connecting…", + "components.Login.invalidPin": "Invalid PIN. Please try again.", "components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.", "components.Login.loginerror": "Something went wrong while trying to sign in.", "components.Login.loginwithapp": "Login with {appName}",