From 9cb97db13ced5df2dc595cd9033470b1a0750093 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Fri, 14 Jan 2022 02:32:53 -0800 Subject: [PATCH] feat(plex): selective user import (#2188) * feat(api): allow importing of only selected Plex users * feat(frontend): modal for importing Plex users * feat: add alert if 'Enable New Plex Sign-In' setting is enabled * refactor: fetch all existing Plex users in a single DB query Co-authored-by: Ryan Cohen --- docs/using-overseerr/users/README.md | 4 +- overseerr-api.yml | 43 +++- server/api/plextv.ts | 2 +- server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/settings.ts | 2 + server/routes/settings/index.ts | 54 +++- server/routes/user/index.ts | 3 +- src/components/Settings/SettingsServices.tsx | 4 +- src/components/UserList/PlexImportModal.tsx | 250 +++++++++++++++++++ src/components/UserList/index.tsx | 61 ++--- src/context/SettingsContext.tsx | 1 + src/i18n/globalMessages.ts | 2 + src/i18n/locale/en.json | 11 +- src/pages/_app.tsx | 1 + 14 files changed, 389 insertions(+), 50 deletions(-) create mode 100644 src/components/UserList/PlexImportModal.tsx diff --git a/docs/using-overseerr/users/README.md b/docs/using-overseerr/users/README.md index 275e469c..139e935a 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 e3fc90e3..8ad5afa4 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 9efcecc2..1733a85a 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 336bab0b..2f556635 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 74d13e53..c500157c 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 bad91eac..c9908f4a 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 e6fa09cd..8352726b 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 377fda3e..1ffbd4cf 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 00000000..7e937793 --- /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 a544c8f9..fdf7b4b0 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 = () => {