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 (
);
};
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 (
-
-
-
-
-
-
-
-
- Avatar
-
-
-
-
-

-
-
-
-
-
-

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