Add server permission management

This commit is contained in:
jeffvli
2022-11-14 01:13:54 -08:00
parent 1babcc40ee
commit c54eea4382
16 changed files with 594 additions and 43 deletions
+11 -11
View File
@@ -194,7 +194,7 @@ const deleteServerUrl = async (
id: urlId,
});
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
@@ -209,7 +209,7 @@ const enableServerUrl = async (
serverId,
});
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
@@ -219,7 +219,7 @@ const disableServerUrl = async (
) => {
await service.servers.disableUrlById(req.authUser);
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
@@ -231,7 +231,7 @@ const deleteServerFolder = async (
await service.servers.deleteFolderById({ id: folderId });
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
@@ -243,7 +243,7 @@ const enableServerFolder = async (
await service.servers.enableFolderById({ id: folderId });
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
@@ -255,7 +255,7 @@ const disableServerFolder = async (
await service.servers.disableFolderById({ id: folderId });
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
@@ -286,7 +286,7 @@ const deleteServerPermission = async (
id: permissionId,
});
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
@@ -302,7 +302,7 @@ const updateServerPermission = async (
type,
});
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
@@ -326,11 +326,11 @@ const deleteServerFolderPermission = async (
req: TypedRequest<typeof validation.servers.deleteServerFolderPermission>,
res: Response
) => {
const { permissionId } = req.params;
const { folderPermissionId } = req.params;
await service.servers.deleteFolderPermission({ id: permissionId });
await service.servers.deleteFolderPermission({ id: folderPermissionId });
const success = ApiSuccess.noContent({ data: null });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
+9 -5
View File
@@ -111,11 +111,13 @@ router
.route('/:serverId/permissions/:permissionId')
.patch(
authenticateServerAdmin,
validateRequest(validation.servers.updateServerPermission)
validateRequest(validation.servers.updateServerPermission),
controller.servers.updateServerPermission
)
.delete(
authenticateServerAdmin,
validateRequest(validation.servers.deleteServerPermission)
validateRequest(validation.servers.deleteServerPermission),
controller.servers.deleteServerPermission
);
router.param('folderId', async (_req, _res, next, folderId) => {
@@ -149,9 +151,11 @@ router
router
.route('/:serverId/folder/:folderId/permissions')
.post(authenticateServerAdmin);
.post(authenticateServerAdmin, controller.servers.addServerFolderPermission);
router
.route('/:serverId/folder/:folderId/permissions/:folderPermissionId')
.patch(authenticateServerAdmin)
.delete(authenticateServerAdmin);
.delete(
authenticateServerAdmin,
controller.servers.deleteServerFolderPermission
);
+1 -5
View File
@@ -13,11 +13,7 @@ export const router: Router = express.Router({ mergeParams: true });
router
.route('/')
.get(
authenticateAdmin,
validateRequest(validation.users.list),
controller.users.getUserList
)
.get(validateRequest(validation.users.list), controller.users.getUserList)
.post(
authenticateAdmin,
validateRequest(validation.users.createUser),
+4 -1
View File
@@ -138,7 +138,10 @@ const findMany = async (user: AuthUser, options?: { enabled?: boolean }) => {
},
// If not admin, only show folders the user has permissions for
{ serverFolderPermissions: { some: { userId: user.id } } },
{ enabled: options?.enabled ? true : undefined },
{
enabled: options?.enabled ? true : undefined,
serverFolderPermissions: { some: { userId: user.id } },
},
],
},
},
+10 -4
View File
@@ -11,9 +11,10 @@ import { SortOrder } from '../types/types';
const findById = async (user: AuthUser, options: { id: string }) => {
const { id } = options;
if (!user.isAdmin && user.id !== id) {
throw ApiError.forbidden();
}
// Possibly restrict detail later if additional sensitive user data is added
// if (!user.isAdmin && user.id !== id) {
// throw ApiError.forbidden();
// }
const uniqueUser = await prisma.user.findUnique({
include: {
@@ -33,9 +34,14 @@ const findById = async (user: AuthUser, options: { id: string }) => {
const findMany = async () => {
const users = await prisma.user.findMany({
include: { files: true },
include: {
files: true,
serverFolderPermissions: true,
serverPermissions: true,
},
orderBy: [{ isAdmin: SortOrder.DESC }, { username: SortOrder.ASC }],
});
return users;
};
+83
View File
@@ -2,6 +2,7 @@ import {
BaseResponse,
NullResponse,
Server,
ServerPermissionType,
ServerType,
ServerUrl,
} from '@/renderer/api/types';
@@ -145,10 +146,91 @@ const fullScan = async (options: {
return data;
};
export type CreateServerPermissionBody = {
type: ServerPermissionType;
userId: string;
};
const createServerPermission = async (
query: { serverId: string },
body: CreateServerPermissionBody
) => {
const { data } = await ax.post<NullResponse>(
`/servers/${query.serverId}/permissions`,
body
);
return data;
};
const deleteServerPermission = async (query: {
permissionId: string;
serverId: string;
}) => {
const { data } = await ax.delete<NullResponse>(
`/servers/${query.serverId}/permissions/${query.permissionId}`
);
return data;
};
export type UpdateServerPermissionBody = {
type: ServerPermissionType;
};
const updateServerPermission = async (
query: {
permissionId: string;
serverId: string;
},
body: UpdateServerPermissionBody
) => {
const { data } = await ax.patch<NullResponse>(
`/servers/${query.serverId}/permissions/${query.permissionId}`,
body
);
return data;
};
export type CreateServerFolderPermissionBody = {
userId: string;
};
const createServerFolderPermission = async (
query: {
folderId: string;
serverId: string;
},
body: CreateServerFolderPermissionBody
) => {
const { data } = await ax.post<NullResponse>(
`/servers/${query.serverId}/folder/${query.folderId}/permissions`,
body
);
return data;
};
const deleteServerFolderPermission = async (query: {
folderId: string;
folderPermissionId: string;
serverId: string;
}) => {
const { data } = await ax.delete<NullResponse>(
`/servers/${query.serverId}/folder/${query.folderId}/permissions/${query.folderPermissionId}`
);
return data;
};
export const serversApi = {
createServer,
createServerFolderPermission,
createServerPermission,
createUrl,
deleteServer,
deleteServerFolderPermission,
deleteServerPermission,
deleteUrl,
disableFolder,
disableUrl,
@@ -158,4 +240,5 @@ export const serversApi = {
getServerList,
quickScan,
updateServer,
updateServerPermission,
};
+5
View File
@@ -2,3 +2,8 @@ export * from './mutations/use-create-server';
export * from './components/add-server-form';
export * from './components/server-list';
export * from './queries/use-server-list';
export * from './mutations/create-server-folder-permission';
export * from './mutations/delete-server-folder-permission';
export * from './mutations/update-server-permission';
export * from './mutations/create-server-permission';
export * from './mutations/delete-server-permission';
@@ -0,0 +1,31 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '@/renderer/api';
import { queryKeys } from '@/renderer/api/query-keys';
import { CreateServerFolderPermissionBody } from '@/renderer/api/servers.api';
import { ApiError, NullResponse } from '@/renderer/api/types';
import { UserListResponse } from '@/renderer/api/users.api';
export const useCreateServerFolderPermission = () => {
const queryClient = useQueryClient();
const mutation = useMutation<
NullResponse,
AxiosError<ApiError>,
{
body: CreateServerFolderPermissionBody;
query: { folderId: string; serverId: string };
},
{ previous: UserListResponse | undefined }
>({
mutationFn: ({ query, body }) =>
api.servers.createServerFolderPermission(query, body),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(
queryKeys.users.detail(variables.body.userId)
);
},
});
return mutation;
};
@@ -0,0 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '@/renderer/api';
import { queryKeys } from '@/renderer/api/query-keys';
import { CreateServerPermissionBody } from '@/renderer/api/servers.api';
import { ApiError, NullResponse } from '@/renderer/api/types';
export const useCreateServerPermission = () => {
const queryClient = useQueryClient();
const mutation = useMutation<
NullResponse,
AxiosError<ApiError>,
{
body: CreateServerPermissionBody;
query: { serverId: string };
},
undefined
>({
mutationFn: ({ query, body }) =>
api.servers.createServerPermission(query, body),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(
queryKeys.users.detail(variables.body.userId)
);
},
});
return mutation;
};
@@ -0,0 +1,26 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '@/renderer/api';
import { queryKeys } from '@/renderer/api/query-keys';
import { ApiError, NullResponse } from '@/renderer/api/types';
export const useDeleteServerFolderPermission = () => {
const queryClient = useQueryClient();
const mutation = useMutation<
NullResponse,
AxiosError<ApiError>,
{
query: { folderId: string; folderPermissionId: string; serverId: string };
userId: string;
},
undefined
>({
mutationFn: ({ query }) => api.servers.deleteServerFolderPermission(query),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(queryKeys.users.detail(variables.userId));
},
});
return mutation;
};
@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '@/renderer/api';
import { queryKeys } from '@/renderer/api/query-keys';
import { ApiError, NullResponse } from '@/renderer/api/types';
export const useDeleteServerPermission = () => {
const queryClient = useQueryClient();
const mutation = useMutation<
NullResponse,
AxiosError<ApiError>,
{ query: { permissionId: string; serverId: string }; userId: string },
undefined
>({
mutationFn: ({ query }) => api.servers.deleteServerPermission(query),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(queryKeys.users.detail(variables.userId));
},
});
return mutation;
};
@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '@/renderer/api';
import { queryKeys } from '@/renderer/api/query-keys';
import { UpdateServerPermissionBody } from '@/renderer/api/servers.api';
import { ApiError, NullResponse } from '@/renderer/api/types';
export const useUpdateServerPermission = () => {
const queryClient = useQueryClient();
const mutation = useMutation<
NullResponse,
AxiosError<ApiError>,
{
body: UpdateServerPermissionBody;
query: { permissionId: string; serverId: string };
userId: string;
},
undefined
>({
mutationFn: ({ query, body }) =>
api.servers.updateServerPermission(query, body),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(queryKeys.users.detail(variables.userId));
},
});
return mutation;
};
@@ -47,9 +47,15 @@ export const usePermissions = () => {
: -1;
});
const isMusicServerAdmin = Object.keys(serverPermissions).some((key) => {
return serverPermissions[key] === ServerPermission.ADMIN;
});
return {
isAdmin: permissions.isAdmin || permissions.isSuperAdmin,
isMusicServerAdmin,
isSuperAdmin: permissions.isSuperAdmin,
userId,
...serverPermissions,
};
}, [
@@ -57,6 +63,7 @@ export const usePermissions = () => {
permissions.isSuperAdmin,
servers?.data,
user?.data?.serverPermissions,
userId,
]);
return permissionSet;
@@ -72,6 +72,7 @@ export const AppMenu = () => {
),
exitTransitionDuration: 300,
overflow: 'inside',
size: 'lg',
title: 'Edit Profile',
transition: 'slide-down',
});
@@ -159,7 +160,7 @@ export const AppMenu = () => {
>
Edit profile
</DropdownMenu.Item>
{permissions.isAdmin && (
{(permissions.isAdmin || permissions.isMusicServerAdmin) && (
<DropdownMenu.Item
rightSection={<RiUserAddLine />}
onClick={handleManageUsersModal}
@@ -0,0 +1,272 @@
import { ChangeEvent } from 'react';
import { Stack, Group, Divider } from '@mantine/core';
import { RiServerFill } from 'react-icons/ri';
import { ServerPermissionType } from '@/renderer/api/types';
import {
Accordion,
Button,
Select,
Switch,
Text,
toast,
Tooltip,
} from '@/renderer/components';
import {
useCreateServerPermission,
useServerList,
useUpdateServerPermission,
useCreateServerFolderPermission,
useDeleteServerFolderPermission,
useDeleteServerPermission,
} from '@/renderer/features/servers';
import { ServerPermission, usePermissions } from '@/renderer/features/shared';
import { useUserDetail } from '@/renderer/features/users/queries/get-user-detail';
import { titleCase } from '@/renderer/utils';
interface EditUserPermissionsFormProps {
onCancel: () => void;
userId: string;
}
export const PERMISSION_TYPE_OPTIONS = [
{ label: 'None', value: '' },
{ label: 'Viewer', value: ServerPermissionType.VIEWER },
{ label: 'Editor', value: ServerPermissionType.EDITOR },
{ label: 'Editor', value: ServerPermissionType.EDITOR },
];
export const EditUserPermissionsForm = ({
userId,
onCancel,
}: EditUserPermissionsFormProps) => {
const permissions = usePermissions();
const { data: servers } = useServerList();
const { data: user } = useUserDetail({ userId });
const createServerPermissionMutation = useCreateServerPermission();
const deleteServerPermissionMutation = useDeleteServerPermission();
const updateServerPermissionMutation = useUpdateServerPermission();
const createServerFolderPermissionMutation =
useCreateServerFolderPermission();
const deleteServerFolderPermissionMutation =
useDeleteServerFolderPermission();
const permissionTypeOptions = [
{ label: 'None', value: 'none' },
{ label: 'Viewer', value: ServerPermissionType.VIEWER },
{ label: 'Editor', value: ServerPermissionType.EDITOR },
{
disabled: !permissions.isAdmin,
label: 'Admin',
value: ServerPermissionType.ADMIN,
},
];
return (
<Stack m={5}>
<Accordion variant="contained">
{servers?.data?.map((s) => {
const currentServerPermission = user?.data?.serverPermissions?.find(
(p) => p.serverId === s.id
);
const isServerAdminEditingSelf =
permissions[s.id] >= ServerPermission.ADMIN &&
user?.data.id === permissions.userId;
const isServerAdminEditingOtherAdmin =
!permissions.isAdmin &&
currentServerPermission?.type === ServerPermissionType.ADMIN;
const isPermissionTypeDisabled =
isServerAdminEditingSelf || isServerAdminEditingOtherAdmin;
const handleChangeServerPermission = async (e: string | null) => {
if (!e || !user) return;
if (e === 'none' && currentServerPermission) {
deleteServerPermissionMutation.mutate(
{
query: {
permissionId: currentServerPermission.id,
serverId: s.id,
},
userId: user.data.id,
},
{
onError: (err) =>
toast.error({
message: err?.response?.data.error.message,
title: 'Error deleting folder permission',
}),
}
);
} else if (currentServerPermission) {
updateServerPermissionMutation.mutate(
{
body: {
type: e as ServerPermissionType,
},
query: {
permissionId: currentServerPermission.id,
serverId: s.id,
},
userId: user.data.id,
},
{
onError: (err) =>
toast.error({
message: err?.response?.data.error.message,
title: 'Error updating folder permission',
}),
}
);
} else {
createServerPermissionMutation.mutate(
{
body: {
type: e as ServerPermissionType,
userId: user.data.id,
},
query: {
serverId: s.id,
},
},
{
onError: (err) =>
toast.error({
message: err?.response?.data.error.message,
title: 'Error creating server permission',
}),
}
);
}
};
return (
<Accordion.Item key={`server-permission-${s.id}`} value={s.name}>
<Accordion.Control icon={<RiServerFill />}>
<Group>
<Text>
{s.name} ({titleCase(s.type)})
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack>
<Select
data={permissionTypeOptions}
defaultValue={currentServerPermission?.type || 'none'}
disabled={isPermissionTypeDisabled}
label="Permission Type"
width={150}
onChange={handleChangeServerPermission}
/>
<Group>
<Tooltip label="Allows the user to trigger full scans and edit user permissions for this server">
<span>
<Text $secondary size="xs">
Admin
</Text>
</span>
</Tooltip>
<Tooltip label="Allows the user to trigger quick scans and edit server urls">
<span>
<Text $secondary size="xs">
Editor
</Text>
</span>
</Tooltip>
<Tooltip label="Allows the user to view the server">
<span>
<Text $secondary size="xs">
Viewer
</Text>
</span>
</Tooltip>
</Group>
<Divider my={5} />
<Stack spacing={0}>
<Text>Music Folders</Text>
<Text $secondary size="xs">
Server admins have access to all music folders by default.
</Text>
</Stack>
{s.serverFolders?.map((f) => {
const currentFolderPermission =
user?.data.serverFolderPermissions?.find(
(p) => p.serverFolderId === f.id
);
const handleToggleMusicFolderPermission = async (
e: ChangeEvent<HTMLInputElement>
) => {
if (!user) return;
const { checked } = e.target;
const serverId = s.id;
if (checked) {
createServerFolderPermissionMutation.mutate(
{
body: {
userId: user.data.id,
},
query: {
folderId: f.id,
serverId,
},
},
{
onError: (err) =>
toast.error({
message: err?.response?.data.error.message,
title: 'Error creating folder permission',
}),
}
);
} else if (currentFolderPermission) {
deleteServerFolderPermissionMutation.mutate(
{
query: {
folderId: f.id,
folderPermissionId: currentFolderPermission.id,
serverId,
},
userId: user.data.id,
},
{
onError: (err) =>
toast.error({
message: err?.response?.data.error.message,
title: 'Error removing folder permission',
}),
}
);
}
};
return (
<Switch
key={`server-folder-permission-${f.id}`}
defaultChecked={user?.data.serverFolderPermissions.some(
(p) => p.serverFolderId === f.id
)}
disabled={isServerAdminEditingOtherAdmin}
label={f.name}
onChange={handleToggleMusicFolderPermission}
/>
);
})}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
<Group mt={10} position="right">
<Button variant="subtle" onClick={onCancel}>
Go back
</Button>
</Group>
</Stack>
);
};
@@ -19,6 +19,7 @@ import {
import { usePermissions } from '@/renderer/features/shared';
import { AddUserForm } from '@/renderer/features/users/components/add-user-form';
import { EditUserForm } from '@/renderer/features/users/components/edit-user-form';
import { EditUserPermissionsForm } from '@/renderer/features/users/components/edit-user-permissions-form';
import { useDeleteUser } from '../mutations/delete-user';
import { useUserList } from '../queries/get-user-list';
@@ -57,7 +58,7 @@ export const UserList = () => {
modal: 'base',
overflow: 'inside',
size: 'lg',
title: `Edit User`,
title: `Edit User (${user.username})`,
transition: 'slide-down',
});
};
@@ -79,6 +80,25 @@ export const UserList = () => {
);
};
const handleEdituserPermissionsModal = (user: User) => {
openContextModal({
centered: true,
exitTransitionDuration: 300,
innerProps: {
modalBody: (vars: ContextModalVars) => (
<EditUserPermissionsForm
userId={user.id}
onCancel={() => vars.context.closeModal(vars.id)}
/>
),
},
modal: 'base',
overflow: 'inside',
title: `Edit Permissions (${user.username})`,
transition: 'slide-down',
});
};
return (
<Stack>
<Group
@@ -103,41 +123,50 @@ export const UserList = () => {
<React.Fragment key={u.id}>
<Group
noWrap
p={5}
position="apart"
sx={{
'&:hover': {
background: 'rgba(125, 125, 125, 0.1)',
},
transition: 'background 0.2s ease',
}}
>
<Group>
<Group noWrap>
<Avatar radius="xl" src={u.avatarUrl} />
<Stack spacing="xs">
<Text overflow="hidden">
{u.username}
<Text overflow="hidden" sx={{ maxWidth: '15rem' }}>
{(u.isSuperAdmin || u.isAdmin) && (
<Tooltip label={u.isSuperAdmin ? 'Super Admin' : 'Admin'}>
<span>
<span style={{ marginRight: '.5rem' }}>
<RiAdminLine />
</span>
</Tooltip>
)}
{u.username}
</Text>
<Text $secondary size="xs">
<Text
$secondary
overflow="hidden"
size="xs"
sx={{ maxWidth: '15rem' }}
>
{u.displayName}
</Text>
</Stack>
</Group>
<Group>
<Button
compact
disabled={!permissions.isAdmin}
leftIcon={<RiEdit2Line />}
variant="subtle"
onClick={() => handleEditUserModal(u)}
>
Edit
</Button>
<Group noWrap>
{!u.isAdmin && (
<Button
compact
disabled={u.isAdmin}
leftIcon={<RiEdit2Line />}
variant="subtle"
onClick={() => handleEdituserPermissionsModal(u)}
>
Permissions
</Button>
)}
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
@@ -149,6 +178,12 @@ export const UserList = () => {
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
rightSection={<RiEdit2Line />}
onClick={() => handleEditUserModal(u)}
>
Edit profile
</DropdownMenu.Item>
<DropdownMenu.Item
rightSection={
<RiDeleteBin2Line color="var(--danger-color)" />