diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index 37c807e8..9e096e33 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { hasPermission } from '../../../server/lib/permissions'; import { Permission, User } from '../../hooks/useUser'; +import useSettings from '../../hooks/useSettings'; export interface PermissionItem { id: string; @@ -33,6 +34,8 @@ const PermissionOption: React.FC = ({ onUpdate, parent, }) => { + const settings = useSettings(); + const autoApprovePermissions = [ Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_MOVIE, @@ -42,34 +45,70 @@ const PermissionOption: React.FC = ({ Permission.AUTO_APPROVE_4K_TV, ]; + let disabled = false; + let checked = hasPermission(option.permission, currentPermission); + + if ( + // Permissions for user ID 1 (Plex server owner) cannot be changed + (currentUser && currentUser.id === 1) || + // Admin permission automatically bypasses/grants all other permissions + (option.permission !== Permission.ADMIN && + hasPermission(Permission.ADMIN, currentPermission)) || + // Manage Requests permission automatically grants all Auto-Approve permissions + (autoApprovePermissions.includes(option.permission) && + hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) || + // Selecting a parent permission automatically selects all children + (!!parent?.permission && + hasPermission(parent.permission, currentPermission)) + ) { + disabled = true; + checked = true; + } + + if ( + // Non-Admin users cannot modify the Admin permission + (actingUser && + !hasPermission(Permission.ADMIN, actingUser.permissions) && + option.permission === Permission.ADMIN) || + // Users without the Manage Settings permission cannot modify/grant that permission + (actingUser && + !hasPermission(Permission.MANAGE_SETTINGS, actingUser.permissions) && + option.permission === Permission.MANAGE_SETTINGS) + ) { + disabled = true; + } + + if ( + // Some permissions are dependent on others; check requirements are fulfilled + (option.requires && + !option.requires.every((requirement) => + hasPermission(requirement.permissions, currentPermission, { + type: requirement.type ?? 'and', + }) + )) || + // Request 4K and Auto-Approve 4K require both 4K movie & 4K series requests to be enabled + ((option.permission === Permission.REQUEST_4K || + option.permission === Permission.AUTO_APPROVE_4K) && + (!settings.currentSettings.movie4kEnabled || + !settings.currentSettings.series4kEnabled)) || + // Request 4K Movie and Auto-Approve 4K Movie require 4K movie requests to be enabled + ((option.permission === Permission.REQUEST_4K_MOVIE || + option.permission === Permission.AUTO_APPROVE_4K_MOVIE) && + !settings.currentSettings.movie4kEnabled) || + // Request 4K Series and Auto-Approve 4K Series require 4K series requests to be enabled + ((option.permission === Permission.REQUEST_4K_TV || + option.permission === Permission.AUTO_APPROVE_4K_TV) && + !settings.currentSettings.series4kEnabled) + ) { + disabled = true; + checked = false; + } + return ( <>
- hasPermission(requirement.permissions, currentPermission, { - type: requirement.type ?? 'and', - }) - )) - ? 'opacity-50' - : '' + disabled ? 'opacity-50' : '' }`} >
@@ -77,30 +116,7 @@ const PermissionOption: React.FC = ({ id={option.id} name="permissions" type="checkbox" - disabled={ - (currentUser && currentUser.id === 1) || - (option.permission !== Permission.ADMIN && - hasPermission(Permission.ADMIN, currentPermission)) || - (autoApprovePermissions.includes(option.permission) && - hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) || - (!!parent?.permission && - hasPermission(parent.permission, currentPermission)) || - (actingUser && - !hasPermission(Permission.ADMIN, actingUser.permissions) && - option.permission === Permission.ADMIN) || - (actingUser && - !hasPermission( - Permission.MANAGE_SETTINGS, - actingUser.permissions - ) && - option.permission === Permission.MANAGE_SETTINGS) || - (option.requires && - !option.requires.every((requirement) => - hasPermission(requirement.permissions, currentPermission, { - type: requirement.type ?? 'and', - }) - )) - } + disabled={disabled} onChange={() => { onUpdate( hasPermission(option.permission, currentPermission) @@ -108,22 +124,7 @@ const PermissionOption: React.FC = ({ : currentPermission + option.permission ); }} - checked={ - (hasPermission(option.permission, currentPermission) || - (!!parent?.permission && - hasPermission(parent.permission, currentPermission)) || - (autoApprovePermissions.includes(option.permission) && - hasPermission( - Permission.MANAGE_REQUESTS, - currentPermission - ))) && - (!option.requires || - option.requires.every((requirement) => - hasPermission(requirement.permissions, currentPermission, { - type: requirement.type ?? 'and', - }) - )) - } + checked={checked} />
diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index ce551774..6ed4886f 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -7,6 +7,7 @@ import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ settings: 'Settings', menuGeneralSettings: 'General Settings', + menuUsers: 'Users', menuPlexSettings: 'Plex', menuServices: 'Services', menuNotifications: 'Notifications', @@ -31,6 +32,11 @@ const SettingsLayout: React.FC = ({ children }) => { route: '/settings/main', regex: /^\/settings(\/main)?$/, }, + { + text: intl.formatMessage(messages.menuUsers), + route: '/settings/users', + regex: /^\/settings\/users/, + }, { text: intl.formatMessage(messages.menuPlexSettings), route: '/settings/plex', diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index 83008652..3143226e 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -11,7 +11,6 @@ import { useUser, Permission } from '../../hooks/useUser'; import { useToasts } from 'react-toast-notifications'; import Badge from '../Common/Badge'; import globalMessages from '../../i18n/globalMessages'; -import PermissionEdit from '../PermissionEdit'; import * as Yup from 'yup'; import RegionSelector from '../RegionSelector'; @@ -34,7 +33,6 @@ const messages = defineMessages({ toastApiKeyFailure: 'Something went wrong while generating a new API key.', toastSettingsSuccess: 'Settings successfully saved!', toastSettingsFailure: 'Something went wrong while saving settings.', - defaultPermissions: 'Default User Permissions', hideAvailable: 'Hide Available Media', csrfProtection: 'Enable CSRF Protection', csrfProtectionTip: @@ -44,7 +42,6 @@ const messages = defineMessages({ trustProxy: 'Enable Proxy Support', trustProxyTip: 'Allows Overseerr to correctly register client IP addresses behind a proxy (Overseerr must be reloaded for changes to take effect)', - localLogin: 'Enable Local User Sign-In', validationApplicationTitle: 'You must provide an application title', validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', @@ -116,9 +113,7 @@ const SettingsMain: React.FC = () => { applicationTitle: data?.applicationTitle, applicationUrl: data?.applicationUrl, csrfProtection: data?.csrfProtection, - defaultPermissions: data?.defaultPermissions ?? 0, hideAvailable: data?.hideAvailable, - localLogin: data?.localLogin, region: data?.region, originalLanguage: data?.originalLanguage, trustProxy: data?.trustProxy, @@ -131,9 +126,7 @@ const SettingsMain: React.FC = () => { applicationTitle: values.applicationTitle, applicationUrl: values.applicationUrl, csrfProtection: values.csrfProtection, - defaultPermissions: values.defaultPermissions, hideAvailable: values.hideAvailable, - localLogin: values.localLogin, region: values.region, originalLanguage: values.originalLanguage, trustProxy: values.trustProxy, @@ -345,42 +338,6 @@ const SettingsMain: React.FC = () => { />
-
- -
- { - setFieldValue('localLogin', !values.localLogin); - }} - /> -
-
-
-
- - {intl.formatMessage(messages.defaultPermissions)} - -
-
- - setFieldValue('defaultPermissions', newPermissions) - } - /> -
-
-
-
diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx new file mode 100644 index 00000000..35dfeea8 --- /dev/null +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import useSWR from 'swr'; +import LoadingSpinner from '../../Common/LoadingSpinner'; +import type { MainSettings } from '../../../../server/lib/settings'; +import { Form, Formik, Field } from 'formik'; +import axios from 'axios'; +import Button from '../../Common/Button'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import PermissionEdit from '../../PermissionEdit'; + +const messages = defineMessages({ + userSettings: 'Users', + userSettingsDescription: 'Configure global and default user settings.', + save: 'Save Changes', + saving: 'Saving…', + toastSettingsSuccess: 'Settings successfully saved!', + toastSettingsFailure: 'Something went wrong while saving settings.', + localLogin: 'Enable Local User Sign-In', + defaultPermissions: 'Default User Permissions', +}); + +const SettingsUsers: React.FC = () => { + const { addToast } = useToasts(); + const intl = useIntl(); + const { data, error, revalidate } = useSWR( + '/api/v1/settings/main' + ); + + if (!data && !error) { + return ; + } + + return ( + <> +
+

{intl.formatMessage(messages.userSettings)}

+

+ {intl.formatMessage(messages.userSettingsDescription)} +

+
+
+ { + try { + await axios.post('/api/v1/settings/main', { + localLogin: values.localLogin, + defaultPermissions: values.defaultPermissions, + }); + + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + addToast(intl.formatMessage(messages.toastSettingsFailure), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidate(); + } + }} + > + {({ isSubmitting, values, setFieldValue }) => { + return ( +
+
+ +
+ { + setFieldValue('localLogin', !values.localLogin); + }} + /> +
+
+
+
+ + {intl.formatMessage(messages.defaultPermissions)} + +
+
+ + setFieldValue('defaultPermissions', newPermissions) + } + /> +
+
+
+
+
+
+ + + +
+
+
+ ); + }} +
+
+ + ); +}; + +export default SettingsUsers; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index b4bd7667..874e740c 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -441,6 +441,14 @@ "components.Settings.SettingsJobsCache.runnow": "Run Now", "components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan", "components.Settings.SettingsJobsCache.unknownJob": "Unknown Job", + "components.Settings.SettingsUsers.defaultPermissions": "Default User Permissions", + "components.Settings.SettingsUsers.localLogin": "Enable Local User Sign-In", + "components.Settings.SettingsUsers.save": "Save Changes", + "components.Settings.SettingsUsers.saving": "Saving…", + "components.Settings.SettingsUsers.toastSettingsFailure": "Something went wrong while saving settings.", + "components.Settings.SettingsUsers.toastSettingsSuccess": "Settings successfully saved!", + "components.Settings.SettingsUsers.userSettings": "Users", + "components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.", "components.Settings.SonarrModal.add": "Add Server", "components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile", "components.Settings.SonarrModal.animequalityprofile": "Anime Quality Profile", @@ -508,7 +516,6 @@ "components.Settings.currentlibrary": "Current Library: {name}", "components.Settings.default": "Default", "components.Settings.default4k": "Default 4K", - "components.Settings.defaultPermissions": "Default User Permissions", "components.Settings.delete": "Delete", "components.Settings.deleteserverconfirm": "Are you sure you want to delete this server?", "components.Settings.edit": "Edit", @@ -519,7 +526,6 @@ "components.Settings.hideAvailable": "Hide Available Media", "components.Settings.hostname": "Hostname/IP", "components.Settings.librariesRemaining": "Libraries Remaining: {count}", - "components.Settings.localLogin": "Enable Local User Sign-In", "components.Settings.manualscan": "Manual Library Scan", "components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", "components.Settings.menuAbout": "About", @@ -529,6 +535,7 @@ "components.Settings.menuNotifications": "Notifications", "components.Settings.menuPlexSettings": "Plex", "components.Settings.menuServices": "Services", + "components.Settings.menuUsers": "Users", "components.Settings.nodefault": "No Default Server", "components.Settings.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.", "components.Settings.notificationAgentSettingsDescription": "Choose the types of notifications to send, and which notification agents to use.", diff --git a/src/pages/settings/users.tsx b/src/pages/settings/users.tsx new file mode 100644 index 00000000..1461d724 --- /dev/null +++ b/src/pages/settings/users.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { NextPage } from 'next'; +import SettingsLayout from '../../components/Settings/SettingsLayout'; +import SettingsUsers from '../../components/Settings/SettingsUsers'; +import { Permission } from '../../hooks/useUser'; +import useRouteGuard from '../../hooks/useRouteGuard'; + +const SettingsUsersPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_SETTINGS); + return ( + + + + ); +}; + +export default SettingsUsersPage;