feat: add support for importing Plex profiles

- Extend API endpoint to fetch and import Plex profiles
- Implement duplicate detection between users and profiles
- Add UI to display and select Plex profiles for import
- Improve error handling for duplicate records
- Enhance success/error messages with profile-specific text
This commit is contained in:
0xsysr3ll
2025-04-24 22:16:13 +02:00
parent 6ac0445f8b
commit f3b9b873ed
5 changed files with 639 additions and 157 deletions

View File

@@ -471,13 +471,13 @@ settingsRoutes.get(
async (req, res, next) => {
const userRepository = getRepository(User);
const qb = userRepository.createQueryBuilder('user');
try {
const admin = await userRepository.findOneOrFail({
select: { id: true, plexToken: true },
where: { id: 1 },
});
const plexApi = new PlexTvAPI(admin.plexToken ?? '');
const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
(user) => user.$
).filter((user) => user.email);
@@ -503,7 +503,7 @@ settingsRoutes.get(
plexUsers.map(async (plexUser) => {
if (
!existingUsers.find(
(user) =>
(user: User) =>
user.plexId === parseInt(plexUser.id) ||
user.email === plexUser.email.toLowerCase()
) &&
@@ -513,16 +513,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: {
isPlexProfile: true,
},
});
const unimportedProfiles = profiles.filter(
(profile) =>
!profile.isMainUser &&
!existingProfileUsers.some(
(user: User) => user.plexProfileId === profile.id
)
);
return res.status(200).json({
users: sortBy(unimportedPlexUsers, 'username'),
profiles: unimportedProfiles,
});
} 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

@@ -528,43 +528,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[] = [];
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,
isPlexProfile: true,
},
});
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);
} 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,
@@ -574,6 +611,7 @@ router.post(
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
@@ -581,7 +619,64 @@ router.post(
}
}
return res.status(201).json(User.filterMany(createdUsers));
if (profileIds && profileIds.length > 0) {
const profiles = await mainPlexTv.getProfiles();
for (const profileId of profileIds) {
const profileData = profiles.find((p: any) => p.id === profileId);
if (profileData) {
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, '');
// Check for existing user with same username or email
const existingUser = await userRepository.findOne({
where: [
{ plexUsername: profileData.username || profileData.title },
{ email: `${emailPrefix}+${safeUsername}@${domainPart}` },
{ plexProfileId: profileId },
],
});
if (existingUser) {
// Skip this profile and add to skipped list
skippedItems.push({
id: profileId,
type: 'profile',
reason: 'DUPLICATE_USER_EXISTS',
});
continue;
}
const profileUser = new User({
email: `${emailPrefix}+${safeUsername}@${domainPart}`,
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) {
next({ status: 500, message: e.message });
}

View File

@@ -132,7 +132,7 @@ const Login = () => {
if (response.data?.status === 'REQUIRES_PIN') {
setShowPinEntry(true);
setPinError(intl.formatMessage(messages.invalidPin));
return;
throw new Error('Invalid PIN');
} else {
setShowProfileSelector(false);
setShowPinEntry(false);
@@ -301,7 +301,9 @@ const Login = () => {
<PlexPinEntry
profileId={pinProfileId}
profileName={pinProfileName}
onSubmit={handleSubmitProfile}
onSubmit={(pin) => {
return handleSubmitProfile(pinProfileId, pin);
}}
onCancel={() => {
setShowPinEntry(false);
setPinProfileId(null);

View File

@@ -3,9 +3,11 @@ import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import type PlexUser from '@server/api/plexapi';
import type { PlexProfile } from '@server/api/plextv';
import axios from 'axios';
import Image from 'next/image';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
@@ -15,15 +17,37 @@ interface PlexImportProps {
onComplete?: () => void;
}
interface PlexImportData {
users: PlexUser[];
profiles: PlexProfile[];
}
const messages = defineMessages('components.UserList', {
importfromplex: 'Import Plex Users',
importfromplexerror: 'Something went wrong while importing Plex users.',
importedfromplex:
'<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!',
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.',
});
const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
@@ -32,44 +56,135 @@ 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<PlexImportData>(
'/api/v1/settings/plex/users',
{
id: string;
title: string;
username: string;
email: string;
thumb: string;
}[]
>(`/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: PlexUser) => {
usernameMap.set(user.username.toLowerCase(), user.id);
});
data.profiles.forEach((profile: PlexProfile) => {
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: createdUsers } = await axios.post(
const { data: response } = await axios.post(
'/api/v1/user/import-from-plex',
{ plexIds: selectedUsers }
);
if (!Array.isArray(createdUsers) || createdUsers.length === 0) {
throw new Error('No users were imported from Plex.');
}
addToast(
intl.formatMessage(messages.importedfromplex, {
userCount: createdUsers.length,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}),
{
autoDismiss: true,
appearance: 'success',
plexIds: selectedUsers,
profileIds: selectedProfiles,
}
);
if (onComplete) {
onComplete();
if (response.data) {
const importedUsers = response.data.filter(
(item) => !item.isPlexProfile
).length;
const importedProfiles = response.data.filter(
(item) => item.isPlexProfile
).length;
let successMessage;
if (importedUsers > 0 && importedProfiles > 0) {
successMessage = intl.formatMessage(messages.importSuccessMixed, {
userCount: importedUsers,
profileCount: importedProfiles,
});
} else if (importedUsers > 0) {
successMessage = intl.formatMessage(messages.importSuccessUsers, {
count: importedUsers,
});
} else if (importedProfiles > 0) {
successMessage = intl.formatMessage(messages.importSuccessProfiles, {
count: importedProfiles,
});
} else {
successMessage = intl.formatMessage(messages.importSuccess, {
count: response.data.length,
});
}
let finalMessage = successMessage;
if (response.skipped && response.skipped.length > 0) {
const skippedUsers = response.skipped.filter(
(item) => 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,
}
);
}
if (skippedProfiles > 0) {
if (skippedMessage) skippedMessage += ' ';
skippedMessage += intl.formatMessage(
messages.skippedProfilesDuplicates,
{
count: skippedProfiles,
}
);
}
finalMessage += ` ${skippedMessage}`;
}
addToast(finalMessage, {
autoDismiss: true,
appearance: 'success',
});
if (onComplete) {
onComplete();
}
} else {
throw new Error('Invalid response format');
}
} catch (e) {
addToast(intl.formatMessage(messages.importfromplexerror), {
@@ -84,24 +199,118 @@ 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: PlexUser) => user.id));
} else {
setSelectedUsers([]);
}
};
const toggleAllProfiles = (): void => {
if (data?.profiles && data.profiles.length > 0 && !isAllProfiles()) {
setSelectedProfiles(
data.profiles.map((profile: PlexProfile) => profile.id)
);
} else {
setSelectedProfiles([]);
}
};
const hasImportableContent =
(data?.users && data.users.length > 0) ||
(data?.profiles && data.profiles.length > 0);
const hasSelectedContent =
selectedUsers.length > 0 || selectedProfiles.length > 0;
return (
<Modal
loading={!data && !error}
@@ -109,13 +318,13 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
onOk={() => {
importUsers();
}}
okDisabled={isImporting || !selectedUsers.length}
okDisabled={isImporting || !hasSelectedContent}
okText={intl.formatMessage(
isImporting ? globalMessages.importing : globalMessages.import
)}
onCancel={onCancel}
>
{data?.length ? (
{hasImportableContent ? (
<>
{settings.currentSettings.newPlexLogin && (
<Alert
@@ -127,57 +336,26 @@ 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>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data?.map((user) => (
<tr key={`user-${user.id}`}>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
{/* Plex Users Section */}
{data?.users && data.users.length > 0 && (
<div className="mb-6 flex flex-col">
<h3 className="mb-2 text-lg font-medium">Plex Users</h3>
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
aria-checked={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"
@@ -185,7 +363,132 @@ 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>
<span
aria-hidden="true"
className={`${
isAllUsers()
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data.users.map((user) => (
<tr
key={`user-${user.id}`}
className={
hasSelectedDuplicate('user', user.id)
? 'bg-yellow-800/20'
: ''
}
>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleUser(user.id);
}
}}
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<Image
className="h-10 w-10 flex-shrink-0 rounded-full"
src={user.thumb}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="flex items-center text-base font-bold leading-5">
{user.username}
{isDuplicate('user', user.id) && (
<span className="ml-2 rounded-full bg-yellow-600 px-2 py-0.5 text-xs font-normal">
{intl.formatMessage(
messages.possibleDuplicate
)}
</span>
)}
</div>
{user.username &&
user.username.toLowerCase() !==
user.email && (
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{/* Plex Profiles Section */}
{data?.profiles && data.profiles.length > 0 && (
<div className="flex flex-col">
<h3 className="mb-2 text-lg font-medium">Plex Profiles</h3>
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
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`}
@@ -193,44 +496,96 @@ 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>
</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>
<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">
{user.email}
(PIN protected)
</div>
)}
</div>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
)}
</>
) : (
<Alert

View File

@@ -237,6 +237,7 @@
"components.Layout.VersionStatus.outofdate": "Out of Date",
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.PlexPinEntry.accessDenied": "Access denied.",
"components.Login.PlexPinEntry.cancel": "Cancel",
"components.Login.PlexPinEntry.invalidPin": "Invalid PIN. Please try again.",
"components.Login.PlexPinEntry.pinCheck": "Checking PIN...",
@@ -1294,27 +1295,36 @@
"components.UserList.creating": "Creating…",
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.",
"components.UserList.deleteuser": "Delete User",
"components.UserList.duplicateProfileWarning": "This profile appears to be a duplicate of an existing user or profile.",
"components.UserList.duplicateUserWarning": "This user appears to be a duplicate of an existing user or profile.",
"components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address",
"components.UserList.importSuccess": "{count, plural, one {# item was} other {# items were}} imported successfully.",
"components.UserList.importSuccessMixed": "{userCount, plural, one {# user} other {# users}} and {profileCount, plural, one {# profile} other {# profiles}} were imported successfully.",
"components.UserList.importSuccessProfiles": "{count, plural, one {# profile was} other {# profiles were}} imported successfully.",
"components.UserList.importSuccessUsers": "{count, plural, one {# user was} other {# users were}} imported successfully.",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importfromJellyfin": "Import {mediaServerName} Users",
"components.UserList.importfromJellyfinerror": "Something went wrong while importing {mediaServerName} users.",
"components.UserList.importfrommediaserver": "Import {mediaServerName} Users",
"components.UserList.importfromplex": "Import Plex Users",
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users.",
"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.plexuser": "Plex User",
"components.UserList.possibleDuplicate": "Possible duplicate",
"components.UserList.profile": "Profile",
"components.UserList.role": "Role",
"components.UserList.skippedProfilesDuplicates": "{count, plural, one {# profile was} other {# profiles were}} skipped due to duplicates.",
"components.UserList.skippedUsersDuplicates": "{count, plural, one {# user was} other {# users were}} skipped due to duplicates.",
"components.UserList.sortCreated": "Join Date",
"components.UserList.sortDisplayName": "Display Name",
"components.UserList.sortRequests": "Request Count",