feat(users): import Plex Home profiles from the admin UI

Extend Plex user import to include Home profiles with duplicate
detection, and return unimported users and profiles together.
This commit is contained in:
0xsysr3ll
2026-07-27 18:49:35 +02:00
parent 95977b6833
commit c06c36ab9e
4 changed files with 678 additions and 203 deletions

View File

@@ -3,6 +3,7 @@ import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { ApiErrorCode } from '@server/constants/error';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
@@ -475,13 +476,13 @@ settingsRoutes.get(
async (req, res, next) => {
const userRepository = getRepository(User);
const qb = userRepository.createQueryBuilder('user');
try {
const admin = await userRepository.findOneOrFail({
select: { id: true, plexToken: true },
where: { id: 1 },
});
const plexApi = new PlexTvAPI(admin.plexToken ?? '');
const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
(user) => user.$
).filter((user) => user.email);
@@ -510,7 +511,7 @@ settingsRoutes.get(
plexUsers.map(async (plexUser) => {
if (
!existingUsers.find(
(user) =>
(user: User) =>
user.plexId === parseInt(plexUser.id) ||
user.email === plexUser.email.toLowerCase()
) &&
@@ -520,16 +521,36 @@ settingsRoutes.get(
}
})
);
return res.status(200).json(sortBy(unimportedPlexUsers, 'username'));
} catch (e) {
logger.error('Something went wrong getting unimported Plex users', {
label: 'API',
errorMessage: e.message,
const profiles = await plexApi.getProfiles();
const existingProfileUsers = await userRepository.find({
where: {
userType: UserType.PLEX_PROFILE,
},
});
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) {
logger.error(
'Something went wrong getting unimported Plex users and profiles',
{
label: 'API',
errorMessage: e.message,
}
);
next({
status: 500,
message: 'Unable to retrieve unimported Plex users.',
message: 'Unable to retrieve unimported Plex users and profiles.',
});
}
}

View File

@@ -1,4 +1,5 @@
import JellyfinAPI from '@server/api/jellyfin';
import type { PlexProfile } from '@server/api/plextv';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
@@ -661,45 +662,80 @@ router.post(
try {
const settings = getSettings();
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({
select: { id: true, plexToken: true },
select: { id: true, plexToken: true, email: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
const plexUsersResponse = await mainPlexTv.getUsers();
const createdUsers: User[] = [];
let refreshedUsers = 0;
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
if (plexIds && plexIds.length > 0) {
const plexUsersResponse = await mainPlexTv.getUsers();
if (account.email) {
const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
if (user) {
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
if (account.email && plexIds.includes(account.id)) {
// Check for duplicate users more thoroughly
const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.orWhere('user.plexUsername = :username', {
username: account.username,
})
.getOne();
if (user) {
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
skippedItems.push({
id: account.id,
type: 'user',
reason: 'USER_ALREADY_EXISTS',
});
} else if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
// Check for profiles with the same username
const existingProfile = await userRepository.findOne({
where: {
plexUsername: account.username,
userType: UserType.PLEX_PROFILE,
},
});
if (existingProfile) {
skippedItems.push({
id: account.id,
type: 'user',
reason: 'PROFILE_WITH_SAME_NAME_EXISTS',
});
continue;
}
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
refreshedUsers += 1;
} else if (!body || body.plexIds.includes(account.id)) {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
const newUser = new User({
plexUsername: account.username,
email: account.email,
@@ -709,6 +745,7 @@ router.post(
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
@@ -716,9 +753,86 @@ router.post(
}
}
if (profileIds && profileIds.length > 0) {
const profiles = await mainPlexTv.getProfiles();
// Filter out real Plex users (with email/isMainUser) from importable profiles
const importableProfiles = profiles.filter(
(p: PlexProfile) => !p.isMainUser
);
for (const profileId of profileIds) {
const profileData = importableProfiles.find(
(p: PlexProfile) => 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,
userType: UserType.PLEX,
},
{ email: proposedEmail, userType: UserType.PLEX },
],
});
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
skippedItems.push({
id: profileId,
type: 'profile',
reason: 'MAIN_USER_ALREADY_EXISTS',
});
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_PROFILE,
plexProfileId: profileId,
plexProfileNumericId: profileData.numericId || null,
mainPlexUserId: mainUser.id,
});
await userRepository.save(profileUser);
createdUsers.push(profileUser);
}
}
}
return res.status(201).json({
createdUsers: User.filterMany(createdUsers),
refreshedUsers,
data: User.filterMany(createdUsers),
skipped: skippedItems,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -852,7 +966,13 @@ router.get<{ id: string }, UserWatchDataResponse>(
try {
const user = await getRepository(User).findOneOrFail({
where: { id: Number(req.params.id) },
select: { id: true, plexId: true },
select: {
id: true,
plexId: true,
plexProfileId: true,
plexProfileNumericId: true,
userType: true,
},
});
const tautulli = new TautulliAPI(settings);

View File

@@ -2,11 +2,12 @@ import Alert from '@app/components/Common/Alert';
import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import useToasts from '@app/hooks/useToasts';
import { UserType } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import axios from 'axios';
import Image from 'next/image';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
@@ -15,28 +16,46 @@ interface PlexImportProps {
onComplete?: () => void;
}
interface PlexImportSkippedItem {
id: string;
type: 'user' | 'profile';
reason: string;
}
interface PlexImportResponse {
createdUsers: unknown[];
refreshedUsers: number;
data?: { userType: number }[];
skipped?: PlexImportSkippedItem[];
}
const messages = defineMessages('components.UserList', {
importfromplex: 'Import Plex Users',
importfromplexerror: 'Something went wrong while importing Plex users.',
importfromplexnochanges:
'Plex import completed. No users were created or refreshed.',
importfromplexsynced:
'Plex users synced successfully. Existing users were refreshed.',
syncnotice:
'You can still click Sync to refresh existing Plex users, such as updated email or username details.',
importedfromplex:
'<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!',
importedPlexUsersNoPassword:
'Imported users do not have a {applicationTitle} password set. If you disable Plex sign-in, they will need to set a password from their profile or via a password reset link.',
importfromplex: 'Import Plex Users & Profiles',
importfromplexerror:
'Something went wrong while importing Plex users and profiles.',
user: 'User',
nouserstoimport: 'There are no Plex users to import.',
profile: 'Profile',
nouserstoimport: 'There are no Plex users or profiles to import.',
newplexsigninenabled:
'The <strong>Enable New Plex Sign-In</strong> setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.',
possibleDuplicate: 'Possible duplicate',
duplicateUserWarning:
'This user appears to be a duplicate of an existing user or profile.',
duplicateProfileWarning:
'This profile appears to be a duplicate of an existing user or profile.',
importSuccess:
'{count, plural, one {# item was} other {# items were}} imported successfully.',
importSuccessUsers:
'{count, plural, one {# user was} other {# users were}} imported successfully.',
importSuccessProfiles:
'{count, plural, one {# profile was} other {# profiles were}} imported successfully.',
importSuccessMixed:
'{userCount, plural, one {# user} other {# users}} and {profileCount, plural, one {# profile} other {# profiles}} were imported successfully.',
skippedUsersDuplicates:
'{count, plural, one {# user was} other {# users were}} skipped due to duplicates.',
skippedProfilesDuplicates:
'{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.',
plexUsers: 'Plex Users',
plexProfiles: 'Plex Profiles',
pinProtected: '(PIN protected)',
});
const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
@@ -45,76 +64,148 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
const { addToast } = useToasts();
const [isImporting, setImporting] = useState(false);
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;
title: string;
username: string;
email: 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,
});
useEffect(() => {
if (data) {
const duplicates: {
[key: string]: { type: 'user' | 'profile'; duplicateWith: string[] };
} = {};
const usernameMap = new Map<string, string>();
data.users.forEach((user) => {
usernameMap.set(user.username.toLowerCase(), user.id);
});
data.profiles.forEach((profile) => {
const profileName = (profile.username || profile.title).toLowerCase();
if (usernameMap.has(profileName)) {
const userId = usernameMap.get(profileName);
duplicates[`profile-${profile.id}`] = {
type: 'profile',
duplicateWith: [`user-${userId}`],
};
duplicates[`user-${userId}`] = {
type: 'user',
duplicateWith: [`profile-${profile.id}`],
};
}
});
setDuplicateMap(duplicates);
}
}, [data]);
const importUsers = async () => {
setImporting(true);
try {
const { data: importResponse } = await axios.post<PlexImportResponse>(
const { data: response } = await axios.post<PlexImportResponse>(
'/api/v1/user/import-from-plex',
{ plexIds: selectedUsers }
{
plexIds: selectedUsers,
profileIds: selectedProfiles,
}
);
const { createdUsers, refreshedUsers } = importResponse;
if (response.data) {
const importedUsers = response.data.filter(
(item) => item.userType !== UserType.PLEX_PROFILE
).length;
const importedProfiles = response.data.filter(
(item) => item.userType === UserType.PLEX_PROFILE
).length;
if (isSyncOnly) {
if (refreshedUsers > 0) {
addToast(intl.formatMessage(messages.importfromplexsynced), {
autoDismiss: true,
appearance: 'success',
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 {
addToast(intl.formatMessage(messages.importfromplexnochanges), {
autoDismiss: true,
appearance: 'info',
successMessage = intl.formatMessage(messages.importSuccess, {
count: response.data.length,
});
}
} else if (createdUsers.length > 0) {
addToast(
intl.formatMessage(messages.importedfromplex, {
userCount: createdUsers.length,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}),
{
autoDismiss: true,
appearance: 'success',
}
);
addToast(
intl.formatMessage(messages.importedPlexUsersNoPassword, {
applicationTitle: settings.currentSettings.applicationTitle,
}),
{
autoDismiss: false,
appearance: 'warning',
let finalMessage = successMessage;
if (response.skipped && response.skipped.length > 0) {
const skippedUsers = response.skipped.filter(
(item) => item.type === 'user'
).length;
const skippedProfiles = response.skipped.filter(
(item) => item.type === 'profile'
).length;
let skippedMessage = '';
if (skippedUsers > 0) {
skippedMessage += intl.formatMessage(
messages.skippedUsersDuplicates,
{
count: skippedUsers,
}
);
}
);
} else if (refreshedUsers > 0) {
addToast(intl.formatMessage(messages.importfromplexsynced), {
if (skippedProfiles > 0) {
if (skippedMessage) skippedMessage += ' ';
skippedMessage += intl.formatMessage(
messages.skippedProfilesDuplicates,
{
count: skippedProfiles,
}
);
}
finalMessage += ` ${skippedMessage}`;
}
addToast(finalMessage, {
autoDismiss: true,
appearance: 'success',
});
} else {
addToast(intl.formatMessage(messages.importfromplexnochanges), {
autoDismiss: true,
appearance: 'info',
});
}
if (onComplete) {
onComplete();
if (onComplete) {
onComplete();
}
} else {
throw new Error('Invalid response format');
}
} catch {
addToast(intl.formatMessage(messages.importfromplexerror), {
@@ -129,25 +220,115 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
const isSelectedUser = (plexId: string): boolean =>
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 => {
if (selectedUsers.includes(plexId)) {
setSelectedUsers((users) => users.filter((user) => user !== plexId));
setSelectedUsers((users: string[]) =>
users.filter((user: string) => user !== plexId)
);
} 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 => {
if (data && selectedUsers.length >= 0 && !isAllUsers()) {
setSelectedUsers(data.map((user) => user.id));
if (data?.users && data.users.length > 0 && !isAllUsers()) {
setSelectedUsers(data.users.map((user) => user.id));
} else {
setSelectedUsers([]);
}
};
const isSyncOnly = !!data && data.length === 0;
const toggleAllProfiles = (): void => {
if (data?.profiles && data.profiles.length > 0 && !isAllProfiles()) {
setSelectedProfiles(data.profiles.map((profile) => profile.id));
} else {
setSelectedProfiles([]);
}
};
const hasImportableContent =
(data?.users && data.users.length > 0) ||
(data?.profiles && data.profiles.length > 0);
const hasSelectedContent =
selectedUsers.length > 0 || selectedProfiles.length > 0;
return (
<Modal
@@ -156,21 +337,13 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
onOk={() => {
importUsers();
}}
okDisabled={
isImporting || !data || (data.length > 0 && !selectedUsers.length)
}
okDisabled={isImporting || !hasSelectedContent}
okText={intl.formatMessage(
isImporting
? isSyncOnly
? globalMessages.syncing
: globalMessages.importing
: isSyncOnly
? globalMessages.sync
: globalMessages.import
isImporting ? globalMessages.importing : globalMessages.import
)}
onCancel={onCancel}
>
{data?.length ? (
{hasImportableContent ? (
<>
{settings.currentSettings.newPlexLogin && (
<Alert
@@ -182,57 +355,28 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
type="info"
/>
)}
<div className="flex flex-col">
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllUsers()}
onClick={() => toggleAllUsers()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleAllUsers();
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
/>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
/>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data?.map((user) => (
<tr key={`user-${user.id}`}>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
{/* Plex Users Section */}
{data?.users && data.users.length > 0 && (
<div className="mb-6 flex flex-col">
<h3 className="mb-2 text-lg font-medium">
{intl.formatMessage(messages.plexUsers)}
</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={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
aria-checked={isAllUsers()}
onClick={() => toggleAllUsers()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleUser(user.id);
toggleAllUsers();
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
@@ -240,7 +384,134 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
/>
<span
aria-hidden="true"
className={`${
isAllUsers()
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
/>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data.users.map((user) => (
<tr
key={`user-${user.id}`}
className={
hasSelectedDuplicate('user', user.id)
? 'bg-yellow-800/20'
: ''
}
>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleUser(user.id);
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
/>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
/>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="flex items-center text-base font-bold leading-5">
{user.username}
{isDuplicate('user', user.id) && (
<span className="ml-2 rounded-full bg-yellow-600 px-2 py-0.5 text-xs font-normal">
{intl.formatMessage(
messages.possibleDuplicate
)}
</span>
)}
</div>
{user.username &&
user.username.toLowerCase() !==
user.email && (
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{/* Plex Profiles Section */}
{data?.profiles && data.profiles.length > 0 && (
<div className="flex flex-col">
<h3 className="mb-2 text-lg font-medium">
{intl.formatMessage(messages.plexProfiles)}
</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`}
@@ -248,49 +519,104 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
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>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="text-base font-bold leading-5">
{user.username}
</div>
{user.username &&
user.username.toLowerCase() !==
user.email && (
</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
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>
</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">
{user.email}
{intl.formatMessage(
messages.pinProtected
)}
</div>
)}
</div>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
)}
</>
) : (
<Alert title={intl.formatMessage(messages.nouserstoimport)} type="info">
{intl.formatMessage(messages.syncnotice)}
</Alert>
<Alert
title={intl.formatMessage(messages.nouserstoimport)}
type="info"
/>
)}
</Modal>
);

View File

@@ -1420,39 +1420,47 @@
"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.descending": "descending",
"components.UserList.duplicateProfileWarning": "This profile appears to be a duplicate of an existing user or profile.",
"components.UserList.duplicateUserWarning": "This user appears to be a duplicate of an existing user or profile.",
"components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address",
"components.UserList.importedPlexUsersNoPassword": "Imported users do not have a {applicationTitle} password set. If you disable Plex sign-in, they will need to set a password from their profile or via a password reset link.",
"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.importedUsersNoPassword": "Imported users do not have a {applicationTitle} password set. If you disable {mediaServerName} sign-in, they will need to set a password from their profile or via a password reset link.",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importfromJellyfin": "Import {mediaServerName} Users",
"components.UserList.importfromJellyfinerror": "Something went wrong while importing {mediaServerName} users.",
"components.UserList.importfrommediaserver": "Import {mediaServerName} Users",
"components.UserList.importfromplex": "Import Plex Users",
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users.",
"components.UserList.importfromplexnochanges": "Plex import completed. No users were created or refreshed.",
"components.UserList.importfromplexsynced": "Plex users synced successfully. Existing users were refreshed.",
"components.UserList.importfromplex": "Import Plex Users & Profiles",
"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.localuser": "Local User",
"components.UserList.mediaServerUser": "{mediaServerName} User",
"components.UserList.newJellyfinsigninenabled": "The <strong>Enable New {mediaServerName} Sign-In</strong> setting is currently enabled. {mediaServerName} users with library access do not need to be imported in order to sign in.",
"components.UserList.newplexsigninenabled": "The <strong>Enable New Plex Sign-In</strong> setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.",
"components.UserList.noJellyfinuserstoimport": "There are no {mediaServerName} users to import.",
"components.UserList.nouserstoimport": "There are no Plex users to import.",
"components.UserList.nouserstoimport": "There are no Plex users or profiles to import.",
"components.UserList.owner": "Owner",
"components.UserList.password": "Password",
"components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.",
"components.UserList.pinProtected": "(PIN protected)",
"components.UserList.plexProfiles": "Plex Profiles",
"components.UserList.plexUsers": "Plex Users",
"components.UserList.plexprofile": "Plex Profile",
"components.UserList.plexuser": "Plex User",
"components.UserList.possibleDuplicate": "Possible duplicate",
"components.UserList.profile": "Profile",
"components.UserList.role": "Role",
"components.UserList.skippedProfilesDuplicates": "{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.",
"components.UserList.skippedUsersDuplicates": "{count, plural, one {# user was} other {# users were}} skipped due to duplicates.",
"components.UserList.sortBy": "Sort by {field}",
"components.UserList.sortByJoined": "Sort by join date",
"components.UserList.sortByRequests": "Sort by number of requests",
"components.UserList.sortByRole": "Sort by user role",
"components.UserList.sortByType": "Sort by account type",
"components.UserList.sortByUser": "Sort by username",
"components.UserList.syncnotice": "You can still click Sync to refresh existing Plex users, such as updated email or username details.",
"components.UserList.toggleSortDirection": "Click again to sort {direction}",
"components.UserList.toggleSortDirectionAria": "Toggle sort direction",
"components.UserList.totalrequests": "Requests",