Compare commits

..

7 Commits

Author SHA1 Message Date
0xsysr3ll
438ccae24c fix(selector): update type annotation for validKeywords 2025-07-30 22:30:29 +02:00
0xsysr3ll
8a13fca3a9 fix(blacklist): add type annotation for validKeywords 2025-07-30 22:26:32 +02:00
0xsysr3ll
999a625b0c refactor(blacklist): return null instead of 404 for missing keywords 2025-07-30 22:18:41 +02:00
0xsysr3ll
0c0d029969 fix(blacklist): remove non-null assertion and add proper type annotation 2025-07-29 00:53:51 +02:00
0xsysr3ll
5ed4e0c714 fix(blacklist): only remove keywords on 404 errors 2025-07-29 00:07:26 +02:00
0xsysr3ll
c36b6d5436 fix(blacklist): handle invalid keywords gracefully 2025-07-28 23:51:13 +02:00
Gauthier
e98f31e66c fix(proxy): initialize image proxies after the proxy is set up (#1794)
The ImageProxy for TMDB and TheTVDB were initialized before the proxy settings were set up, so they
were ignoring the proxy settings.

fix #1787
2025-07-24 10:33:53 +02:00
24 changed files with 386 additions and 2314 deletions

View File

@@ -133,18 +133,6 @@ 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
@@ -206,27 +194,6 @@ 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:
@@ -3691,17 +3658,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.
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.
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.
security: []
tags:
- auth
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/User'
requestBody:
required: true
content:
@@ -3711,155 +3678,8 @@ 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
@@ -7448,11 +7268,22 @@ paths:
example: 1
responses:
'200':
description: Keyword returned
description: Keyword returned (null if not found)
content:
application/json:
schema:
nullable: true
$ref: '#/components/schemas/Keyword'
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Unable to retrieve keyword data.'
/watchproviders/regions:
get:
summary: Get watch provider regions

View File

@@ -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,37 +31,6 @@ 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 {
$: {
@@ -256,156 +225,6 @@ 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 switchProfile(
profileId: string,
pin?: string
): Promise<boolean> {
const urlPath = `/api/v2/home/users/${profileId}/switch`;
try {
// @codeql-disable-next-line XssThrough -- False positive: baseURL is hardcoded to Plex API
const response = await axios.post(urlPath, pin ? { pin } : {}, {
baseURL: 'https://clients.plex.tv',
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.warn('Failed to switch Plex profile', {
label: 'Plex.TV Metadata API',
errorMessage: e.message,
profileId,
});
return false;
}
}
public async validateProfilePin(
profileId: string,
pin: string
): Promise<boolean> {
try {
const success = await this.switchProfile(profileId, pin);
return success;
} 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();

View File

@@ -1054,7 +1054,7 @@ class TheMovieDb extends ExternalAPI {
keywordId,
}: {
keywordId: number;
}): Promise<TmdbKeyword> {
}): Promise<TmdbKeyword | null> {
try {
const data = await this.get<TmdbKeyword>(
`/keyword/${keywordId}`,
@@ -1064,6 +1064,9 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
if (e.response?.status === 404) {
return null;
}
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
}
}

View File

@@ -9,7 +9,4 @@ export enum ApiErrorCode {
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unauthorized = 'UNAUTHORIZED',
Unknown = 'UNKNOWN',
InvalidPin = 'INVALID_PIN',
NewPlexLoginDisabled = 'NEW_PLEX_LOGIN_DISABLED',
ProfileUserExists = 'PROFILE_USER_EXISTS',
}

View File

@@ -91,15 +91,6 @@ 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;

View File

@@ -72,6 +72,7 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
const blacklistedTagsArr = blacklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit;
const invalidKeywords = new Set<string>();
if (blacklistedTags.length === 0) {
return;
@@ -87,6 +88,19 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
// Iterate for each tag
for (const tag of blacklistedTagsArr) {
const keywordDetails = await tmdb.getKeywordDetails({
keywordId: Number(tag),
});
if (keywordDetails === null) {
logger.warn('Skipping invalid keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
keywordId: tag,
});
invalidKeywords.add(tag);
continue;
}
let queryMax = pageLimit * SortOptionsIterable.length;
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
@@ -102,24 +116,51 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
throw new AbortTransaction();
}
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
try {
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
}
} catch (error) {
logger.error('Error processing keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
keywordId: tag,
errorMessage: error.message,
});
}
}
}
}
if (invalidKeywords.size > 0) {
const currentTags = blacklistedTagsArr.filter(
(tag) => !invalidKeywords.has(tag)
);
const cleanedTags = currentTags.join(',');
if (cleanedTags !== blacklistedTags) {
settings.main.blacklistedTags = cleanedTags;
await settings.save();
logger.info('Cleaned up invalid keywords from settings', {
label: 'Blacklisted Tags Processor',
removedKeywords: Array.from(invalidKeywords),
newBlacklistedTags: cleanedTags,
});
}
}
}
private async processResults(

View File

@@ -1,21 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlexProfilesSupport1745265840052 implements MigrationInterface {
name = 'AddPlexProfilesSupport1745265840052';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ADD "plexProfileId" character varying`
);
await queryRunner.query(
`ALTER TABLE "user" ADD "isPlexProfile" boolean NOT NULL DEFAULT false`
);
await queryRunner.query(`ALTER TABLE "user" ADD "mainPlexUserId" integer`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "mainPlexUserId"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isPlexProfile"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexProfileId"`);
}
}

View File

@@ -1,21 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlexProfilesSupport1745265825619 implements MigrationInterface {
name = 'AddPlexProfilesSupport1745265825619';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ADD "plexProfileId" character varying`
);
await queryRunner.query(
`ALTER TABLE "user" ADD "isPlexProfile" boolean NOT NULL DEFAULT false`
);
await queryRunner.query(`ALTER TABLE "user" ADD "mainPlexUserId" integer`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "mainPlexUserId"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isPlexProfile"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexProfileId"`);
}
}

View File

