Compare commits
28 Commits
develop
...
preview-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57135b39c6 | ||
|
|
c6f98a84d4 | ||
|
|
718c64f973 | ||
|
|
c6ab5c56ad | ||
|
|
268f931844 | ||
|
|
93e379ac68 | ||
|
|
6380c951b4 | ||
|
|
c071e1f1fd | ||
|
|
e79dca33fa | ||
|
|
c2a61862c1 | ||
|
|
eaa3691671 | ||
|
|
0aa3f293bc | ||
|
|
5ed3269bbb | ||
|
|
f3b9b873ed | ||
|
|
6ac0445f8b | ||
|
|
46c871c3cf | ||
|
|
7da109e556 | ||
|
|
1374f30ca9 | ||
|
|
3a58649122 | ||
|
|
2f0a11bafe | ||
|
|
a234d57335 | ||
|
|
7f28834073 | ||
|
|
0a6c2ee9cc | ||
|
|
62b1bfcd89 | ||
|
|
88a9848249 | ||
|
|
a0fa320056 | ||
|
|
acc059c0aa | ||
|
|
f5089502b9 |
@@ -133,6 +133,18 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
example: 5
|
example: 5
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
plexProfileId:
|
||||||
|
type: string
|
||||||
|
example: '12345'
|
||||||
|
readOnly: true
|
||||||
|
isPlexProfile:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
readOnly: true
|
||||||
|
mainPlexUserId:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
readOnly: true
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- email
|
- email
|
||||||
@@ -194,6 +206,27 @@ components:
|
|||||||
trustProxy:
|
trustProxy:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: true
|
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:
|
PlexLibrary:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -3658,17 +3691,17 @@ paths:
|
|||||||
/auth/plex:
|
/auth/plex:
|
||||||
post:
|
post:
|
||||||
summary: Sign in using a Plex token
|
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: []
|
security: []
|
||||||
tags:
|
tags:
|
||||||
- auth
|
- auth
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/User'
|
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -3678,8 +3711,155 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
authToken:
|
authToken:
|
||||||
type: string
|
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:
|
required:
|
||||||
- authToken
|
- 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:
|
/auth/jellyfin:
|
||||||
post:
|
post:
|
||||||
summary: Sign in using a Jellyfin username and password
|
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 cacheManager from '@server/lib/cache';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
import ExternalAPI from './externalapi';
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface PlexAccountResponse {
|
interface PlexAccountResponse {
|
||||||
user: PlexUser;
|
user: PlexUser;
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,37 @@ interface PlexUser {
|
|||||||
};
|
};
|
||||||
entitlements: string[];
|
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 {
|
interface ConnectionResponse {
|
||||||
$: {
|
$: {
|
||||||
@@ -225,6 +256,156 @@ 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> {
|
public async checkUserAccess(userId: number): Promise<boolean> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ export enum ApiErrorCode {
|
|||||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||||
Unauthorized = 'UNAUTHORIZED',
|
Unauthorized = 'UNAUTHORIZED',
|
||||||
Unknown = 'UNKNOWN',
|
Unknown = 'UNKNOWN',
|
||||||
|
InvalidPin = 'INVALID_PIN',
|
||||||
|
NewPlexLoginDisabled = 'NEW_PLEX_LOGIN_DISABLED',
|
||||||
|
ProfileUserExists = 'PROFILE_USER_EXISTS',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ export class User {
|
|||||||
@Column({ type: 'varchar', nullable: true, select: false })
|
@Column({ type: 'varchar', nullable: true, select: false })
|
||||||
public plexToken?: string | null;
|
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 })
|
@Column({ type: 'integer', default: 0 })
|
||||||
public permissions = 0;
|
public permissions = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ import axios from 'axios';
|
|||||||
import * as EmailValidator from 'email-validator';
|
import * as EmailValidator from 'email-validator';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
|
|
||||||
const authRoutes = Router();
|
const authRoutes = Router();
|
||||||
|
|
||||||
authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
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) => {
|
authRoutes.post('/plex', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const userRepository = getRepository(User);
|
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) {
|
if (!body.authToken) {
|
||||||
return next({
|
return next({
|
||||||
@@ -65,12 +69,15 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Plex login is disabled' });
|
return res.status(500).json({ error: 'Plex login is disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 plextv = new PlexTvAPI(body.authToken);
|
||||||
const account = await plextv.getUser();
|
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
|
let user = await userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder('user')
|
||||||
.where('user.plexId = :id', { id: account.id })
|
.where('user.plexId = :id', { id: account.id })
|
||||||
@@ -79,6 +86,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
|
// First user setup - create the admin user
|
||||||
if (!user && !(await userRepository.count())) {
|
if (!user && !(await userRepository.count())) {
|
||||||
user = new User({
|
user = new User({
|
||||||
email: account.email,
|
email: account.email,
|
||||||
@@ -88,6 +96,122 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
permissions: Permission.ADMIN,
|
permissions: Permission.ADMIN,
|
||||||
avatar: account.thumb,
|
avatar: account.thumb,
|
||||||
userType: UserType.PLEX,
|
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 })
|
||||||
|
.orWhere('user.email = :email', {
|
||||||
|
email: account.email.toLowerCase(),
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
plexId: account.id,
|
||||||
|
plexToken: account.authToken,
|
||||||
|
permissions: Permission.ADMIN,
|
||||||
|
avatar: account.thumb,
|
||||||
|
userType: UserType.PLEX,
|
||||||
|
plexProfileId: account.id.toString(),
|
||||||
|
isPlexProfile: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
settings.main.mediaServerType = MediaServerType.PLEX;
|
settings.main.mediaServerType = MediaServerType.PLEX;
|
||||||
@@ -135,13 +259,15 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Update existing user
|
||||||
user.plexToken = body.authToken;
|
user.plexToken = body.authToken;
|
||||||
user.plexId = account.id;
|
user.plexId = account.id;
|
||||||
user.avatar = account.thumb;
|
user.avatar = account.thumb;
|
||||||
user.email = account.email;
|
user.email = account.email;
|
||||||
user.plexUsername = account.username;
|
user.plexUsername = account.username;
|
||||||
user.userType = UserType.PLEX;
|
user.userType = UserType.PLEX;
|
||||||
|
user.plexProfileId = account.id.toString();
|
||||||
|
user.isPlexProfile = false;
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
} else if (!settings.main.newPlexLogin) {
|
} else if (!settings.main.newPlexLogin) {
|
||||||
@@ -157,8 +283,25 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
return next({
|
return next({
|
||||||
status: 403,
|
status: 403,
|
||||||
|
error: ApiErrorCode.NewPlexLoginDisabled,
|
||||||
message: 'Access denied.',
|
message: 'Access denied.',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
user = new User({
|
||||||
|
email: account.email,
|
||||||
|
plexUsername: account.username,
|
||||||
|
plexId: account.id,
|
||||||
|
plexToken: account.authToken,
|
||||||
|
permissions: settings.main.defaultPermissions,
|
||||||
|
avatar: account.thumb,
|
||||||
|
userType: UserType.PLEX,
|
||||||
|
plexProfileId: account.id.toString(),
|
||||||
|
isPlexProfile: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userRepository.save(user);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
|
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
|
||||||
@@ -170,42 +313,64 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
plexUsername: account.username,
|
plexUsername: account.username,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
user = new User({
|
|
||||||
email: account.email,
|
|
||||||
plexUsername: account.username,
|
|
||||||
plexId: account.id,
|
|
||||||
plexToken: account.authToken,
|
|
||||||
permissions: settings.main.defaultPermissions,
|
|
||||||
avatar: account.thumb,
|
|
||||||
userType: UserType.PLEX,
|
|
||||||
});
|
|
||||||
|
|
||||||
await userRepository.save(user);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
'Failed sign-in attempt by Plex user without access to the media server',
|
|
||||||
{
|
|
||||||
label: 'API',
|
|
||||||
ip: req.ip,
|
|
||||||
email: account.email,
|
|
||||||
plexId: account.id,
|
|
||||||
plexUsername: account.username,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return next({
|
return next({
|
||||||
status: 403,
|
status: 403,
|
||||||
|
error: ApiErrorCode.NewPlexLoginDisabled,
|
||||||
message: 'Access denied.',
|
message: 'Access denied.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set logged in session
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (req.session) {
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json(user?.filter() ?? {});
|
return res.status(200).json(user?.filter() ?? {});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong authenticating with Plex account', {
|
logger.error('Something went wrong authenticating with Plex account', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
@@ -219,6 +384,364 @@ 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 {
|
function getUserAvatarUrl(user: User): string {
|
||||||
return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`;
|
return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,13 +471,13 @@ settingsRoutes.get(
|
|||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const qb = userRepository.createQueryBuilder('user');
|
const qb = userRepository.createQueryBuilder('user');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
select: { id: true, plexToken: true },
|
select: { id: true, plexToken: true },
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
});
|
});
|
||||||
const plexApi = new PlexTvAPI(admin.plexToken ?? '');
|
const plexApi = new PlexTvAPI(admin.plexToken ?? '');
|
||||||
|
|
||||||
const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
|
const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
|
||||||
(user) => user.$
|
(user) => user.$
|
||||||
).filter((user) => user.email);
|
).filter((user) => user.email);
|
||||||
@@ -503,7 +503,7 @@ settingsRoutes.get(
|
|||||||
plexUsers.map(async (plexUser) => {
|
plexUsers.map(async (plexUser) => {
|
||||||
if (
|
if (
|
||||||
!existingUsers.find(
|
!existingUsers.find(
|
||||||
(user) =>
|
(user: User) =>
|
||||||
user.plexId === parseInt(plexUser.id) ||
|
user.plexId === parseInt(plexUser.id) ||
|
||||||
user.email === plexUser.email.toLowerCase()
|
user.email === plexUser.email.toLowerCase()
|
||||||
) &&
|
) &&
|
||||||
@@ -513,16 +513,36 @@ settingsRoutes.get(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
const profiles = await plexApi.getProfiles();
|
||||||
|
const existingProfileUsers = await userRepository.find({
|
||||||
|
where: {
|
||||||
|
isPlexProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(200).json(sortBy(unimportedPlexUsers, 'username'));
|
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,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong getting unimported Plex users', {
|
logger.error(
|
||||||
|
'Something went wrong getting unimported Plex users and profiles',
|
||||||
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
next({
|
next({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: 'Unable to retrieve unimported Plex users.',
|
message: 'Unable to retrieve unimported Plex users and profiles.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,27 +528,42 @@ router.post(
|
|||||||
try {
|
try {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const body = req.body as { plexIds: string[] } | undefined;
|
const { plexIds, profileIds } = req.body as {
|
||||||
|
plexIds?: string[];
|
||||||
|
profileIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const skippedItems: {
|
||||||
|
id: string;
|
||||||
|
type: 'user' | 'profile';
|
||||||
|
reason: string;
|
||||||
|
}[] = [];
|
||||||
|
const createdUsers: User[] = [];
|
||||||
|
|
||||||
// taken from auth.ts
|
|
||||||
const mainUser = await userRepository.findOneOrFail({
|
const mainUser = await userRepository.findOneOrFail({
|
||||||
select: { id: true, plexToken: true },
|
select: { id: true, plexToken: true, email: true, plexId: true },
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
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) {
|
for (const rawUser of plexUsersResponse.MediaContainer.User) {
|
||||||
const account = rawUser.$;
|
const account = rawUser.$;
|
||||||
|
|
||||||
if (account.email) {
|
if (account.email && plexIds.includes(account.id)) {
|
||||||
|
// Check for duplicate users more thoroughly
|
||||||
const user = await userRepository
|
const user = await userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder('user')
|
||||||
.where('user.plexId = :id', { id: account.id })
|
.where('user.plexId = :id', { id: account.id })
|
||||||
.orWhere('user.email = :email', {
|
.orWhere('user.email = :email', {
|
||||||
email: account.email.toLowerCase(),
|
email: account.email.toLowerCase(),
|
||||||
})
|
})
|
||||||
|
.orWhere('user.plexUsername = :username', {
|
||||||
|
username: account.username,
|
||||||
|
})
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -562,9 +577,31 @@ router.post(
|
|||||||
user.userType = UserType.PLEX;
|
user.userType = UserType.PLEX;
|
||||||
user.plexId = parseInt(account.id);
|
user.plexId = parseInt(account.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
} else if (!body || body.plexIds.includes(account.id)) {
|
skippedItems.push({
|
||||||
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const newUser = new User({
|
const newUser = new User({
|
||||||
plexUsername: account.username,
|
plexUsername: account.username,
|
||||||
email: account.email,
|
email: account.email,
|
||||||
@@ -574,6 +611,7 @@ router.post(
|
|||||||
avatar: account.thumb,
|
avatar: account.thumb,
|
||||||
userType: UserType.PLEX,
|
userType: UserType.PLEX,
|
||||||
});
|
});
|
||||||
|
|
||||||
await userRepository.save(newUser);
|
await userRepository.save(newUser);
|
||||||
createdUsers.push(newUser);
|
createdUsers.push(newUser);
|
||||||
}
|
}
|
||||||
@@ -581,7 +619,89 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(201).json(User.filterMany(createdUsers));
|
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,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
}
|
}
|
||||||
|
|||||||
195
src/components/Login/PlexPinEntry.tsx
Normal file
195
src/components/Login/PlexPinEntry.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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;
|
||||||
170
src/components/Login/PlexProfileSelector.tsx
Normal file
170
src/components/Login/PlexProfileSelector.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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;
|
||||||
@@ -8,11 +8,15 @@ import LanguagePicker from '@app/components/Layout/LanguagePicker';
|
|||||||
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
||||||
import LocalLogin from '@app/components/Login/LocalLogin';
|
import LocalLogin from '@app/components/Login/LocalLogin';
|
||||||
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
|
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 useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { XCircleIcon } from '@heroicons/react/24/solid';
|
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 { MediaServerType } from '@server/constants/server';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useRouter } from 'next/dist/client/router';
|
import { useRouter } from 'next/dist/client/router';
|
||||||
@@ -29,6 +33,11 @@ const messages = defineMessages('components.Login', {
|
|||||||
signinwithjellyfin: 'Use your {mediaServerName} account',
|
signinwithjellyfin: 'Use your {mediaServerName} account',
|
||||||
signinwithoverseerr: 'Use your {applicationTitle} account',
|
signinwithoverseerr: 'Use your {applicationTitle} account',
|
||||||
orsigninwith: 'Or sign in with',
|
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 = () => {
|
const Login = () => {
|
||||||
@@ -39,36 +48,158 @@ const Login = () => {
|
|||||||
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isProcessing, setProcessing] = useState(false);
|
const [isProcessing, setProcessing] = useState(false);
|
||||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
const [authToken, setAuthToken] = useState<string | undefined>();
|
||||||
const [mediaServerLogin, setMediaServerLogin] = useState(
|
const [mediaServerLogin, setMediaServerLogin] = useState(
|
||||||
settings.currentSettings.mediaServerLogin
|
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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/v1/auth/plex', { authToken });
|
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) {
|
if (response.data?.id) {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.response?.data?.message);
|
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);
|
setAuthToken(undefined);
|
||||||
|
} finally {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
login();
|
login();
|
||||||
}
|
}
|
||||||
}, [authToken, revalidate]);
|
}, [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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
@@ -197,6 +328,38 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<div className="px-10 py-8">
|
<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);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowPinEntry(false);
|
||||||
|
setPinProfileId(null);
|
||||||
|
setPinProfileName(null);
|
||||||
|
setPinProfileThumb(null);
|
||||||
|
setPinIsProtected(false);
|
||||||
|
setPinIsMainUser(false);
|
||||||
|
setPinError(null);
|
||||||
|
setShowProfileSelector(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : showProfileSelector ? (
|
||||||
|
<PlexProfileSelector
|
||||||
|
profiles={profiles}
|
||||||
|
mainUserId={mainUserId || 1}
|
||||||
|
authToken={authToken}
|
||||||
|
onProfileSelected={(profileId, pin, onError) =>
|
||||||
|
handleSubmitProfile(profileId, pin, onError)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<SwitchTransition mode="out-in">
|
<SwitchTransition mode="out-in">
|
||||||
<CSSTransition
|
<CSSTransition
|
||||||
key={mediaServerLogin ? 'ms' : 'local'}
|
key={mediaServerLogin ? 'ms' : 'local'}
|
||||||
@@ -215,9 +378,11 @@ const Login = () => {
|
|||||||
}}
|
}}
|
||||||
classNames={{
|
classNames={{
|
||||||
appear: 'opacity-0',
|
appear: 'opacity-0',
|
||||||
appearActive: 'transition-opacity duration-500 opacity-100',
|
appearActive:
|
||||||
|
'transition-opacity duration-500 opacity-100',
|
||||||
enter: 'opacity-0',
|
enter: 'opacity-0',
|
||||||
enterActive: 'transition-opacity duration-500 opacity-100',
|
enterActive:
|
||||||
|
'transition-opacity duration-500 opacity-100',
|
||||||
exitActive: 'transition-opacity duration-0 opacity-0',
|
exitActive: 'transition-opacity duration-0 opacity-0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -237,8 +402,11 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CSSTransition>
|
</CSSTransition>
|
||||||
</SwitchTransition>
|
</SwitchTransition>
|
||||||
|
)}
|
||||||
|
|
||||||
{additionalLoginOptions.length > 0 &&
|
{!showProfileSelector &&
|
||||||
|
!showPinEntry &&
|
||||||
|
additionalLoginOptions.length > 0 &&
|
||||||
(loginFormVisible ? (
|
(loginFormVisible ? (
|
||||||
<div className="flex items-center py-5">
|
<div className="flex items-center py-5">
|
||||||
<div className="flex-grow border-t border-gray-600"></div>
|
<div className="flex-grow border-t border-gray-600"></div>
|
||||||
@@ -253,6 +421,7 @@ const Login = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{!showProfileSelector && !showPinEntry && (
|
||||||
<div
|
<div
|
||||||
className={`flex w-full flex-wrap gap-2 ${
|
className={`flex w-full flex-wrap gap-2 ${
|
||||||
!loginFormVisible ? 'flex-col' : ''
|
!loginFormVisible ? 'flex-col' : ''
|
||||||
@@ -260,6 +429,7 @@ const Login = () => {
|
|||||||
>
|
>
|
||||||
{additionalLoginOptions}
|
{additionalLoginOptions}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,22 +34,37 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
|||||||
MediaServerType.NOT_CONFIGURED
|
MediaServerType.NOT_CONFIGURED
|
||||||
);
|
);
|
||||||
const { user, revalidate } = useUser();
|
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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
|
if (!authToken) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await axios.post('/api/v1/auth/plex', {
|
const response = await axios.post('/api/v1/auth/plex', {
|
||||||
authToken: authToken,
|
authToken,
|
||||||
|
isSetup: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.email) {
|
if (response.status >= 200 && response.status < 300) {
|
||||||
revalidate();
|
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();
|
login();
|
||||||
}
|
}
|
||||||
}, [authToken, mediaServerType, revalidate]);
|
}, [authToken, mediaServerType, revalidate]);
|
||||||
@@ -58,7 +73,7 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
|||||||
if (user) {
|
if (user) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
}, [user, mediaServerType, onComplete]);
|
}, [user, onComplete]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@@ -74,14 +89,20 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
|||||||
<FormattedMessage {...messages.signinWithPlex} />
|
<FormattedMessage {...messages.signinWithPlex} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded bg-red-600 p-3 text-white">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{serverType === MediaServerType.PLEX && (
|
{serverType === MediaServerType.PLEX && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-center bg-black/30 px-10 py-8">
|
<div className="flex justify-center bg-black/30 px-10 py-8">
|
||||||
<PlexLoginButton
|
<PlexLoginButton
|
||||||
|
isProcessing={isLoading}
|
||||||
large
|
large
|
||||||
onAuthToken={(authToken) => {
|
onAuthToken={(token) => {
|
||||||
setMediaServerType(MediaServerType.PLEX);
|
setMediaServerType(MediaServerType.PLEX);
|
||||||
setAuthToken(authToken);
|
setAuthToken(token);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
@@ -16,14 +16,31 @@ interface PlexImportProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = defineMessages('components.UserList', {
|
const messages = defineMessages('components.UserList', {
|
||||||
importfromplex: 'Import Plex Users',
|
importfromplex: 'Import Plex Users & Profiles',
|
||||||
importfromplexerror: 'Something went wrong while importing Plex users.',
|
importfromplexerror:
|
||||||
importedfromplex:
|
'Something went wrong while importing Plex users and profiles.',
|
||||||
'<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!',
|
|
||||||
user: 'User',
|
user: 'User',
|
||||||
nouserstoimport: 'There are no Plex users to import.',
|
profile: 'Profile',
|
||||||
|
nouserstoimport: 'There are no Plex users or profiles to import.',
|
||||||
newplexsigninenabled:
|
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.',
|
'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) => {
|
const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||||
@@ -32,45 +49,149 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const [isImporting, setImporting] = useState(false);
|
const [isImporting, setImporting] = useState(false);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
const { data, error } = useSWR<
|
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||||
{
|
const [duplicateMap, setDuplicateMap] = useState<{
|
||||||
|
[key: string]: { type: 'user' | 'profile'; duplicateWith: string[] };
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const { data, error } = useSWR<{
|
||||||
|
users: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
}[]
|
}[];
|
||||||
>(`/api/v1/settings/plex/users`, {
|
profiles: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
username?: string;
|
||||||
|
thumb: string;
|
||||||
|
isMainUser?: boolean;
|
||||||
|
protected?: boolean;
|
||||||
|
}[];
|
||||||
|
}>('/api/v1/settings/plex/users', {
|
||||||
revalidateOnMount: true,
|
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 () => {
|
const importUsers = async () => {
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: createdUsers } = await axios.post(
|
const { data: response } = await axios.post(
|
||||||
'/api/v1/user/import-from-plex',
|
'/api/v1/user/import-from-plex',
|
||||||
{ plexIds: selectedUsers }
|
{
|
||||||
|
plexIds: selectedUsers,
|
||||||
|
profileIds: selectedProfiles,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!Array.isArray(createdUsers) || createdUsers.length === 0) {
|
if (response.data) {
|
||||||
throw new Error('No users were imported from Plex.');
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addToast(
|
let finalMessage = successMessage;
|
||||||
intl.formatMessage(messages.importedfromplex, {
|
|
||||||
userCount: createdUsers.length,
|
if (response.skipped && response.skipped.length > 0) {
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
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,
|
autoDismiss: true,
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(intl.formatMessage(messages.importfromplexerror), {
|
addToast(intl.formatMessage(messages.importfromplexerror), {
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
@@ -84,24 +205,116 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
const isSelectedUser = (plexId: string): boolean =>
|
const isSelectedUser = (plexId: string): boolean =>
|
||||||
selectedUsers.includes(plexId);
|
selectedUsers.includes(plexId);
|
||||||
|
|
||||||
const isAllUsers = (): boolean => selectedUsers.length === data?.length;
|
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 toggleUser = (plexId: string): void => {
|
const toggleUser = (plexId: string): void => {
|
||||||
if (selectedUsers.includes(plexId)) {
|
if (selectedUsers.includes(plexId)) {
|
||||||
setSelectedUsers((users) => users.filter((user) => user !== plexId));
|
setSelectedUsers((users: string[]) =>
|
||||||
|
users.filter((user: string) => user !== plexId)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setSelectedUsers((users) => [...users, plexId]);
|
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]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAllUsers = (): void => {
|
const toggleAllUsers = (): void => {
|
||||||
if (data && selectedUsers.length >= 0 && !isAllUsers()) {
|
if (data?.users && data.users.length > 0 && !isAllUsers()) {
|
||||||
setSelectedUsers(data.map((user) => user.id));
|
setSelectedUsers(data.users.map((user) => user.id));
|
||||||
} else {
|
} else {
|
||||||
setSelectedUsers([]);
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
loading={!data && !error}
|
loading={!data && !error}
|
||||||
@@ -109,13 +322,13 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
onOk={() => {
|
onOk={() => {
|
||||||
importUsers();
|
importUsers();
|
||||||
}}
|
}}
|
||||||
okDisabled={isImporting || !selectedUsers.length}
|
okDisabled={isImporting || !hasSelectedContent}
|
||||||
okText={intl.formatMessage(
|
okText={intl.formatMessage(
|
||||||
isImporting ? globalMessages.importing : globalMessages.import
|
isImporting ? globalMessages.importing : globalMessages.import
|
||||||
)}
|
)}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
>
|
>
|
||||||
{data?.length ? (
|
{hasImportableContent ? (
|
||||||
<>
|
<>
|
||||||
{settings.currentSettings.newPlexLogin && (
|
{settings.currentSettings.newPlexLogin && (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -127,7 +340,11 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
type="info"
|
type="info"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
|
||||||
|
{/* 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="-mx-4 sm:mx-0">
|
||||||
<div className="inline-block min-w-full py-2 align-middle">
|
<div className="inline-block min-w-full py-2 align-middle">
|
||||||
<div className="overflow-hidden shadow sm:rounded-lg">
|
<div className="overflow-hidden shadow sm:rounded-lg">
|
||||||
@@ -156,7 +373,9 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
|
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`}
|
} 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>
|
||||||
</span>
|
</span>
|
||||||
@@ -167,8 +386,15 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700 bg-gray-600">
|
<tbody className="divide-y divide-gray-700 bg-gray-600">
|
||||||
{data?.map((user) => (
|
{data.users.map((user) => (
|
||||||
<tr key={`user-${user.id}`}>
|
<tr
|
||||||
|
key={`user-${user.id}`}
|
||||||
|
className={
|
||||||
|
hasSelectedDuplicate('user', user.id)
|
||||||
|
? 'bg-yellow-800/20'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
||||||
<span
|
<span
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
@@ -210,8 +436,15 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
height={40}
|
height={40}
|
||||||
/>
|
/>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<div className="text-base font-bold leading-5">
|
<div className="flex items-center text-base font-bold leading-5">
|
||||||
{user.username}
|
{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>
|
</div>
|
||||||
{user.username &&
|
{user.username &&
|
||||||
user.username.toLowerCase() !==
|
user.username.toLowerCase() !==
|
||||||
@@ -231,6 +464,132 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
||||||
|
role="checkbox"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-checked={isAllProfiles()}
|
||||||
|
onClick={() => toggleAllProfiles()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === 'Space') {
|
||||||
|
toggleAllProfiles();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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={`${
|
||||||
|
isAllProfiles()
|
||||||
|
? '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={`${
|
||||||
|
isAllProfiles()
|
||||||
|
? '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 && (
|
||||||
|
<div className="text-sm leading-5 text-gray-300">
|
||||||
|
(PIN protected)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
|
|||||||
@@ -237,7 +237,20 @@
|
|||||||
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
||||||
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
|
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
|
||||||
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
|
"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.adminerror": "You must use an admin account to sign in.",
|
||||||
|
"components.Login.authFailed": "Authentication failed",
|
||||||
"components.Login.back": "Go back",
|
"components.Login.back": "Go back",
|
||||||
"components.Login.credentialerror": "The username or password is incorrect.",
|
"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.",
|
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
|
||||||
@@ -248,6 +261,7 @@
|
|||||||
"components.Login.hostname": "{mediaServerName} URL",
|
"components.Login.hostname": "{mediaServerName} URL",
|
||||||
"components.Login.initialsignin": "Connect",
|
"components.Login.initialsignin": "Connect",
|
||||||
"components.Login.initialsigningin": "Connecting…",
|
"components.Login.initialsigningin": "Connecting…",
|
||||||
|
"components.Login.invalidPin": "Invalid PIN. Please try again.",
|
||||||
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||||
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
||||||
"components.Login.loginwithapp": "Login with {appName}",
|
"components.Login.loginwithapp": "Login with {appName}",
|
||||||
@@ -255,6 +269,7 @@
|
|||||||
"components.Login.orsigninwith": "Or sign in with",
|
"components.Login.orsigninwith": "Or sign in with",
|
||||||
"components.Login.password": "Password",
|
"components.Login.password": "Password",
|
||||||
"components.Login.port": "Port",
|
"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.save": "Add",
|
||||||
"components.Login.saving": "Adding…",
|
"components.Login.saving": "Adding…",
|
||||||
"components.Login.servertype": "Server Type",
|
"components.Login.servertype": "Server Type",
|
||||||
@@ -1281,27 +1296,36 @@
|
|||||||
"components.UserList.creating": "Creating…",
|
"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.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.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.edituser": "Edit User Permissions",
|
||||||
"components.UserList.email": "Email Address",
|
"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.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.importfromJellyfin": "Import {mediaServerName} Users",
|
||||||
"components.UserList.importfromJellyfinerror": "Something went wrong while importing {mediaServerName} users.",
|
"components.UserList.importfromJellyfinerror": "Something went wrong while importing {mediaServerName} users.",
|
||||||
"components.UserList.importfrommediaserver": "Import {mediaServerName} Users",
|
"components.UserList.importfrommediaserver": "Import {mediaServerName} Users",
|
||||||
"components.UserList.importfromplex": "Import Plex Users",
|
"components.UserList.importfromplex": "Import Plex Users & Profiles",
|
||||||
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users.",
|
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users and profiles.",
|
||||||
"components.UserList.localLoginDisabled": "The <strong>Enable Local Sign-In</strong> setting is currently disabled.",
|
"components.UserList.localLoginDisabled": "The <strong>Enable Local Sign-In</strong> setting is currently disabled.",
|
||||||
"components.UserList.localuser": "Local User",
|
"components.UserList.localuser": "Local User",
|
||||||
"components.UserList.mediaServerUser": "{mediaServerName} 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.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.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.noJellyfinuserstoimport": "There are no {mediaServerName} users to import.",
|
||||||
"components.UserList.nouserstoimport": "There are no Plex users to import.",
|
"components.UserList.nouserstoimport": "There are no Plex users or profiles to import.",
|
||||||
"components.UserList.owner": "Owner",
|
"components.UserList.owner": "Owner",
|
||||||
"components.UserList.password": "Password",
|
"components.UserList.password": "Password",
|
||||||
"components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.",
|
"components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.",
|
||||||
"components.UserList.plexuser": "Plex User",
|
"components.UserList.plexuser": "Plex User",
|
||||||
|
"components.UserList.possibleDuplicate": "Possible duplicate",
|
||||||
|
"components.UserList.profile": "Profile",
|
||||||
"components.UserList.role": "Role",
|
"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.sortCreated": "Join Date",
|
||||||
"components.UserList.sortDisplayName": "Display Name",
|
"components.UserList.sortDisplayName": "Display Name",
|
||||||
"components.UserList.sortRequests": "Request Count",
|
"components.UserList.sortRequests": "Request Count",
|
||||||
|
|||||||
Reference in New Issue
Block a user