From 185ac2648fd21c4bf9692ac5ac055e9c740065ca Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 17 Nov 2020 05:48:59 +0000 Subject: [PATCH] feat: user edit functionality (managing permissions) --- overseerr-api.yml | 1 + server/routes/user.ts | 31 ++ src/components/Layout/SearchInput/index.tsx | 4 +- src/components/Layout/Sidebar/index.tsx | 2 +- src/components/UserEdit/index.tsx | 327 ++++++++++++++++++++ src/components/UserList/index.tsx | 5 +- src/components/UserProfile/index.tsx | 96 ------ src/hooks/useRouteGuard.ts | 16 + src/hooks/useUser.ts | 14 +- src/pages/settings/index.tsx | 3 + src/pages/settings/jobs.tsx | 3 + src/pages/settings/main.tsx | 3 + src/pages/settings/plex.tsx | 3 + src/pages/settings/services.tsx | 3 + src/pages/users/[userId].tsx | 9 - src/pages/users/[userId]/edit.tsx | 12 + src/pages/users/index.tsx | 3 + 17 files changed, 415 insertions(+), 120 deletions(-) create mode 100644 src/components/UserEdit/index.tsx delete mode 100644 src/components/UserProfile/index.tsx create mode 100644 src/hooks/useRouteGuard.ts delete mode 100644 src/pages/users/[userId].tsx create mode 100644 src/pages/users/[userId]/edit.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 5f00fb5d9..41f6a2a25 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -35,6 +35,7 @@ components: readOnly: true requests: type: array + readOnly: true items: $ref: '#/components/schemas/MediaRequest' required: diff --git a/server/routes/user.ts b/server/routes/user.ts index 646a64846..86e5920ba 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; import { User } from '../entity/User'; +import { hasPermission, Permission } from '../lib/permissions'; const router = Router(); @@ -50,6 +51,36 @@ router.put<{ id: string }>('/:id', async (req, res, next) => { where: { id: Number(req.params.id) }, }); + // Only let the owner user modify themselves + if (user.id === 1 && req.user?.id !== 1) { + return next({ + status: 403, + message: 'You do not have permission to modify this user', + }); + } + + // Only let the owner grant admin privileges + if ( + hasPermission(Permission.ADMIN, req.body.permissions) && + req.user?.id !== 1 + ) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } + + // Only let users with the manage settings permission, grant the same permission + if ( + hasPermission(Permission.MANAGE_SETTINGS, req.body.permissions) && + !hasPermission(Permission.MANAGE_SETTINGS, req.user?.permissions ?? 0) + ) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } + Object.assign(user, req.body); await userRepository.save(user); diff --git a/src/components/Layout/SearchInput/index.tsx b/src/components/Layout/SearchInput/index.tsx index 9f5ab7889..7d3a57dca 100644 --- a/src/components/Layout/SearchInput/index.tsx +++ b/src/components/Layout/SearchInput/index.tsx @@ -11,7 +11,7 @@ const SearchInput: React.FC = () => { const { searchValue, setSearchValue, setIsOpen } = useSearchInput(); return (
-
+
@@ -36,7 +36,7 @@ const SearchInput: React.FC = () => { onBlur={() => setIsOpen(false)} />
-
+
); }; diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index fcc98e802..c27223cab 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -6,7 +6,7 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import { useUser, Permission } from '../../../hooks/useUser'; const messages = defineMessages({ - dashboard: 'Dashboard', + dashboard: 'Discover', requests: 'Requests', users: 'Users', settings: 'Settings', diff --git a/src/components/UserEdit/index.tsx b/src/components/UserEdit/index.tsx new file mode 100644 index 000000000..6885c96a7 --- /dev/null +++ b/src/components/UserEdit/index.tsx @@ -0,0 +1,327 @@ +import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import { Permission, useUser } from '../../hooks/useUser'; +import { hasPermission } from '../../../server/lib/permissions'; +import Button from '../Common/Button'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; + +const messages = defineMessages({ + edituser: 'Edit User', + username: 'Username', + avatar: 'Avatar', + email: 'Email', + permissions: 'Permissions', + admin: 'Admin', + adminDescription: + 'Full administrator access. Bypasses all permission checks.', + users: 'Manage Users', + usersDescription: + 'Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.', + settings: 'Manage Settings', + settingsDescription: + 'Grants permission to modify all Overseerr settings. User must have this permission to be able to grant it to others.', + managerequests: 'Manage Requests', + managerequestsDescription: + 'Grants permission to manage Overseerr requests. This includes approving and denying requests.', + request: 'Request', + requestDescription: + 'Grants permission to make requests for movies or tv shows.', + vote: 'Vote', + voteDescription: + 'Grants permission to vote on requests (voting not yet implemented)', + autoapprove: 'Auto Approve', + autoapproveDescription: + 'Grants auto approval for any requests made by this user.', + save: 'Save', + saving: 'Saving...', + usersaved: 'User succesfully saved', + userfail: 'Something went wrong saving the user.', +}); + +interface PermissionOption { + id: string; + name: string; + description: string; + permission: Permission; +} + +const UserEdit: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + const { addToast } = useToasts(); + const [isUpdating, setIsUpdating] = useState(false); + const { user: currentUser, hasPermission: currentHasPermission } = useUser(); + const { user, error, revalidate } = useUser({ + id: Number(router.query.userId), + }); + const [currentPermission, setCurrentPermission] = useState(0); + + useEffect(() => { + if (currentPermission !== user?.permissions ?? 0) { + setCurrentPermission(user?.permissions ?? 0); + } + // We know what we are doing here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user]); + + const updateUser = async () => { + try { + setIsUpdating(true); + + await axios.put(`/api/v1/user/${user?.id}`, { + permissions: currentPermission, + email: user?.email, + avatar: user?.avatar, + }); + + addToast(intl.formatMessage(messages.usersaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.userfail), { + appearance: 'error', + autoDismiss: true, + }); + throw new Error(`Something went wrong saving the user: ${e.message}`); + } finally { + revalidate(); + setIsUpdating(false); + } + }; + + if (!user && !error) { + return ; + } + + const permissionList: PermissionOption[] = [ + { + id: 'admin', + name: intl.formatMessage(messages.admin), + description: intl.formatMessage(messages.adminDescription), + permission: Permission.ADMIN, + }, + { + id: 'settings', + name: intl.formatMessage(messages.settings), + description: intl.formatMessage(messages.settingsDescription), + permission: Permission.MANAGE_SETTINGS, + }, + { + id: 'users', + name: intl.formatMessage(messages.users), + description: intl.formatMessage(messages.usersDescription), + permission: Permission.MANAGE_USERS, + }, + { + id: 'managerequest', + name: intl.formatMessage(messages.managerequests), + description: intl.formatMessage(messages.managerequestsDescription), + permission: Permission.MANAGE_REQUESTS, + }, + { + id: 'request', + name: intl.formatMessage(messages.request), + description: intl.formatMessage(messages.requestDescription), + permission: Permission.REQUEST, + }, + { + id: 'vote', + name: intl.formatMessage(messages.vote), + description: intl.formatMessage(messages.voteDescription), + permission: Permission.VOTE, + }, + { + id: 'autoapprove', + name: intl.formatMessage(messages.autoapprove), + description: intl.formatMessage(messages.autoapproveDescription), + permission: Permission.AUTO_APPROVE, + }, + ]; + + return ( +
+
+
+

+ +

+
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ {permissionList.map((permissionOption) => ( +
+
+ { + setCurrentPermission((current) => + hasPermission( + permissionOption.permission, + currentPermission + ) + ? current - permissionOption.permission + : current + permissionOption.permission + ); + }} + checked={hasPermission( + permissionOption.permission, + currentPermission + )} + /> +
+
+ +

+ {permissionOption.description} +

+
+
+ ))} +
+
+
+
+
+
+
+ + + +
+
+
+
+ ); +}; + +export default UserEdit; diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 6e7d3fcd8..08b43bd9b 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -100,7 +100,10 @@ const UserList: React.FC = () => { buttonType="warning" className="mr-2" onClick={() => - router.push('/users/[userId]', `/users/${user.id}`) + router.push( + '/users/[userId]/edit', + `/users/${user.id}/edit` + ) } > Edit diff --git a/src/components/UserProfile/index.tsx b/src/components/UserProfile/index.tsx deleted file mode 100644 index aa43a6ffd..000000000 --- a/src/components/UserProfile/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import { useRouter } from 'next/router'; -import useSWR from 'swr'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import type { User } from '../../../server/entity/User'; - -const UserProfile: React.FC = () => { - const router = useRouter(); - const { data, error } = useSWR(`/api/v1/user/${router.query.userId}`); - - if (!data && !error) { - return ; - } - - return ( -
-
-
-

- User Profile -

-
-
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- -
-
- -
- -
-
-
-
- ); -}; - -export default UserProfile; diff --git a/src/hooks/useRouteGuard.ts b/src/hooks/useRouteGuard.ts new file mode 100644 index 000000000..772cd64d5 --- /dev/null +++ b/src/hooks/useRouteGuard.ts @@ -0,0 +1,16 @@ +import { Permission, useUser } from './useUser'; +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +const useRouteGuard = (permission: Permission | Permission[]): void => { + const router = useRouter(); + const { user, hasPermission } = useUser(); + + useEffect(() => { + if (user && !hasPermission(permission)) { + router.push('/'); + } + }, [user, permission, router, hasPermission]); +}; + +export default useRouteGuard; diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index 9994adf67..5f33a93b2 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -1,24 +1,16 @@ import useSwr from 'swr'; import { useRef } from 'react'; -import { hasPermission } from '../../server/lib/permissions'; +import { hasPermission, Permission } from '../../server/lib/permissions'; export interface User { id: number; + username: string; email: string; avatar: string; permissions: number; } -export enum Permission { - NONE = 0, - ADMIN = 2, - MANAGE_SETTINGS = 4, - MANAGE_USERS = 8, - MANAGE_REQUESTS = 16, - REQUEST = 32, - VOTE = 64, - AUTO_APPROVE = 128, -} +export { Permission }; interface UserHookResponse { user?: User; diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index a3e267e41..534857f36 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -2,8 +2,11 @@ import React from 'react'; import { NextPage } from 'next'; import SettingsLayout from '../../components/Settings/SettingsLayout'; import SettingsMain from '../../components/Settings/SettingsMain'; +import useRouteGuard from '../../hooks/useRouteGuard'; +import { Permission } from '../../hooks/useUser'; const SettingsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); return ( diff --git a/src/pages/settings/jobs.tsx b/src/pages/settings/jobs.tsx index 0e5292d18..2ca87139a 100644 --- a/src/pages/settings/jobs.tsx +++ b/src/pages/settings/jobs.tsx @@ -2,8 +2,11 @@ import React from 'react'; import type { NextPage } from 'next'; import SettingsLayout from '../../components/Settings/SettingsLayout'; import SettingsJobs from '../../components/Settings/SettingsJobs'; +import { Permission } from '../../hooks/useUser'; +import useRouteGuard from '../../hooks/useRouteGuard'; const SettingsMainPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); return ( diff --git a/src/pages/settings/main.tsx b/src/pages/settings/main.tsx index 84b845979..3a84b8236 100644 --- a/src/pages/settings/main.tsx +++ b/src/pages/settings/main.tsx @@ -2,8 +2,11 @@ import React from 'react'; import { NextPage } from 'next'; import SettingsLayout from '../../components/Settings/SettingsLayout'; import SettingsMain from '../../components/Settings/SettingsMain'; +import { Permission } from '../../hooks/useUser'; +import useRouteGuard from '../../hooks/useRouteGuard'; const SettingsMainPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); return ( diff --git a/src/pages/settings/plex.tsx b/src/pages/settings/plex.tsx index d05887308..ea10f4448 100644 --- a/src/pages/settings/plex.tsx +++ b/src/pages/settings/plex.tsx @@ -2,8 +2,11 @@ import React from 'react'; import type { NextPage } from 'next'; import SettingsLayout from '../../components/Settings/SettingsLayout'; import SettingsPlex from '../../components/Settings/SettingsPlex'; +import { Permission } from '../../hooks/useUser'; +import useRouteGuard from '../../hooks/useRouteGuard'; const PlexSettingsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); return ( diff --git a/src/pages/settings/services.tsx b/src/pages/settings/services.tsx index d7cafe8a1..52e55ddf2 100644 --- a/src/pages/settings/services.tsx +++ b/src/pages/settings/services.tsx @@ -2,8 +2,11 @@ import React from 'react'; import type { NextPage } from 'next'; import SettingsLayout from '../../components/Settings/SettingsLayout'; import SettingsServices from '../../components/Settings/SettingsServices'; +import { Permission } from '../../hooks/useUser'; +import useRouteGuard from '../../hooks/useRouteGuard'; const ServicesSettingsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); return ( diff --git a/src/pages/users/[userId].tsx b/src/pages/users/[userId].tsx deleted file mode 100644 index a8eca967c..000000000 --- a/src/pages/users/[userId].tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { NextPage } from 'next'; -import UserProfile from '../../components/UserProfile'; - -const UserProfilePage: NextPage = () => { - return ; -}; - -export default UserProfilePage; diff --git a/src/pages/users/[userId]/edit.tsx b/src/pages/users/[userId]/edit.tsx new file mode 100644 index 000000000..8f28cca5e --- /dev/null +++ b/src/pages/users/[userId]/edit.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { NextPage } from 'next'; +import UserEdit from '../../../components/UserEdit'; +import useRouteGuard from '../../../hooks/useRouteGuard'; +import { Permission } from '../../../hooks/useUser'; + +const UserProfilePage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ; +}; + +export default UserProfilePage; diff --git a/src/pages/users/index.tsx b/src/pages/users/index.tsx index 5a7dafe33..25351b0ea 100644 --- a/src/pages/users/index.tsx +++ b/src/pages/users/index.tsx @@ -1,8 +1,11 @@ import React from 'react'; import type { NextPage } from 'next'; import UserList from '../../components/UserList'; +import useRouteGuard from '../../hooks/useRouteGuard'; +import { Permission } from '../../hooks/useUser'; const UsersPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); return ; };