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 <ryan@sct.dev>
This commit is contained in:
TheCatLady
2022-01-14 02:32:53 -08:00
committed by GitHub
parent 256163971f
commit 9cb97db13c
14 changed files with 389 additions and 50 deletions

View File

@@ -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 &rarr; Users**. 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 &rarr; 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. 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.

View File

@@ -1994,6 +1994,36 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/PlexDevice' $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: /settings/radarr:
get: get:
summary: Get Radarr settings summary: Get Radarr settings
@@ -3196,11 +3226,22 @@ paths:
post: post:
summary: Import all users from Plex summary: Import all users from Plex
description: | 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. Requires the `MANAGE_USERS` permission.
tags: tags:
- users - users
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
plexIds:
type: array
items:
type: string
responses: responses:
'201': '201':
description: A list of the newly created users description: A list of the newly created users

View File

@@ -224,7 +224,7 @@ class PlexTvAPI {
const users = friends.MediaContainer.User; 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) { if (!user) {
throw new Error( throw new Error(

View File

@@ -35,6 +35,7 @@ export interface PublicSettingsResponse {
enablePushRegistration: boolean; enablePushRegistration: boolean;
locale: string; locale: string;
emailEnabled: boolean; emailEnabled: boolean;
newPlexLogin: boolean;
} }
export interface CacheItem { export interface CacheItem {

View File

@@ -113,6 +113,7 @@ interface FullPublicSettings extends PublicSettings {
enablePushRegistration: boolean; enablePushRegistration: boolean;
locale: string; locale: string;
emailEnabled: boolean; emailEnabled: boolean;
newPlexLogin: boolean;
} }
export interface NotificationAgentConfig { export interface NotificationAgentConfig {
@@ -469,6 +470,7 @@ class Settings {
enablePushRegistration: this.data.notifications.agents.webpush.enabled, enablePushRegistration: this.data.notifications.agents.webpush.enabled,
locale: this.data.main.locale, locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled, emailEnabled: this.data.notifications.agents.email.enabled,
newPlexLogin: this.data.main.newPlexLogin,
}; };
} }

View File

@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import fs from 'fs'; import fs from 'fs';
import { merge, omit } from 'lodash'; import { merge, omit, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule'; import { rescheduleJob } from 'node-schedule';
import path from 'path'; import path from 'path';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
@@ -225,6 +225,58 @@ settingsRoutes.post('/plex/sync', (req, res) => {
return res.status(200).json(plexFullScanner.status()); 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( settingsRoutes.get(
'/logs', '/logs',
rateLimit({ windowMs: 60 * 1000, max: 50 }), rateLimit({ windowMs: 60 * 1000, max: 50 }),

View File

@@ -400,6 +400,7 @@ router.post(
try { try {
const settings = getSettings(); const settings = getSettings();
const userRepository = getRepository(User); const userRepository = getRepository(User);
const body = req.body as { plexIds: string[] } | undefined;
// taken from auth.ts // taken from auth.ts
const mainUser = await userRepository.findOneOrFail({ const mainUser = await userRepository.findOneOrFail({
@@ -434,7 +435,7 @@ router.post(
user.plexId = parseInt(account.id); user.plexId = parseInt(account.id);
} }
await userRepository.save(user); await userRepository.save(user);
} else { } else if (!body || body.plexIds.includes(account.id)) {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) { if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
const newUser = new User({ const newUser = new User({
plexUsername: account.username, plexUsername: account.username,

View File

@@ -292,7 +292,7 @@ const SettingsServices: React.FC = () => {
serverType: 'Radarr', serverType: 'Radarr',
strong: function strong(msg) { strong: function strong(msg) {
return ( return (
<strong className="font-semibold text-yellow-100"> <strong className="font-semibold text-white">
{msg} {msg}
</strong> </strong>
); );
@@ -382,7 +382,7 @@ const SettingsServices: React.FC = () => {
serverType: 'Sonarr', serverType: 'Sonarr',
strong: function strong(msg) { strong: function strong(msg) {
return ( return (
<strong className="font-semibold text-yellow-100"> <strong className="font-semibold text-white">
{msg} {msg}
</strong> </strong>
); );

View File

@@ -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:
'<strong>{userCount}</strong> {userCount, plural, one {user} other {users}} Plex users imported successfully!',
user: 'User',
nouserstoimport: 'There are no Plex users 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.',
});
const PlexImportModal: React.FC<PlexImportProps> = ({
onCancel,
onComplete,
}) => {
const intl = useIntl();
const settings = useSettings();
const { addToast } = useToasts();
const [isImporting, setImporting] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
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 <strong>{msg}</strong>;
},
}),
{
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 (
<Modal
loading={!data && !error}
title={intl.formatMessage(messages.importfromplex)}
iconSvg={<InboxInIcon />}
onOk={() => {
importUsers();
}}
okDisabled={isImporting || !selectedUsers.length}
okText={intl.formatMessage(
isImporting ? globalMessages.importing : globalMessages.import
)}
onCancel={onCancel}
>
{data?.length ? (
<>
{settings.currentSettings.newPlexLogin && (
<Alert
title={intl.formatMessage(messages.newplexsigninenabled, {
strong: function strong(msg) {
return (
<strong className="font-semibold text-white">{msg}</strong>
);
},
})}
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 px-4 py-3 bg-gray-500">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllUsers()}
onClick={() => 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"
>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`}
></span>
<span
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 border border-gray-200 rounded-full bg-white shadow transform group-focus:ring group-focus:border-blue-300 transition-transform ease-in-out duration-200`}
></span>
</span>
</th>
<th className="px-1 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
{intl.formatMessage(messages.user)}
</th>
</tr>
</thead>
<tbody className="bg-gray-600 divide-y divide-gray-700">
{data?.map((user) => (
<tr key={`user-${user.id}`}>
<td className="px-4 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
<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 items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none"
>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 border border-gray-200 rounded-full bg-white shadow transform group-focus:ring group-focus:border-blue-300 transition-transform ease-in-out duration-200`}
></span>
</span>
</td>
<td className="px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 whitespace-nowrap">
<div className="flex items-center">
<img
className="flex-shrink-0 w-10 h-10 rounded-full"
src={user.thumb}
alt=""
/>
<div className="ml-4">
<div className="text-base font-bold leading-5">
{user.username}
</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>
</>
) : (
<Alert
title={intl.formatMessage(messages.nouserstoimport)}
type="info"
/>
)}
</Modal>
);
};
export default PlexImportModal;

View File

@@ -33,15 +33,12 @@ import SensitiveInput from '../Common/SensitiveInput';
import Table from '../Common/Table'; import Table from '../Common/Table';
import Transition from '../Transition'; import Transition from '../Transition';
import BulkEditModal from './BulkEditModal'; import BulkEditModal from './BulkEditModal';
import PlexImportModal from './PlexImportModal';
const messages = defineMessages({ const messages = defineMessages({
users: 'Users', users: 'Users',
userlist: 'User List', userlist: 'User List',
importfromplex: 'Import Users from Plex', importfromplex: 'Import Plex Users',
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.',
user: 'User', user: 'User',
totalrequests: 'Requests', totalrequests: 'Requests',
accounttype: 'Type', accounttype: 'Type',
@@ -103,7 +100,7 @@ const UserList: React.FC = () => {
); );
const [isDeleting, setDeleting] = useState(false); const [isDeleting, setDeleting] = useState(false);
const [isImporting, setImporting] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [deleteModal, setDeleteModal] = useState<{ const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean; isOpen: boolean;
user?: User; 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) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
@@ -354,7 +322,7 @@ const UserList: React.FC = () => {
title={intl.formatMessage(messages.localLoginDisabled, { title={intl.formatMessage(messages.localLoginDisabled, {
strong: function strong(msg) { strong: function strong(msg) {
return ( return (
<strong className="font-semibold text-yellow-100"> <strong className="font-semibold text-white">
{msg} {msg}
</strong> </strong>
); );
@@ -481,6 +449,24 @@ const UserList: React.FC = () => {
/> />
</Transition> </Transition>
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showImportModal}
>
<PlexImportModal
onCancel={() => setShowImportModal(false)}
onComplete={() => {
setShowImportModal(false);
revalidate();
}}
/>
</Transition>
<div className="flex flex-col justify-between lg:items-end lg:flex-row"> <div className="flex flex-col justify-between lg:items-end lg:flex-row">
<Header>{intl.formatMessage(messages.userlist)}</Header> <Header>{intl.formatMessage(messages.userlist)}</Header>
<div className="flex flex-col flex-grow mt-2 lg:flex-row lg:flex-grow-0"> <div className="flex flex-col flex-grow mt-2 lg:flex-row lg:flex-grow-0">
@@ -496,8 +482,7 @@ const UserList: React.FC = () => {
<Button <Button
className="flex-grow outline lg:mr-2" className="flex-grow outline lg:mr-2"
buttonType="primary" buttonType="primary"
disabled={isImporting} onClick={() => setShowImportModal(true)}
onClick={() => importFromPlex()}
> >
<InboxInIcon /> <InboxInIcon />
<span>{intl.formatMessage(messages.importfromplex)}</span> <span>{intl.formatMessage(messages.importfromplex)}</span>

View File

@@ -22,6 +22,7 @@ const defaultSettings = {
enablePushRegistration: false, enablePushRegistration: false,
locale: 'en', locale: 'en',
emailEnabled: false, emailEnabled: false,
newPlexLogin: true,
}; };
export const SettingsContext = React.createContext<SettingsContextProps>({ export const SettingsContext = React.createContext<SettingsContextProps>({

View File

@@ -31,6 +31,8 @@ const globalMessages = defineMessages({
testing: 'Testing…', testing: 'Testing…',
save: 'Save Changes', save: 'Save Changes',
saving: 'Saving…', saving: 'Saving…',
import: 'Import',
importing: 'Importing…',
close: 'Close', close: 'Close',
edit: 'Edit', edit: 'Edit',
areyousure: 'Are you sure?', areyousure: 'Are you sure?',

View File

@@ -835,12 +835,13 @@
"components.UserList.displayName": "Display Name", "components.UserList.displayName": "Display Name",
"components.UserList.edituser": "Edit User Permissions", "components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address", "components.UserList.email": "Email Address",
"components.UserList.importedfromplex": "{userCount, plural, one {# new user} other {# new users}} imported from Plex successfully!", "components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {user} other {users}} Plex users imported successfully!",
"components.UserList.importfromplex": "Import Users from Plex", "components.UserList.importfromplex": "Import Plex Users",
"components.UserList.importfromplexerror": "Something went wrong while importing users from Plex.", "components.UserList.importfromplexerror": "Something went wrong while importing Plex users.",
"components.UserList.localLoginDisabled": "The <strong>Enable Local Sign-In</strong> setting is currently disabled.", "components.UserList.localLoginDisabled": "The <strong>Enable Local Sign-In</strong> setting is currently disabled.",
"components.UserList.localuser": "Local User", "components.UserList.localuser": "Local User",
"components.UserList.nouserstoimport": "No new users to import from Plex.", "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.nouserstoimport": "There are no Plex users to import.",
"components.UserList.owner": "Owner", "components.UserList.owner": "Owner",
"components.UserList.password": "Password", "components.UserList.password": "Password",
"components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.", "components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.",
@@ -974,6 +975,8 @@
"i18n.edit": "Edit", "i18n.edit": "Edit",
"i18n.experimental": "Experimental", "i18n.experimental": "Experimental",
"i18n.failed": "Failed", "i18n.failed": "Failed",
"i18n.import": "Import",
"i18n.importing": "Importing…",
"i18n.loading": "Loading…", "i18n.loading": "Loading…",
"i18n.movie": "Movie", "i18n.movie": "Movie",
"i18n.movies": "Movies", "i18n.movies": "Movies",

View File

@@ -169,6 +169,7 @@ CoreApp.getInitialProps = async (initialProps) => {
enablePushRegistration: false, enablePushRegistration: false,
locale: 'en', locale: 'en',
emailEnabled: false, emailEnabled: false,
newPlexLogin: true,
}; };
if (ctx.res) { if (ctx.res) {