@@ -18,6 +18,7 @@ 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) => {
@@ -48,12 +49,7 @@ 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;
profileId?: string;
pin?: string;
isSetup?: boolean;
};
const body = req.body as { authToken?: string };
if (!body.authToken) {
return next({
@@ -69,97 +65,12 @@ 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);
// 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
// Next let's see if the user already exists
let user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
@@ -168,40 +79,7 @@ authRoutes.post('/plex', async (req, res, next) => {
})
.getOne();
const safeUsername = (account.username || account.title)
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9._-]/g, '');
const emailPrefix = account.email.split('@')[0];
const domainPart = account.email.includes('@')
? account.email.split('@')[1]
: 'plex.local';
const proposedEmail = `${emailPrefix}+${safeUsername}@${domainPart}`;
const existingProfileUser = await userRepository.findOne({
where: [
{ plexUsername: account.username, isPlexProfile: true },
{ email: proposedEmail, isPlexProfile: true },
],
});
if (!user && existingProfileUser) {
logger.warn(
'Main user login attempted but profile user already exists for this person',
{
label: 'Auth',
plexUsername: account.username,
email: account.email,
profileUserId: existingProfileUser.id,
}
);
return next({
status: 409,
message:
'A profile user already exists for this Plex account. Please contact your administrator to resolve this duplicate.',
error: ApiErrorCode.ProfileUserExists,
});
}
if (!user && !(await userRepository.count())) {
// First user setup through standard auth flow
user = new User({
email: account.email,
plexUsername: account.username,
@@ -210,8 +88,6 @@ 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;
@@ -259,15 +135,13 @@ 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) {
@@ -283,11 +157,19 @@ authRoutes.post('/plex', async (req, res, next) => {
);
return next({
status: 403,
error: ApiErrorCode.NewPlexLoginDisabled,
message: 'Access denied.',
});
} else {
// Create new user
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,
}
);
user = new User({
email: account.email,
plexUsername: account.username,
@@ -296,15 +178,13 @@ 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.info(
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
logger.warn(
'Failed sign-in attempt by Plex user without access to the media server',
{
label: 'API',
ip: req.ip,
@@ -315,62 +195,17 @@ authRoutes.post('/plex', async (req, res, next) => {
);
return next({
status: 403,
error: ApiErrorCode.NewPlexLoginDisabled,
message: 'Access denied.',
});
}
}
const adminUser = await userRepository.findOne({ where: { id: 1 } });
const isMainUser = profiles.some(
(profile) => profile.isMainUser && profile.id === account.id.toString()
);
const isAdmin = user?.id === adminUser?.id;
if (isMainUser || isAdmin) {
// Only update existing profiles for the main user
for (const profile of profiles) {
if (profile.isMainUser) continue;
const existingProfileUser = await userRepository.findOne({
where: { plexProfileId: profile.id },
});
if (existingProfileUser) {
// Only update profiles that don't have their own Plex ID
// or are already marked as profiles
if (
!existingProfileUser.plexId ||
existingProfileUser.plexId === user.plexId ||
existingProfileUser.isPlexProfile
) {
existingProfileUser.plexToken = user.plexToken;
existingProfileUser.avatar = profile.thumb;
existingProfileUser.plexUsername =
profile.username || profile.title;
await userRepository.save(existingProfileUser);
}
}
}
// Set logged in session
if (req.session) {
req.session.userId = user.id;
}
if (isAdmin || isMainUser) {
// Return main user ID and profiles for selection
const mainUserIdToSend =
user?.id && Number(user.id) > 0 ? Number(user.id) : 1;
return res.status(200).json({
status: 'REQUIRES_PROFILE',
mainUserId: mainUserIdToSend,
profiles: profiles,
});
} else {
// For non-main users, just log them in directly
if (req.session) {
req.session.userId = user.id;
}
return res.status(200).json(user?.filter() ?? {});
}
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
logger.error('Something went wrong authenticating with Plex account', {
label: 'API',
@@ -384,364 +219,6 @@ 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,
});
}
}
const userAccount = await plextv.getUser();
const adminUser = await userRepository.findOne({ where: { id: 1 } });
const isMainPlexUser = profiles.some(
(profile) =>
profile.isMainUser && profile.id === userAccount.id.toString()
);
const isAdminUser = mainUser.id === adminUser?.id;
let profileUser = await userRepository.findOne({
where: [
{ plexProfileId: profileId },
{ plexUsername: selectedProfile.username || selectedProfile.title },
],
});
// Profile doesn't exist yet - only allow creation for admin/main Plex user
if (!profileUser) {
// Profile doesn't exist yet
if (!settings.main.newPlexLogin) {
return next({
status: 403,
error: ApiErrorCode.NewPlexLoginDisabled,
message: 'Access denied.',
});
}
// Only allow profile creation for main Plex user or admin user
if (!isMainPlexUser && !isAdminUser) {
return next({
status: 403,
message: 'Only the Plex server owner can create profile users.',
});
}
// Check for existing users that might match this profile
const emailPrefix = mainUser.email.split('@')[0];
const domainPart = mainUser.email.includes('@')
? mainUser.email.split('@')[1]
: 'plex.local';
const safeUsername = (selectedProfile.username || selectedProfile.title)
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9._-]/g, '');
const proposedEmail = `${emailPrefix}+${safeUsername}@${domainPart}`;
// First check for existing user with this email
const existingEmailUser = await userRepository.findOne({
where: { email: proposedEmail },
});
if (existingEmailUser) {
logger.warn('Found existing user with same email as profile', {
label: 'Auth',
email: proposedEmail,
profileId,
existingUserId: existingEmailUser.id,
});
// Use the existing user
profileUser = existingEmailUser;
if (req.session) {
req.session.userId = profileUser.id;
}
return res.status(200).json(profileUser.filter() ?? {});
} else {
// Then check for any other potential matches
const exactProfileUser = await userRepository.findOne({
where: { plexProfileId: profileId },
});
if (exactProfileUser) {
logger.info('Found existing profile user with exact ID match', {
label: 'Auth',
profileId,
userId: exactProfileUser.id,
});
if (req.session) {
req.session.userId = exactProfileUser.id;
}
return res.status(200).json(exactProfileUser.filter() ?? {});
} else {
// Create a new profile user
profileUser = new User({
email: proposedEmail,
plexUsername: selectedProfile.username || selectedProfile.title,
plexId: mainUser.plexId,
plexToken: tokenToUse,
permissions: settings.main.defaultPermissions,
avatar: selectedProfile.thumb,
userType: UserType.PLEX,
plexProfileId: profileId,
isPlexProfile: true,
mainPlexUserId: mainUser.id,
});
logger.info('Creating new profile user', {
label: 'Auth',
profileId,
email: proposedEmail,
});
await userRepository.save(profileUser);
if (req.session) {
req.session.userId = profileUser.id;
}
return res.status(200).json(profileUser.filter() ?? {});
}
}
} else {
// Profile exists - only set mainPlexUserId if it's the main user creating it
if (
profileUser.plexId &&
profileUser.plexId !== mainUser.plexId &&
!profileUser.isPlexProfile
) {
logger.warn('Attempted to use a regular Plex user as a profile', {
label: 'Auth',
profileId,
userId: profileUser.id,
mainUserId: mainUser.id,
});
// Simply use their account without modifying it
if (req.session) {
req.session.userId = profileUser.id;
}
return res.status(200).json(profileUser.filter() ?? {});
}
// Otherwise update and use this profile
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 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}`;
}

View File

@@ -128,11 +128,15 @@ discoverRoutes.get('/movies', async (req, res, next) => {
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
const keywordResults = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
}
return res.status(200).json({
@@ -415,11 +419,15 @@ discoverRoutes.get('/tv', async (req, res, next) => {
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
const keywordResults = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
}
return res.status(200).json({

View File

@@ -4,27 +4,40 @@ import { Router } from 'express';
const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
const tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
// Delay the initialization of ImageProxy instances until the proxy (if any) is properly configured
let _tmdbImageProxy: ImageProxy;
function initTmdbImageProxy() {
if (!_tmdbImageProxy) {
_tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
}
return _tmdbImageProxy;
}
let _tvdbImageProxy: ImageProxy;
function initTvdbImageProxy() {
if (!_tvdbImageProxy) {
_tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
}
return _tvdbImageProxy;
}
router.get('/:type/*', async (req, res) => {
const imagePath = req.path.replace(/^\/\w+/, '');
try {
let imageData;
if (req.params.type === 'tmdb') {
imageData = await tmdbImageProxy.getImage(imagePath);
imageData = await initTmdbImageProxy().getImage(imagePath);
} else if (req.params.type === 'tvdb') {
imageData = await tvdbImageProxy.getImage(imagePath);
imageData = await initTvdbImageProxy().getImage(imagePath);
} else {
logger.error('Unsupported image type', {
imagePath,

View File

@@ -471,13 +471,13 @@ settingsRoutes.get(
async (req, res, next) => {
const userRepository = getRepository(User);
const qb = userRepository.createQueryBuilder('user');
try {
const admin = await userRepository.findOneOrFail({
select: { id: true, plexToken: true },
where: { id: 1 },
});
const plexApi = new PlexTvAPI(admin.plexToken ?? '');
const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
(user) => user.$
).filter((user) => user.email);
@@ -503,7 +503,7 @@ settingsRoutes.get(
plexUsers.map(async (plexUser) => {
if (
!existingUsers.find(
(user: User) =>
(user) =>
user.plexId === parseInt(plexUser.id) ||
user.email === plexUser.email.toLowerCase()
) &&
@@ -513,36 +513,16 @@ settingsRoutes.get(
}
})
);
const profiles = await plexApi.getProfiles();
const existingProfileUsers = await userRepository.find({
where: {
isPlexProfile: true,
},
});
const unimportedProfiles = profiles.filter(
(profile) =>
!profile.isMainUser &&
!existingProfileUsers.some(
(user: User) => user.plexProfileId === profile.id
)
);
return res.status(200).json({
users: sortBy(unimportedPlexUsers, 'username'),
profiles: unimportedProfiles,
});
return res.status(200).json(sortBy(unimportedPlexUsers, 'username'));
} catch (e) {
logger.error(
'Something went wrong getting unimported Plex users and profiles',
{
label: 'API',
errorMessage: e.message,
}
);
logger.error('Something went wrong getting unimported Plex users', {
label: 'API',
errorMessage: e.message,
});
next({
status: 500,
message: 'Unable to retrieve unimported Plex users and profiles.',
message: 'Unable to retrieve unimported Plex users.',
});
}
}

View File

@@ -528,80 +528,43 @@ router.post(
try {
const settings = getSettings();
const userRepository = getRepository(User);
const { plexIds, profileIds } = req.body as {
plexIds?: string[];
profileIds?: string[];
};
const skippedItems: {
id: string;
type: 'user' | 'profile';
reason: string;
}[] = [];
const createdUsers: User[] = [];
const body = req.body as { plexIds: string[] } | undefined;
// taken from auth.ts
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, email: true, plexId: true },
select: { id: true, plexToken: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (plexIds && plexIds.length > 0) {
const plexUsersResponse = await mainPlexTv.getUsers();
const plexUsersResponse = await mainPlexTv.getUsers();
const createdUsers: User[] = [];
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
if (account.email) {
const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
if (account.email && plexIds.includes(account.id)) {
// Check for duplicate users more thoroughly
const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.orWhere('user.plexUsername = :username', {
username: account.username,
})
.getOne();
if (user) {
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
skippedItems.push({
id: account.id,
type: 'user',
reason: 'USER_ALREADY_EXISTS',
});
} else if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
// Check for profiles with the same username
const existingProfile = await userRepository.findOne({
where: {
plexUsername: account.username,
isPlexProfile: true,
},
});
if (existingProfile) {
skippedItems.push({
id: account.id,
type: 'user',
reason: 'PROFILE_WITH_SAME_NAME_EXISTS',
});
continue;
}
if (user) {
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
} else if (!body || body.plexIds.includes(account.id)) {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
const newUser = new User({
plexUsername: account.username,
email: account.email,
@@ -611,7 +574,6 @@ router.post(
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
@@ -619,89 +581,7 @@ router.post(
}
}
if (profileIds && profileIds.length > 0) {
const profiles = await mainPlexTv.getProfiles();
// Filter out real Plex users (with email/isMainUser) from importable profiles
const importableProfiles = profiles.filter((p: any) => !p.isMainUser);
for (const profileId of profileIds) {
const profileData = importableProfiles.find(
(p: any) => p.id === profileId
);
if (profileData) {
// Check for existing user with same plexProfileId
const existingUser = await userRepository.findOne({
where: { plexProfileId: profileId },
});
const emailPrefix = mainUser.email.split('@')[0];
const domainPart = mainUser.email.includes('@')
? mainUser.email.split('@')[1]
: 'plex.local';
const safeUsername = (profileData.username || profileData.title)
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9._-]/g, '');
const proposedEmail = `${emailPrefix}+${safeUsername}@${domainPart}`;
// Check for main user with same plexUsername or email
const mainUserDuplicate = await userRepository.findOne({
where: [
{
plexUsername: profileData.username || profileData.title,
isPlexProfile: false,
},
{ email: proposedEmail, isPlexProfile: false },
],
});
if (existingUser) {
// Skip this profile and add to skipped list
skippedItems.push({
id: profileId,
type: 'profile',
reason: 'DUPLICATE_USER_EXISTS',
});
continue;
}
if (mainUserDuplicate) {
// Skip this profile and add to skipped list, but ensure main user is imported
skippedItems.push({
id: profileId,
type: 'profile',
reason: 'MAIN_USER_ALREADY_EXISTS',
});
// If main user is not already in createdUsers, add it
if (!createdUsers.find((u) => u.id === mainUserDuplicate.id)) {
createdUsers.push(mainUserDuplicate);
}
continue;
}
const profileUser = new User({
email: proposedEmail,
plexUsername: profileData.username || profileData.title,
plexId: mainUser.plexId,
plexToken: mainUser.plexToken,
permissions: settings.main.defaultPermissions,
avatar: profileData.thumb,
userType: UserType.PLEX,
plexProfileId: profileId,
isPlexProfile: true,
mainPlexUserId: mainUser.id,
});
await userRepository.save(profileUser);
createdUsers.push(profileUser);
}
}
}
return res.status(201).json({
data: User.filterMany(createdUsers),
skipped: skippedItems,
});
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
}

View File

@@ -29,14 +29,10 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
Promise.all(
keywordIds.map(async (keywordId) => {
try {
const { data } = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return data.name;
} catch (err) {
return '';
}
const { data } = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return data?.name || `[Invalid: ${keywordId}]`;
})
).then((keywords) => {
setTagNamesBlacklistedFor(keywords.join(', '));

View File

@@ -5,7 +5,10 @@ import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { ArrowDownIcon } from '@heroicons/react/24/solid';
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
import type {
TmdbKeyword,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { useFormikContext } from 'formik';
@@ -124,15 +127,19 @@ const ControlledKeywordSelector = ({
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const { data } = await axios.get<Keyword>(
const { data } = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return data;
})
);
const validKeywords: TmdbKeyword[] = keywords.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
onChange(
keywords.map((keyword) => ({
validKeywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -77,16 +77,19 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
const keyword = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setDefaultDataValue(
keywords.map((keyword) => ({
validKeywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -1,195 +0,0 @@
import Button from '@app/components/Common/Button';
import defineMessages from '@app/utils/defineMessages';
import { LockClosedIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import { useEffect, useRef, 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;
profileThumb?: string | null;
isProtected?: boolean;
isMainUser?: boolean;
error?: string | null;
onSubmit: (pin: string) => Promise<void>;
onCancel: () => void;
}
const PlexPinEntry = ({
profileName,
profileThumb,
isProtected,
isMainUser,
error,
onSubmit,
onCancel,
}: PlexPinEntryProps) => {
const intl = useIntl();
const [pin, setPin] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleSubmit = async (pinToSubmit?: string) => {
const pinValue = pinToSubmit || pin;
if (!pinValue || isSubmitting) return;
setIsSubmitting(true);
try {
await onSubmit(pinValue);
setPin('');
} catch (err) {
setPin('');
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && pin && !isSubmitting) {
handleSubmit();
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D/g, '');
setPin(value);
if (value.length === 4 && !isSubmitting) {
handleSubmit(value);
}
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.select();
};
// PIN boxes rendering
const pinDigits = pin.split('').slice(0, 4);
const boxes = Array.from({ length: 4 }, (_, i) => (
<div
key={i}
className={`mx-2 flex h-12 w-12 items-center justify-center rounded-lg border-2 font-mono text-2xl transition-all
${
i === pin.length
? 'border-indigo-500 ring-2 ring-indigo-500'
: 'border-white/30'
}
${pinDigits[i] ? 'text-white' : 'text-white/40'}`}
aria-label={pinDigits[i] ? 'Entered' : 'Empty'}
>
{pinDigits[i] ? '•' : ''}
</div>
));
return (
<div className="mx-auto flex w-full max-w-md flex-col items-center rounded-2xl border border-white/20 bg-white/10 p-6 shadow-lg backdrop-blur">
<div className="flex w-full flex-col items-center">
{/* Avatar */}
<div className="relative mx-auto mb-1 flex h-20 w-20 shrink-0 grow-0 items-center justify-center overflow-hidden rounded-full bg-gray-900 shadow ring-2 ring-indigo-400">
{profileThumb ? (
<Image
src={profileThumb}
alt={profileName}
fill
sizes="80px"
className="object-cover"
/>
) : (
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-700 text-3xl font-bold text-white">
{profileName?.[0] || '?'}
</span>
)}
</div>
{/* Icons */}
<div className="mb-1 flex items-center justify-center gap-2">
{isProtected && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<LockClosedIcon className="h-4 w-4 text-indigo-400" />
</span>
)}
{isMainUser && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<svg
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4 text-yellow-400"
>
<path d="M2.166 6.5l3.5 7 4.334-7 4.334 7 3.5-7L17.5 17.5h-15z" />
</svg>
</span>
)}
</div>
<p className="mb-3 text-center text-base font-semibold text-white">
{profileName}
</p>
<h2 className="mb-3 text-center text-xl font-bold text-white">
{intl.formatMessage(messages.pinRequired)}
</h2>
<p className="mb-4 text-center text-sm text-gray-200">
{intl.formatMessage(messages.pinDescription)}
</p>
<div className="mb-4 flex flex-row items-center justify-center">
{boxes}
{/* Visually hidden input for keyboard entry */}
<input
ref={inputRef}
type="password"
className="absolute opacity-0"
value={pin}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
maxLength={4}
pattern="[0-9]{4}"
inputMode="numeric"
aria-label="PIN Input"
/>
</div>
{error && (
<div
className="mb-4 text-center font-medium text-red-400"
aria-live="polite"
>
{error}
</div>
)}
<div className="flex w-full 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>
</div>
);
};
export default PlexPinEntry;

View File

@@ -1,170 +0,0 @@
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 type { PlexProfile } from '@server/api/plextv';
import Image from 'next/image';
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 | undefined;
onProfileSelected: (
profileId: string,
pin?: string,
onError?: (msg: string) => void
) => Promise<void>;
}
const PlexProfileSelector = ({
profiles,
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 [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 {
setIsSubmitting(true);
try {
onProfileSelected(profile.id);
} catch (err) {
setError(intl.formatMessage(messages.selectProfileError));
} finally {
setIsSubmitting(false);
}
}
};
const handlePinSubmit = async (pin: string) => {
if (!selectedProfileId) return;
await onProfileSelected(selectedProfileId, pin);
};
const handlePinCancel = () => {
setShowPinEntry(false);
setSelectedProfile(null);
setSelectedProfileId(null);
};
if (showPinEntry && selectedProfile && selectedProfileId) {
return (
<PlexPinEntry
profileId={selectedProfileId}
profileName={
selectedProfile.title ||
selectedProfile.username ||
intl.formatMessage(messages.profile)
}
profileThumb={selectedProfile.thumb}
isProtected={selectedProfile.protected}
isMainUser={selectedProfile.isMainUser}
onSubmit={handlePinSubmit}
onCancel={handlePinCancel}
/>
);
}
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 justify-items-center gap-4 sm:grid-cols-3 sm:gap-6 md:gap-8">
{profiles.map((profile) => (
<button
key={profile.id}
type="button"
onClick={() => handleProfileClick(profile)}
disabled={
isSubmitting ||
(selectedProfileId === profile.id && !profile.protected)
}
className={`relative flex h-48 w-32 flex-col items-center justify-start rounded-2xl border border-white/20 bg-white/10 p-6 shadow-lg backdrop-blur transition-all hover:ring-2 hover:ring-indigo-400 ${
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="relative mx-auto mb-2 flex h-20 w-20 shrink-0 grow-0 items-center justify-center overflow-hidden rounded-full bg-gray-900 shadow ring-2 ring-indigo-400">
<Image
src={profile.thumb}
alt={profile.title || profile.username || 'Profile'}
fill
sizes="80px"
className="object-cover"
/>
</div>
<div className="mb-2 flex items-center justify-center gap-2">
{profile.protected && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<LockClosedIcon className="h-4 w-4 text-indigo-400" />
</span>
)}
{profile.isMainUser && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<svg
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4 text-yellow-400"
>
<path d="M2.166 6.5l3.5 7 4.334-7 4.334 7 3.5-7L17.5 17.5h-15z" />
</svg>
</span>
)}
</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>
</button>
))}
</div>
</div>
</div>
);
};
export default PlexProfileSelector;

