diff --git a/docs/using-overseerr/users/README.md b/docs/using-overseerr/users/README.md index 275e469c0..139e935a9 100644 --- a/docs/using-overseerr/users/README.md +++ b/docs/using-overseerr/users/README.md @@ -8,9 +8,9 @@ The user account created during Overseerr setup is the "Owner" account, which ca There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-permissions) defined in **Settings → Users**. -### Importing Users from Plex +### Importing Plex Users -Clicking the **Import Users from Plex** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically. +Clicking the **Import Plex Users** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically. Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-permissions) upon their first login. diff --git a/overseerr-api.yml b/overseerr-api.yml index e3fc90e32..8ad5afa46 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1994,6 +1994,36 @@ paths: type: array items: $ref: '#/components/schemas/PlexDevice' + /settings/plex/users: + get: + summary: Get Plex users + description: | + Returns a list of Plex users in a JSON array. + + Requires the `MANAGE_USERS` permission. + tags: + - settings + - users + responses: + '200': + description: Plex users + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + username: + type: string + email: + type: string + thumb: + type: string /settings/radarr: get: summary: Get Radarr settings @@ -3196,11 +3226,22 @@ paths: post: summary: Import all users from Plex description: | - Requests users from the Plex Server and creates a new user for each of them + Fetches and imports users from the Plex server. If a list of Plex IDs is provided in the request body, only the specified users will be imported. Otherwise, all users will be imported. Requires the `MANAGE_USERS` permission. tags: - users + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + plexIds: + type: array + items: + type: string responses: '201': description: A list of the newly created users diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 9efcecc2b..1733a85a6 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -224,7 +224,7 @@ class PlexTvAPI { const users = friends.MediaContainer.User; - const user = users.find((u) => Number(u.$.id) === userId); + const user = users.find((u) => parseInt(u.$.id) === userId); if (!user) { throw new Error( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 336bab0bd..2f5566352 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -35,6 +35,7 @@ export interface PublicSettingsResponse { enablePushRegistration: boolean; locale: string; emailEnabled: boolean; + newPlexLogin: boolean; } export interface CacheItem { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 74d13e538..c500157cc 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -113,6 +113,7 @@ interface FullPublicSettings extends PublicSettings { enablePushRegistration: boolean; locale: string; emailEnabled: boolean; + newPlexLogin: boolean; } export interface NotificationAgentConfig { @@ -469,6 +470,7 @@ class Settings { enablePushRegistration: this.data.notifications.agents.webpush.enabled, locale: this.data.main.locale, emailEnabled: this.data.notifications.agents.email.enabled, + newPlexLogin: this.data.main.newPlexLogin, }; } diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index bad91eaca..c9908f4a4 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; -import { merge, omit } from 'lodash'; +import { merge, omit, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; import { getRepository } from 'typeorm'; @@ -225,6 +225,58 @@ settingsRoutes.post('/plex/sync', (req, res) => { return res.status(200).json(plexFullScanner.status()); }); +settingsRoutes.get( + '/plex/users', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res) => { + const userRepository = getRepository(User); + const qb = userRepository.createQueryBuilder('user'); + + const admin = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + const plexApi = new PlexTvAPI(admin.plexToken ?? ''); + const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map( + (user) => user.$ + ); + + const unimportedPlexUsers: { + id: string; + title: string; + username: string; + email: string; + thumb: string; + }[] = []; + + const existingUsers = await qb + .where('user.plexId IN (:...plexIds)', { + plexIds: plexUsers.map((plexUser) => plexUser.id), + }) + .orWhere('user.email IN (:...plexEmails)', { + plexEmails: plexUsers.map((plexUser) => plexUser.email.toLowerCase()), + }) + .getMany(); + + await Promise.all( + plexUsers.map(async (plexUser) => { + if ( + !existingUsers.find( + (user) => + user.plexId === parseInt(plexUser.id) || + user.email === plexUser.email.toLowerCase() + ) && + (await plexApi.checkUserAccess(parseInt(plexUser.id))) + ) { + unimportedPlexUsers.push(plexUser); + } + }) + ); + + return res.status(200).json(sortBy(unimportedPlexUsers, 'username')); + } +); + settingsRoutes.get( '/logs', rateLimit({ windowMs: 60 * 1000, max: 50 }), diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index e6fa09cdb..8352726b0 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -400,6 +400,7 @@ router.post( try { const settings = getSettings(); const userRepository = getRepository(User); + const body = req.body as { plexIds: string[] } | undefined; // taken from auth.ts const mainUser = await userRepository.findOneOrFail({ @@ -434,7 +435,7 @@ router.post( user.plexId = parseInt(account.id); } await userRepository.save(user); - } else { + } else if (!body || body.plexIds.includes(account.id)) { if (await mainPlexTv.checkUserAccess(parseInt(account.id))) { const newUser = new User({ plexUsername: account.username, diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 377fda3e9..1ffbd4cfa 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -292,7 +292,7 @@ const SettingsServices: React.FC = () => { serverType: 'Radarr', strong: function strong(msg) { return ( - + {msg} ); @@ -382,7 +382,7 @@ const SettingsServices: React.FC = () => { serverType: 'Sonarr', strong: function strong(msg) { return ( - + {msg} ); diff --git a/src/components/UserList/PlexImportModal.tsx b/src/components/UserList/PlexImportModal.tsx new file mode 100644 index 000000000..7e9377931 --- /dev/null +++ b/src/components/UserList/PlexImportModal.tsx @@ -0,0 +1,250 @@ +import { InboxInIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import useSettings from '../../hooks/useSettings'; +import globalMessages from '../../i18n/globalMessages'; +import Alert from '../Common/Alert'; +import Modal from '../Common/Modal'; + +interface PlexImportProps { + onCancel?: () => void; + onComplete?: () => void; +} + +const messages = defineMessages({ + importfromplex: 'Import Plex Users', + importfromplexerror: 'Something went wrong while importing Plex users.', + importedfromplex: + '{userCount} {userCount, plural, one {user} other {users}} Plex users imported successfully!', + user: 'User', + nouserstoimport: 'There are no Plex users 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.', +}); + +const PlexImportModal: React.FC = ({ + onCancel, + onComplete, +}) => { + const intl = useIntl(); + const settings = useSettings(); + const { addToast } = useToasts(); + const [isImporting, setImporting] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + const { data, error } = useSWR< + { + id: string; + title: string; + username: string; + email: string; + thumb: string; + }[] + >(`/api/v1/settings/plex/users`, { + revalidateOnMount: true, + }); + + const importUsers = async () => { + setImporting(true); + + try { + const { data: createdUsers } = await axios.post( + '/api/v1/user/import-from-plex', + { plexIds: selectedUsers } + ); + + if (!createdUsers.length) { + throw new Error('No users were imported from Plex.'); + } + + addToast( + intl.formatMessage(messages.importedfromplex, { + userCount: createdUsers.length, + strong: function strong(msg) { + return {msg}; + }, + }), + { + autoDismiss: true, + appearance: 'success', + } + ); + + if (onComplete) { + onComplete(); + } + } catch (e) { + addToast(intl.formatMessage(messages.importfromplexerror), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setImporting(false); + } + }; + + const isSelectedUser = (plexId: string): boolean => + selectedUsers.includes(plexId); + + const isAllUsers = (): boolean => selectedUsers.length === data?.length; + + const toggleUser = (plexId: string): void => { + if (selectedUsers.includes(plexId)) { + setSelectedUsers((users) => users.filter((user) => user !== plexId)); + } else { + setSelectedUsers((users) => [...users, plexId]); + } + }; + + const toggleAllUsers = (): void => { + if (data && selectedUsers.length >= 0 && !isAllUsers()) { + setSelectedUsers(data.map((user) => user.id)); + } else { + setSelectedUsers([]); + } + }; + + return ( + } + onOk={() => { + importUsers(); + }} + okDisabled={isImporting || !selectedUsers.length} + okText={intl.formatMessage( + isImporting ? globalMessages.importing : globalMessages.import + )} + onCancel={onCancel} + > + {data?.length ? ( + <> + {settings.currentSettings.newPlexLogin && ( + {msg} + ); + }, + })} + type="info" + /> + )} +
+
+
+
+ + + + + + + + + {data?.map((user) => ( + + + + + ))} + +
+ toggleAllUsers()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleAllUsers(); + } + }} + className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none" + > + + + + + {intl.formatMessage(messages.user)} +
+ toggleUser(user.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleUser(user.id); + } + }} + className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none" + > + + + + +
+ +
+
+ {user.username} +
+ {user.username && + user.username.toLowerCase() !== + user.email && ( +
+ {user.email} +
+ )} +
+
+
+
+
+
+
+ + ) : ( + + )} + + ); +}; + +export default PlexImportModal; diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index a544c8f99..fdf7b4b0a 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -33,15 +33,12 @@ import SensitiveInput from '../Common/SensitiveInput'; import Table from '../Common/Table'; import Transition from '../Transition'; import BulkEditModal from './BulkEditModal'; +import PlexImportModal from './PlexImportModal'; const messages = defineMessages({ users: 'Users', userlist: 'User List', - importfromplex: 'Import Users from Plex', - importfromplexerror: 'Something went wrong while importing users from Plex.', - importedfromplex: - '{userCount, plural, one {# new user} other {# new users}} imported from Plex successfully!', - nouserstoimport: 'No new users to import from Plex.', + importfromplex: 'Import Plex Users', user: 'User', totalrequests: 'Requests', accounttype: 'Type', @@ -103,7 +100,7 @@ const UserList: React.FC = () => { ); const [isDeleting, setDeleting] = useState(false); - const [isImporting, setImporting] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; user?: User; @@ -193,35 +190,6 @@ const UserList: React.FC = () => { } }; - const importFromPlex = async () => { - setImporting(true); - - try { - const { data: createdUsers } = await axios.post( - '/api/v1/user/import-from-plex' - ); - addToast( - createdUsers.length - ? intl.formatMessage(messages.importedfromplex, { - userCount: createdUsers.length, - }) - : intl.formatMessage(messages.nouserstoimport), - { - autoDismiss: true, - appearance: 'success', - } - ); - } catch (e) { - addToast(intl.formatMessage(messages.importfromplexerror), { - autoDismiss: true, - appearance: 'error', - }); - } finally { - revalidate(); - setImporting(false); - } - }; - if (!data && !error) { return ; } @@ -354,7 +322,7 @@ const UserList: React.FC = () => { title={intl.formatMessage(messages.localLoginDisabled, { strong: function strong(msg) { return ( - + {msg} ); @@ -481,6 +449,24 @@ const UserList: React.FC = () => { /> + + setShowImportModal(false)} + onComplete={() => { + setShowImportModal(false); + revalidate(); + }} + /> + +
{intl.formatMessage(messages.userlist)}
@@ -496,8 +482,7 @@ const UserList: React.FC = () => {