diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 12b574659..e01ef5a0e 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -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.', }); } } diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 0d5843f85..e3986d4e5 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -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); diff --git a/src/components/UserList/PlexImportModal.tsx b/src/components/UserList/PlexImportModal.tsx index c82f7ebc8..1021ae863 100644 --- a/src/components/UserList/PlexImportModal.tsx +++ b/src/components/UserList/PlexImportModal.tsx @@ -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: - '{userCount} 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 Enable New Plex Sign-In 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([]); - const { data, error } = useSWR< - { + const [selectedProfiles, setSelectedProfiles] = useState([]); + 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(); + + 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( + const { data: response } = await axios.post( '/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) => {msg}, - }), - { - 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 ( { 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 && ( { type="info" /> )} -
-
-
-
- - - - - - - - - {data?.map((user) => ( - - + + ))} + +
- 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" - > - - {intl.formatMessage(messages.user)} -
+ + {/* Plex Users Section */} + {data?.users && data.users.length > 0 && ( +
+

+ {intl.formatMessage(messages.plexUsers)} +

+
+
+
+ + + + + + + + + {data.users.map((user) => ( + + + + + ))} + +
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) => { + {intl.formatMessage(messages.user)} +
+ 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" + > + +
+ +
+
+ {user.username} + {isDuplicate('user', user.id) && ( + + {intl.formatMessage( + messages.possibleDuplicate + )} + + )} +
+ {user.username && + user.username.toLowerCase() !== + user.email && ( +
+ {user.email} +
+ )} +
+
+
+
+
+
+
+ )} + + {/* Plex Profiles Section */} + {data?.profiles && data.profiles.length > 0 && ( +
+

+ {intl.formatMessage(messages.plexProfiles)} +

+
+
+
+ + + + + + + + {data.profiles.map((profile) => ( + + + - - ))} - -
+ 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" + > + -
- -
-
- {user.username} -
- {user.username && - user.username.toLowerCase() !== - user.email && ( + +
+ {intl.formatMessage(messages.profile)} +
+ 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" + > + +
+ +
+
+ {profile.title || profile.username} + {isDuplicate('profile', profile.id) && ( + + {intl.formatMessage( + messages.possibleDuplicate + )} + + )} +
+ {profile.protected && (
- {user.email} + {intl.formatMessage( + messages.pinProtected + )}
)} +
- -
+
+
- + )} ) : ( - - {intl.formatMessage(messages.syncnotice)} - + )}
); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 908518cd7..0f46263bd 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -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": "{userCount} {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!", - "components.UserList.importedfromplex": "{userCount} 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 Enable Local Sign-In setting is currently disabled.", "components.UserList.localuser": "Local User", "components.UserList.mediaServerUser": "{mediaServerName} User", "components.UserList.newJellyfinsigninenabled": "The Enable New {mediaServerName} Sign-In setting is currently enabled. {mediaServerName} users with library access do not need to be imported in order to sign in.", "components.UserList.newplexsigninenabled": "The Enable New Plex Sign-In 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",