View File

@@ -8,15 +8,11 @@ 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 type { PlexProfile } from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
@@ -33,11 +29,6 @@ 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.',
profileUserExists:
'A profile user already exists for this Plex account. Please contact your administrator to resolve this duplicate.',
});
const Login = () => {
@@ -48,158 +39,36 @@ const Login = () => {
const [error, setError] = useState('');
const [isProcessing, setProcessing] = useState(false);
const [authToken, setAuthToken] = useState<string | undefined>();
const [authToken, setAuthToken] = useState<string | undefined>(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 [pinProfileThumb, setPinProfileThumb] = useState<string | null>(null);
const [pinIsProtected, setPinIsProtected] = useState<boolean>(false);
const [pinIsMainUser, setPinIsMainUser] = useState<boolean>(false);
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 });
switch (response.data?.status) {
case 'REQUIRES_PIN': {
setPinProfileId(response.data.profileId);
setPinProfileName(response.data.profileName);
setPinProfileThumb(response.data.profileThumb);
setPinIsProtected(response.data.isProtected);
setPinIsMainUser(response.data.isMainUser);
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;
if (response.data?.id) {
revalidate();
}
} catch (e) {
const httpStatus = e?.response?.status;
const msg =
httpStatus === 403
? intl.formatMessage(messages.accessDenied)
: e?.response?.data?.message ??
intl.formatMessage(messages.authFailed);
setError(msg);
setError(e.response?.data?.message);
setAuthToken(undefined);
} finally {
setProcessing(false);
}
};
if (authToken) {
login();
}
}, [authToken, revalidate, intl]);
const handleSubmitProfile = async (
profileId: string,
pin?: string,
onError?: (msg: string) => void
) => {
setProcessing(true);
setError('');
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);
setPinProfileId(profileId);
setPinProfileName(
profiles.find((p) => p.id === profileId)?.title ||
profiles.find((p) => p.id === profileId)?.username ||
'Profile'
);
setPinProfileThumb(
profiles.find((p) => p.id === profileId)?.thumb || null
);
setPinIsProtected(
profiles.find((p) => p.id === profileId)?.protected || false
);
setPinIsMainUser(
profiles.find((p) => p.id === profileId)?.isMainUser || false
);
setPinError(intl.formatMessage(messages.invalidPin));
throw new Error('Invalid PIN');
} else {
setShowProfileSelector(false);
setShowPinEntry(false);
setPinError(null);
setPinProfileId(null);
setPinProfileName(null);
setPinProfileThumb(null);
setPinIsProtected(false);
setPinIsMainUser(false);
revalidate();
}
} catch (e) {
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;
case ApiErrorCode.ProfileUserExists:
msg = intl.formatMessage(messages.profileUserExists);
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);
if (onError) {
onError(msg);
}
}
};
}, [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.
useEffect(() => {
if (user) {
router.push('/');
@@ -328,85 +197,48 @@ const Login = () => {
</div>
</Transition>
<div className="px-10 py-8">
{showPinEntry && pinProfileId && pinProfileName ? (
<PlexPinEntry
profileId={pinProfileId}
profileName={pinProfileName}
profileThumb={pinProfileThumb}
isProtected={pinIsProtected}
isMainUser={pinIsMainUser}
error={pinError}
onSubmit={(pin) => {
return handleSubmitProfile(pinProfileId, pin);
<SwitchTransition mode="out-in">
<CSSTransition
key={mediaServerLogin ? 'ms' : 'local'}
nodeRef={loginRef}
addEndListener={(done) => {
loginRef.current?.addEventListener(
'transitionend',
done,
false
);
}}
onCancel={() => {
setShowPinEntry(false);
setPinProfileId(null);
setPinProfileName(null);
setPinProfileThumb(null);
setPinIsProtected(false);
setPinIsMainUser(false);
setPinError(null);
setShowProfileSelector(true);
onEntered={() => {
document
.querySelector<HTMLInputElement>('#email, #username')
?.focus();
}}
/>
) : 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>
)}
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>
{!showProfileSelector &&
!showPinEntry &&
additionalLoginOptions.length > 0 &&
{additionalLoginOptions.length > 0 &&
(loginFormVisible ? (
<div className="flex items-center py-5">
<div className="flex-grow border-t border-gray-600"></div>
@@ -421,15 +253,13 @@ const Login = () => {
</h2>
))}
{!showProfileSelector && !showPinEntry && (
<div
className={`flex w-full flex-wrap gap-2 ${
!loginFormVisible ? 'flex-col' : ''
}`}
>
{additionalLoginOptions}
</div>
)}
<div
className={`flex w-full flex-wrap gap-2 ${
!loginFormVisible ? 'flex-col' : ''
}`}
>
{additionalLoginOptions}
</div>
</div>
</>
</div>

View File

@@ -309,16 +309,19 @@ export const KeywordSelector = ({
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
const keyword = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setDefaultDataValue(
keywords.map((keyword) => ({
validKeywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))

View File

@@ -113,12 +113,16 @@ const OverrideRuleTiles = ({
.flat()
.filter((keywordId) => keywordId)
.map(async (keywordId) => {
const response = await axios.get(`/api/v1/keyword/${keywordId}`);
const keyword: Keyword = response.data;
return keyword;
const response = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return response.data;
})
);
setKeywords(keywords);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setKeywords(validKeywords);
const allUsersFromRules = rules
.map((rule) => rule.users)
.filter((users) => users)

View File

@@ -34,37 +34,22 @@ 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 () => {
if (!authToken) return;
const response = await axios.post('/api/v1/auth/plex', {
authToken: authToken,
});
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 (response.data?.email) {
revalidate();
}
};
if (authToken && mediaServerType === MediaServerType.PLEX) {
if (authToken && mediaServerType == MediaServerType.PLEX) {
login();
}
}, [authToken, mediaServerType, revalidate]);
@@ -73,7 +58,7 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
if (user) {
onComplete();
}
}, [user, onComplete]);
}, [user, mediaServerType, onComplete]);
return (
<div className="p-4">
@@ -89,20 +74,14 @@ 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={(token) => {
onAuthToken={(authToken) => {
setMediaServerType(MediaServerType.PLEX);
setAuthToken(token);
setAuthToken(authToken);
}}
/>
</div>

View File

@@ -5,7 +5,7 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import axios from 'axios';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
@@ -16,31 +16,14 @@ interface PlexImportProps {
}
const messages = defineMessages('components.UserList', {
importfromplex: 'Import Plex Users & Profiles',
importfromplexerror:
'Something went wrong while importing Plex users and profiles.',
importfromplex: 'Import Plex Users',
importfromplexerror: 'Something went wrong while importing Plex users.',
importedfromplex:
'<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!',
user: 'User',
profile: 'Profile',
nouserstoimport: 'There are no Plex users or profiles to import.',
nouserstoimport: 'There are no Plex users to import.',
newplexsigninenabled:
'The <strong>Enable New Plex Sign-In</strong> setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.',
possibleDuplicate: 'Possible duplicate',
duplicateUserWarning:
'This user appears to be a duplicate of an existing user or profile.',
duplicateProfileWarning:
'This profile appears to be a duplicate of an existing user or profile.',
importSuccess:
'{count, plural, one {# item was} other {# items were}} imported successfully.',
importSuccessUsers:
'{count, plural, one {# user was} other {# users were}} imported successfully.',
importSuccessProfiles:
'{count, plural, one {# profile was} other {# profiles were}} imported successfully.',
importSuccessMixed:
'{userCount, plural, one {# user} other {# users}} and {profileCount, plural, one {# profile} other {# profiles}} were imported successfully.',
skippedUsersDuplicates:
'{count, plural, one {# user was} other {# users were}} skipped due to duplicates.',
skippedProfilesDuplicates:
'{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.',
});
const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
@@ -49,148 +32,44 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
const { addToast } = useToasts();
const [isImporting, setImporting] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
const [duplicateMap, setDuplicateMap] = useState<{
[key: string]: { type: 'user' | 'profile'; duplicateWith: string[] };
}>({});
const { data, error } = useSWR<{
users: {
const { data, error } = useSWR<
{
id: string;
title: string;
username: string;
email: string;
thumb: string;
}[];
profiles: {
id: string;
title: string;
username?: string;
thumb: string;
isMainUser?: boolean;
protected?: boolean;
}[];
}>('/api/v1/settings/plex/users', {
}[]
>(`/api/v1/settings/plex/users`, {
revalidateOnMount: true,
});
useEffect(() => {
if (data) {
const duplicates: {
[key: string]: { type: 'user' | 'profile'; duplicateWith: string[] };
} = {};
const usernameMap = new Map<string, string>();
data.users.forEach((user) => {
usernameMap.set(user.username.toLowerCase(), user.id);
});
data.profiles.forEach((profile) => {
const profileName = (profile.username || profile.title).toLowerCase();
if (usernameMap.has(profileName)) {
const userId = usernameMap.get(profileName);
duplicates[`profile-${profile.id}`] = {
type: 'profile',
duplicateWith: [`user-${userId}`],
};
duplicates[`user-${userId}`] = {
type: 'user',
duplicateWith: [`profile-${profile.id}`],
};
}
});
setDuplicateMap(duplicates);
}
}, [data]);
const importUsers = async () => {
setImporting(true);
try {
const { data: response } = await axios.post(
const { data: createdUsers } = await axios.post(
'/api/v1/user/import-from-plex',
{ plexIds: selectedUsers }
);
if (!Array.isArray(createdUsers) || createdUsers.length === 0) {
throw new Error('No users were imported from Plex.');
}
addToast(
intl.formatMessage(messages.importedfromplex, {
userCount: createdUsers.length,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}),
{
plexIds: selectedUsers,
profileIds: selectedProfiles,
autoDismiss: true,
appearance: 'success',
}
);
if (response.data) {
const importedUsers = response.data.filter(
(item: { isPlexProfile: boolean }) => !item.isPlexProfile
).length;
const importedProfiles = response.data.filter(
(item: { isPlexProfile: boolean }) => item.isPlexProfile
).length;
let successMessage;
if (importedUsers > 0 && importedProfiles > 0) {
successMessage = intl.formatMessage(messages.importSuccessMixed, {
userCount: importedUsers,
profileCount: importedProfiles,
});
} else if (importedUsers > 0) {
successMessage = intl.formatMessage(messages.importSuccessUsers, {
count: importedUsers,
});
} else if (importedProfiles > 0) {
successMessage = intl.formatMessage(messages.importSuccessProfiles, {
count: importedProfiles,
});
} else {
successMessage = intl.formatMessage(messages.importSuccess, {
count: response.data.length,
});
}
let finalMessage = successMessage;
if (response.skipped && response.skipped.length > 0) {
const skippedUsers = response.skipped.filter(
(item: { type: string }) => item.type === 'user'
).length;
const skippedProfiles = response.skipped.filter(
(item: { type: string }) => item.type === 'profile'
).length;
let skippedMessage = '';
if (skippedUsers > 0) {
skippedMessage += intl.formatMessage(
messages.skippedUsersDuplicates,
{
count: skippedUsers,
}
);
}
if (skippedProfiles > 0) {
if (skippedMessage) skippedMessage += ' ';
skippedMessage += intl.formatMessage(
messages.skippedProfilesDuplicates,
{
count: skippedProfiles,
}
);
}
finalMessage += ` ${skippedMessage}`;
}
addToast(finalMessage, {
autoDismiss: true,
appearance: 'success',
});
if (onComplete) {
onComplete();
}
} else {
throw new Error('Invalid response format');
if (onComplete) {
onComplete();
}
} catch (e) {
addToast(intl.formatMessage(messages.importfromplexerror), {
@@ -205,116 +84,24 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
const isSelectedUser = (plexId: string): boolean =>
selectedUsers.includes(plexId);
const isSelectedProfile = (plexId: string): boolean =>
selectedProfiles.includes(plexId);
const isDuplicate = (type: 'user' | 'profile', id: string): boolean => {
const key = `${type}-${id}`;
return !!duplicateMap[key];
};
const isDuplicateWithSelected = (
type: 'user' | 'profile',
id: string
): boolean => {
const key = `${type}-${id}`;
if (!duplicateMap[key]) return false;
return duplicateMap[key].duplicateWith.some((dup) => {
if (dup.startsWith('user-')) {
const userId = dup.replace('user-', '');
return selectedUsers.includes(userId);
} else if (dup.startsWith('profile-')) {
const profileId = dup.replace('profile-', '');
return selectedProfiles.includes(profileId);
}
return false;
});
};
const hasSelectedDuplicate = (
type: 'user' | 'profile',
id: string
): boolean => {
if (type === 'user' && selectedUsers.includes(id)) {
return isDuplicateWithSelected('user', id);
} else if (type === 'profile' && selectedProfiles.includes(id)) {
return isDuplicateWithSelected('profile', id);
}
return false;
};
const isAllUsers = (): boolean =>
data?.users && data.users.length > 0
? selectedUsers.length === data.users.length
: false;
const isAllProfiles = (): boolean =>
data?.profiles && data.profiles.length > 0
? selectedProfiles.length === data.profiles.length
: false;
const isAllUsers = (): boolean => selectedUsers.length === data?.length;
const toggleUser = (plexId: string): void => {
if (selectedUsers.includes(plexId)) {
setSelectedUsers((users: string[]) =>
users.filter((user: string) => user !== plexId)
);
setSelectedUsers((users) => users.filter((user) => user !== plexId));
} else {
const willCreateDuplicate = isDuplicateWithSelected('user', plexId);
if (willCreateDuplicate) {
addToast(intl.formatMessage(messages.duplicateUserWarning), {
autoDismiss: true,
appearance: 'warning',
});
}
setSelectedUsers((users: string[]) => [...users, plexId]);
}
};
const toggleProfile = (plexId: string): void => {
if (selectedProfiles.includes(plexId)) {
setSelectedProfiles((profiles: string[]) =>
profiles.filter((profile: string) => profile !== plexId)
);
} else {
const willCreateDuplicate = isDuplicateWithSelected('profile', plexId);
if (willCreateDuplicate) {
addToast(intl.formatMessage(messages.duplicateProfileWarning), {
autoDismiss: true,
appearance: 'warning',
});
}
setSelectedProfiles((profiles: string[]) => [...profiles, plexId]);
setSelectedUsers((users) => [...users, plexId]);
}
};
const toggleAllUsers = (): void => {
if (data?.users && data.users.length > 0 && !isAllUsers()) {
setSelectedUsers(data.users.map((user) => user.id));
if (data && selectedUsers.length >= 0 && !isAllUsers()) {
setSelectedUsers(data.map((user) => user.id));
} else {
setSelectedUsers([]);
}
};
const toggleAllProfiles = (): void => {
if (data?.profiles && data.profiles.length > 0 && !isAllProfiles()) {
setSelectedProfiles(data.profiles.map((profile) => profile.id));
} else {
setSelectedProfiles([]);
}
};
const hasImportableContent =
(data?.users && data.users.length > 0) ||
(data?.profiles && data.profiles.length > 0);
const hasSelectedContent =
selectedUsers.length > 0 || selectedProfiles.length > 0;
return (
<Modal
loading={!data && !error}
@@ -322,13 +109,13 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
onOk={() => {
importUsers();
}}
okDisabled={isImporting || !hasSelectedContent}
okDisabled={isImporting || !selectedUsers.length}
okText={intl.formatMessage(
isImporting ? globalMessages.importing : globalMessages.import
)}
onCancel={onCancel}
>
{hasImportableContent ? (
{data?.length ? (
<>
{settings.currentSettings.newPlexLogin && (
<Alert
@@ -340,151 +127,57 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
type="info"
/>
)}
{/* Plex Users Section */}
{data?.users && data.users.length > 0 && (
<div className="mb-6 flex flex-col">
<h3 className="mb-2 text-lg font-medium">Plex Users</h3>
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllUsers()}
onClick={() => toggleAllUsers()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleAllUsers();
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isAllUsers()
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data.users.map((user) => (
<tr
key={`user-${user.id}`}
className={
hasSelectedDuplicate('user', user.id)
? 'bg-yellow-800/20'
: ''
}
<div className="flex flex-col">
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllUsers()}
onClick={() => toggleAllUsers()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleAllUsers();
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleUser(user.id);
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="flex items-center text-base font-bold leading-5">
{user.username}
{isDuplicate('user', user.id) && (
<span className="ml-2 rounded-full bg-yellow-600 px-2 py-0.5 text-xs font-normal">
{intl.formatMessage(
messages.possibleDuplicate
)}
</span>
)}
</div>
{user.username &&
user.username.toLowerCase() !==
user.email && (
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{/* Plex Profiles Section */}
{data?.profiles && data.profiles.length > 0 && (
<div className="flex flex-col">
<h3 className="mb-2 text-lg font-medium">Plex Profiles</h3>
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data?.map((user) => (
<tr key={`user-${user.id}`}>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllProfiles()}
onClick={() => toggleAllProfiles()}
aria-checked={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleAllProfiles();
toggleUser(user.id);
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
@@ -492,7 +185,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
<span
aria-hidden="true"
className={`${
isAllProfiles()
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
@@ -500,96 +193,44 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
<span
aria-hidden="true"
className={`${
isAllProfiles()
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.profile)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data.profiles.map((profile) => (
<tr
key={`profile-${profile.id}`}
className={
hasSelectedDuplicate('profile', profile.id)
? 'bg-yellow-800/20'
: ''
}
>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={isSelectedProfile(profile.id)}
onClick={() => toggleProfile(profile.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleProfile(profile.id);
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isSelectedProfile(profile.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedProfile(profile.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
className="h-10 w-10 flex-shrink-0 rounded-full"
src={profile.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="flex items-center text-base font-bold leading-5">
{profile.title || profile.username}
{isDuplicate('profile', profile.id) && (
<span className="ml-2 rounded-full bg-yellow-600 px-2 py-0.5 text-xs font-normal">
{intl.formatMessage(
messages.possibleDuplicate
)}
</span>
)}
</div>
{profile.protected && (
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="text-base font-bold leading-5">
{user.username}
</div>
{user.username &&
user.username.toLowerCase() !==
user.email && (
<div className="text-sm leading-5 text-gray-300">
(PIN protected)
{user.email}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</>
) : (
<Alert

View File

@@ -237,20 +237,7 @@
"components.Layout.VersionStatus.outofdate": "Out of Date",
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.PlexPinEntry.accessDenied": "Access denied.",
"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.",
@@ -261,7 +248,6 @@
"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}",
@@ -269,7 +255,6 @@
"components.Login.orsigninwith": "Or sign in with",
"components.Login.password": "Password",
"components.Login.port": "Port",
"components.Login.profileUserExists": "A profile user already exists for this Plex account. Please contact your administrator to resolve this duplicate.",
"components.Login.save": "Add",
"components.Login.saving": "Adding…",
"components.Login.servertype": "Server Type",
@@ -1296,36 +1281,27 @@
"components.UserList.creating": "Creating…",
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.",
"components.UserList.deleteuser": "Delete User",
"components.UserList.duplicateProfileWarning": "This profile appears to be a duplicate of an existing user or profile.",
"components.UserList.duplicateUserWarning": "This user appears to be a duplicate of an existing user or profile.",
"components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address",
"components.UserList.importSuccess": "{count, plural, one {# item was} other {# items were}} imported successfully.",
"components.UserList.importSuccessMixed": "{userCount, plural, one {# user} other {# users}} and {profileCount, plural, one {# profile} other {# profiles}} were imported successfully.",
"components.UserList.importSuccessProfiles": "{count, plural, one {# profile was} other {# profiles were}} imported successfully.",
"components.UserList.importSuccessUsers": "{count, plural, one {# user was} other {# users were}} imported successfully.",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importfromJellyfin": "Import {mediaServerName} Users",
"components.UserList.importfromJellyfinerror": "Something went wrong while importing {mediaServerName} users.",
"components.UserList.importfrommediaserver": "Import {mediaServerName} Users",
"components.UserList.importfromplex": "Import Plex Users & Profiles",
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users and profiles.",
"components.UserList.importfromplex": "Import Plex Users",
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users.",
"components.UserList.localLoginDisabled": "The <strong>Enable Local Sign-In</strong> setting is currently disabled.",
"components.UserList.localuser": "Local User",
"components.UserList.mediaServerUser": "{mediaServerName} User",
"components.UserList.newJellyfinsigninenabled": "The <strong>Enable New {mediaServerName} Sign-In</strong> setting is currently enabled. {mediaServerName} users with library access do not need to be imported in order to sign in.",
"components.UserList.newplexsigninenabled": "The <strong>Enable New Plex Sign-In</strong> setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.",
"components.UserList.noJellyfinuserstoimport": "There are no {mediaServerName} users to import.",
"components.UserList.nouserstoimport": "There are no Plex users or profiles to import.",
"components.UserList.nouserstoimport": "There are no Plex users to import.",
"components.UserList.owner": "Owner",
"components.UserList.password": "Password",
"components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.",
"components.UserList.plexuser": "Plex User",
"components.UserList.possibleDuplicate": "Possible duplicate",
"components.UserList.profile": "Profile",
"components.UserList.role": "Role",
"components.UserList.skippedProfilesDuplicates": "{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.",
"components.UserList.skippedUsersDuplicates": "{count, plural, one {# user was} other {# users were}} skipped due to duplicates.",
"components.UserList.sortCreated": "Join Date",
"components.UserList.sortDisplayName": "Display Name",
"components.UserList.sortRequests": "Request Count",