feat(auth): support Plex home profile login
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<PlexProfile[]> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const settings = getSettings();
|
||||
|
||||
|
||||
@@ -9,4 +9,6 @@ export enum ApiErrorCode {
|
||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||
Unauthorized = 'UNAUTHORIZED',
|
||||
Unknown = 'UNKNOWN',
|
||||
InvalidPin = 'INVALID_PIN',
|
||||
NewPlexLoginDisabled = 'NEW_PLEX_LOGIN_DISABLED',
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.dropColumn('user', 'plexProfileId');
|
||||
await queryRunner.dropColumn('user', 'isPlexProfile');
|
||||
await queryRunner.dropColumn('user', 'mainPlexUserId');
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.dropColumn('user', 'plexProfileId');
|
||||
await queryRunner.dropColumn('user', 'isPlexProfile');
|
||||
await queryRunner.dropColumn('user', 'mainPlexUserId');
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
142
src/components/Login/PlexPinEntry.tsx
Normal file
142
src/components/Login/PlexPinEntry.tsx
Normal file
@@ -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<void>;
|
||||
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<string | null>(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 (
|
||||
<div className="w-full max-w-md">
|
||||
<h2 className="mb-6 text-center text-xl font-bold text-gray-100">
|
||||
{intl.formatMessage(messages.pinRequired)}
|
||||
</h2>
|
||||
<p className="mb-6 text-center text-sm text-gray-300">
|
||||
{intl.formatMessage(messages.pinDescription)}{' '}
|
||||
<strong>{profileName}</strong>
|
||||
</p>
|
||||
|
||||
{displayError && (
|
||||
<div
|
||||
className="mb-4 rounded-md bg-red-500/90 p-3 text-center text-sm font-medium text-white shadow-md transition-all duration-300"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-md bg-white/10 px-4 py-3 text-center font-mono text-3xl tracking-[0.3em] text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="• • • •"
|
||||
maxLength={4}
|
||||
autoFocus
|
||||
pattern="[0-9]{4}"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button buttonType="default" onClick={onCancel} className="mr-2 flex-1">
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!pin || isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
className="ml-2 flex-1"
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.pinCheck)
|
||||
: intl.formatMessage(messages.submit)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlexPinEntry;
|
||||
152
src/components/Login/PlexProfileSelector.tsx
Normal file
152
src/components/Login/PlexProfileSelector.tsx
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
const PlexProfileSelector = ({
|
||||
profiles,
|
||||
mainUserId,
|
||||
authToken,
|
||||
onProfileSelected,
|
||||
}: PlexProfileSelectorProps) => {
|
||||
const intl = useIntl();
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pinError, setPinError] = useState<string | null>(null);
|
||||
const [showPinEntry, setShowPinEntry] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState<PlexProfile | null>(
|
||||
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 (
|
||||
<PlexPinEntry
|
||||
profileId={selectedProfileId!}
|
||||
profileName={
|
||||
selectedProfile.title ||
|
||||
selectedProfile.username ||
|
||||
intl.formatMessage(messages.profile)
|
||||
}
|
||||
onSubmit={handlePinSubmit}
|
||||
onCancel={handlePinCancel}
|
||||
error={pinError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h2 className="mb-6 text-center text-xl font-bold text-gray-100">
|
||||
{intl.formatMessage(messages.selectProfile)}
|
||||
</h2>
|
||||
<p className="mb-6 text-center text-sm text-gray-300">
|
||||
{intl.formatMessage(messages.selectProfileDescription)}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-600 p-3 text-white">
|
||||
{intl.formatMessage(messages.selectProfileError)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative mb-6">
|
||||
{isSubmitting && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/50">
|
||||
<SmallLoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
onClick={() => handleProfileClick(profile)}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(selectedProfileId === profile.id && !profile.protected)
|
||||
}
|
||||
className={`relative flex transform flex-col items-center rounded-2xl p-5 transition-all hover:scale-105 hover:shadow-lg ${
|
||||
selectedProfileId === profile.id
|
||||
? 'bg-indigo-600 ring-2 ring-indigo-400'
|
||||
: 'border border-white/20 bg-white/10 backdrop-blur-sm'
|
||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<div className="mb-4 h-20 w-20 overflow-hidden rounded-full shadow-md ring-2 ring-white/30">
|
||||
<img
|
||||
src={profile.thumb}
|
||||
alt={profile.title || profile.username || 'Profile'}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="mb-1 w-full break-words text-center text-base font-semibold text-white"
|
||||
title={profile.username || profile.title}
|
||||
>
|
||||
{profile.username || profile.title}
|
||||
</span>
|
||||
|
||||
{profile.protected && (
|
||||
<div className="mt-2 text-gray-400">
|
||||
<LockClosedIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlexProfileSelector;
|
||||
@@ -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<string | undefined>(undefined);
|
||||
const [authToken, setAuthToken] = useState<string | undefined>();
|
||||
const [mediaServerLogin, setMediaServerLogin] = useState(
|
||||
settings.currentSettings.mediaServerLogin
|
||||
);
|
||||
const profilesRef = useRef<PlexProfile[]>([]);
|
||||
const [profiles, setProfiles] = useState<PlexProfile[]>([]);
|
||||
const [mainUserId, setMainUserId] = useState<number | null>(null);
|
||||
const [showProfileSelector, setShowProfileSelector] = useState(false);
|
||||
const [showPinEntry, setShowPinEntry] = useState(false);
|
||||
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
|
||||
const [pinProfileName, setPinProfileName] = useState<string | null>(null);
|
||||
const [pinError, setPinError] = useState<string | null>(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 = () => {
|
||||
</div>
|
||||
</Transition>
|
||||
<div className="px-10 py-8">
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={mediaServerLogin ? 'ms' : 'local'}
|
||||
nodeRef={loginRef}
|
||||
addEndListener={(done) => {
|
||||
loginRef.current?.addEventListener(
|
||||
'transitionend',
|
||||
done,
|
||||
false
|
||||
);
|
||||
{showPinEntry && pinProfileId && pinProfileName ? (
|
||||
<PlexPinEntry
|
||||
profileId={pinProfileId}
|
||||
profileName={pinProfileName}
|
||||
onSubmit={handleSubmitProfile}
|
||||
onCancel={() => {
|
||||
setShowPinEntry(false);
|
||||
setPinProfileId(null);
|
||||
setPinProfileName(null);
|
||||
setPinError(null);
|
||||
setShowProfileSelector(true);
|
||||
}}
|
||||
onEntered={() => {
|
||||
document
|
||||
.querySelector<HTMLInputElement>('#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',
|
||||
}}
|
||||
>
|
||||
<div ref={loginRef} className="button-container">
|
||||
{isJellyfin &&
|
||||
(mediaServerLogin ||
|
||||
!settings.currentSettings.localLogin) ? (
|
||||
<JellyfinLogin
|
||||
serverType={settings.currentSettings.mediaServerType}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
) : (
|
||||
settings.currentSettings.localLogin && (
|
||||
<LocalLogin revalidate={revalidate} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
error={pinError}
|
||||
/>
|
||||
) : showProfileSelector ? (
|
||||
<PlexProfileSelector
|
||||
profiles={profiles}
|
||||
mainUserId={mainUserId || 1}
|
||||
authToken={authToken}
|
||||
onProfileSelected={(profileId, pin, onError) =>
|
||||
handleSubmitProfile(profileId, pin, onError)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={mediaServerLogin ? 'ms' : 'local'}
|
||||
nodeRef={loginRef}
|
||||
addEndListener={(done) => {
|
||||
loginRef.current?.addEventListener(
|
||||
'transitionend',
|
||||
done,
|
||||
false
|
||||
);
|
||||
}}
|
||||
onEntered={() => {
|
||||
document
|
||||
.querySelector<HTMLInputElement>('#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',
|
||||
}}
|
||||
>
|
||||
<div ref={loginRef} className="button-container">
|
||||
{isJellyfin &&
|
||||
(mediaServerLogin ||
|
||||
!settings.currentSettings.localLogin) ? (
|
||||
<JellyfinLogin
|
||||
serverType={settings.currentSettings.mediaServerType}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
) : (
|
||||
settings.currentSettings.localLogin && (
|
||||
<LocalLogin revalidate={revalidate} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
)}
|
||||
|
||||
{additionalLoginOptions.length > 0 &&
|
||||
{!showProfileSelector &&
|
||||
!showPinEntry &&
|
||||
additionalLoginOptions.length > 0 &&
|
||||
(loginFormVisible ? (
|
||||
<div className="flex items-center py-5">
|
||||
<div className="flex-grow border-t border-gray-600"></div>
|
||||
@@ -253,13 +385,15 @@ const Login = () => {
|
||||
</h2>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`flex w-full flex-wrap gap-2 ${
|
||||
!loginFormVisible ? 'flex-col' : ''
|
||||
}`}
|
||||
>
|
||||
{additionalLoginOptions}
|
||||
</div>
|
||||
{!showProfileSelector && !showPinEntry && (
|
||||
<div
|
||||
className={`flex w-full flex-wrap gap-2 ${
|
||||
!loginFormVisible ? 'flex-col' : ''
|
||||
}`}
|
||||
>
|
||||
{additionalLoginOptions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
@@ -34,22 +34,37 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
||||
MediaServerType.NOT_CONFIGURED
|
||||
);
|
||||
const { user, revalidate } = useUser();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<LoginWithMediaServerProps> = ({
|
||||
if (user) {
|
||||
onComplete();
|
||||
}
|
||||
}, [user, mediaServerType, onComplete]);
|
||||
}, [user, onComplete]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -74,14 +89,20 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
||||
<FormattedMessage {...messages.signinWithPlex} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded bg-red-600 p-3 text-white">{error}</div>
|
||||
)}
|
||||
|
||||
{serverType === MediaServerType.PLEX && (
|
||||
<>
|
||||
<div className="flex justify-center bg-black/30 px-10 py-8">
|
||||
<PlexLoginButton
|
||||
isProcessing={isLoading}
|
||||
large
|
||||
onAuthToken={(authToken) => {
|
||||
onAuthToken={(token) => {
|
||||
setMediaServerType(MediaServerType.PLEX);
|
||||
setAuthToken(authToken);
|
||||
setAuthToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user