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

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

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