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>
pull/2421/head
TheCatLady 2 years ago committed by GitHub
parent 256163971f
commit 9cb97db13c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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**.
### 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.

@ -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

@ -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(

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

@ -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,
};
}

@ -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 }),

@ -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,

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

@ -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;

@ -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 <LoadingSpinner />;
}
@ -354,7 +322,7 @@ const UserList: React.FC = () => {
title={intl.formatMessage(messages.localLoginDisabled, {
strong: function strong(msg) {
return (
<strong className="font-semibold text-yellow-100">
<strong className="font-semibold text-white">
{msg}
</strong>
);
@ -481,6 +449,24 @@ const UserList: React.FC = () => {
/>
</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">
<Header>{intl.formatMessage(messages.userlist)}</Header>
<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
className="flex-grow outline lg:mr-2"
buttonType="primary"
disabled={isImporting}
onClick={() => importFromPlex()}
onClick={() => setShowImportModal(true)}
>
<InboxInIcon />
<span>{intl.formatMessage(messages.importfromplex)}</span>

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

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

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

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

Loading…
Cancel
Save