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:
@@ -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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user