diff --git a/server/controllers/users.controller.ts b/server/controllers/users.controller.ts index aaf676987..6e12e9be7 100644 --- a/server/controllers/users.controller.ts +++ b/server/controllers/users.controller.ts @@ -9,8 +9,8 @@ const getUserDetail = async ( req: TypedRequest, res: Response ) => { - const { id } = req.params; - const user = await service.users.findById(req.authUser, { id }); + const { userId } = req.params; + const user = await service.users.findById(req.authUser, { id: userId }); const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] }); return res.status(success.statusCode).json(getSuccessResponse(success)); }; diff --git a/server/middleware/authenticate-server-admin.ts b/server/middleware/authenticate-server-admin.ts new file mode 100644 index 000000000..a2b468486 --- /dev/null +++ b/server/middleware/authenticate-server-admin.ts @@ -0,0 +1,40 @@ +import { ServerPermission, ServerPermissionType } from '@prisma/client'; +import { NextFunction, Request, Response } from 'express'; + +export const authenticateServerAdmin = ( + req: Request, + res: Response, + next: NextFunction +) => { + if (!req.params.serverId) { + return res.status(403).json({ + error: { + message: 'Server id is required.', + path: req.path, + }, + response: 'Error', + statusCode: 403, + }); + } + + if (req.authUser.isAdmin || req.authUser.isSuperAdmin) { + return next(); + } + + const permission = req.authUser.serverPermissions.find( + (p: ServerPermission) => p.serverId === req.params.serverId + )?.type; + + if (permission !== ServerPermissionType.ADMIN) { + return res.status(403).json({ + error: { + message: 'This action requires "Admin" server permissions.', + path: req.path, + }, + response: 'Error', + statusCode: 403, + }); + } + + return next(); +}; diff --git a/server/middleware/authenticate-server-editor.ts b/server/middleware/authenticate-server-editor.ts new file mode 100644 index 000000000..51906dcfb --- /dev/null +++ b/server/middleware/authenticate-server-editor.ts @@ -0,0 +1,43 @@ +import { ServerPermission, ServerPermissionType } from '@prisma/client'; +import { NextFunction, Request, Response } from 'express'; + +export const authenticateServerEditor = ( + req: Request, + res: Response, + next: NextFunction +) => { + if (!req.params.serverId) { + return res.status(403).json({ + error: { + message: 'Server id is required.', + path: req.path, + }, + response: 'Error', + statusCode: 403, + }); + } + + if (req.authUser.isAdmin || req.authUser.isSuperAdmin) { + return next(); + } + + const permission = req.authUser.serverPermissions.find( + (p: ServerPermission) => p.serverId === req.params.serverId + )?.type; + + if ( + permission !== ServerPermissionType.EDITOR && + permission !== ServerPermissionType.ADMIN + ) { + return res.status(403).json({ + error: { + message: 'This action requires "Editor" server permissions.', + path: req.path, + }, + response: 'Error', + statusCode: 403, + }); + } + + return next(); +}; diff --git a/server/middleware/authenticate-server-viewer.ts b/server/middleware/authenticate-server-viewer.ts new file mode 100644 index 000000000..e70a70442 --- /dev/null +++ b/server/middleware/authenticate-server-viewer.ts @@ -0,0 +1,40 @@ +import { ServerPermission, ServerPermissionType } from '@prisma/client'; +import { NextFunction, Request, Response } from 'express'; + +export const authenticateServerViewer = ( + req: Request, + res: Response, + next: NextFunction +) => { + if (!req.params.serverId) { + return res.status(403).json({ + error: { + message: 'Server id is required.', + path: req.path, + }, + response: 'Error', + statusCode: 403, + }); + } + + if (req.authUser.isAdmin || req.authUser.isSuperAdmin) { + return next(); + } + + const permission = req.authUser.serverPermissions.find( + (p: ServerPermission) => p.serverId === req.params.serverId + )?.type; + + if (permission === undefined) { + return res.status(403).json({ + error: { + message: 'This action requires "Viewer" server permissions.', + path: req.path, + }, + response: 'Error', + statusCode: 403, + }); + } + + return next(); +}; diff --git a/server/middleware/authenticate-super-admin.ts b/server/middleware/authenticate-super-admin.ts new file mode 100644 index 000000000..28a97a40c --- /dev/null +++ b/server/middleware/authenticate-super-admin.ts @@ -0,0 +1,20 @@ +import { NextFunction, Request, Response } from 'express'; + +export const authenticateSuperAdmin = ( + req: Request, + res: Response, + next: NextFunction +) => { + if (!req.authUser.isSuperAdmin) { + return res.status(403).json({ + error: { + message: 'This action requires an administrator account.', + path: req.path, + }, + response: 'Error', + statusCode: 403, + }); + } + + return next(); +}; diff --git a/server/middleware/index.ts b/server/middleware/index.ts index 57d4d9525..f95708a57 100644 --- a/server/middleware/index.ts +++ b/server/middleware/index.ts @@ -1,3 +1,7 @@ export * from './error-handler'; export * from './authenticate'; export * from './authenticate-admin'; +export * from './authenticate-super-admin'; +export * from './authenticate-server-admin'; +export * from './authenticate-server-editor'; +export * from './authenticate-server-viewer'; diff --git a/server/routes/servers.route.ts b/server/routes/servers.route.ts index 1a92f22cf..186d8e172 100644 --- a/server/routes/servers.route.ts +++ b/server/routes/servers.route.ts @@ -1,6 +1,9 @@ import express, { Router } from 'express'; import { controller } from '@controllers/index'; import { authenticateAdmin } from '@middleware/authenticate-admin'; +import { authenticateServerAdmin } from '@middleware/authenticate-server-admin'; +import { authenticateServerEditor } from '@middleware/authenticate-server-editor'; +import { authenticateServerViewer } from '@middleware/authenticate-server-viewer'; import { service } from '@services/index'; import { validateRequest, validation } from '@validations/index'; @@ -25,7 +28,7 @@ router controller.servers.getServerDetail ) .patch( - authenticateAdmin, + authenticateServerAdmin, validateRequest(validation.servers.update), controller.servers.updateServer ) @@ -38,7 +41,7 @@ router router .route('/:serverId/refresh') .get( - authenticateAdmin, + authenticateServerEditor, validateRequest(validation.servers.refresh), controller.servers.refreshServer ); @@ -46,23 +49,23 @@ router router .route('/:serverId/scan') .post( + authenticateServerAdmin, validateRequest(validation.servers.scan), - authenticateAdmin, controller.servers.quickScanServer ); router .route('/:serverId/full-scan') .post( + authenticateServerAdmin, validateRequest(validation.servers.scan), - authenticateAdmin, controller.servers.fullScanServer ); router .route('/:serverId/url') .post( - authenticateAdmin, + authenticateServerEditor, validateRequest(validation.servers.createUrl), controller.servers.createServerUrl ); @@ -75,7 +78,7 @@ router.param('urlId', async (_req, _res, next, urlId) => { router .route('/:serverId/url/:urlId') .delete( - authenticateAdmin, + authenticateServerEditor, validateRequest(validation.servers.deleteUrl), controller.servers.deleteServerUrl ); @@ -83,6 +86,7 @@ router router .route('/:serverId/url/:urlId/enable') .post( + authenticateServerViewer, validateRequest(validation.servers.enableUrl), controller.servers.enableServerUrl ); @@ -90,6 +94,7 @@ router router .route('/:serverId/url/:urlId/disable') .post( + authenticateServerViewer, validateRequest(validation.servers.disableUrl), controller.servers.disableServerUrl ); @@ -102,7 +107,7 @@ router.param('folderId', async (_req, _res, next, folderId) => { router .route('/:serverId/folder/:folderId') .delete( - authenticateAdmin, + authenticateServerAdmin, validateRequest(validation.servers.deleteFolder), controller.servers.deleteServerFolder ); @@ -110,6 +115,7 @@ router router .route('/:serverId/folder/:folderId/enable') .post( + authenticateServerAdmin, validateRequest(validation.servers.enableFolder), controller.servers.enableServerFolder ); @@ -117,6 +123,7 @@ router router .route('/:serverId/folder/:folderId/disable') .post( + authenticateServerAdmin, validateRequest(validation.servers.disableFolder), controller.servers.disableServerFolder ); diff --git a/server/services/users.service.ts b/server/services/users.service.ts index c4a5a466b..7d6f4bc19 100644 --- a/server/services/users.service.ts +++ b/server/services/users.service.ts @@ -11,7 +11,7 @@ const findById = async (user: AuthUser, options: { id: string }) => { } const uniqueUser = await prisma.user.findUnique({ - include: { serverFolderPermissions: true }, + include: { serverFolderPermissions: true, serverPermissions: true }, where: { id }, }); @@ -61,7 +61,7 @@ const createUser = async ( const createdUser = await prisma.user.create({ data: { deviceId: `${username}_${randomString(10)}`, - enabled: false, + enabled: true, isAdmin, password: hashedPassword, username, diff --git a/src/renderer/features/auth/queries/use-login.ts b/src/renderer/features/auth/queries/use-login.ts index 8cf6afd7e..339b7115c 100644 --- a/src/renderer/features/auth/queries/use-login.ts +++ b/src/renderer/features/auth/queries/use-login.ts @@ -19,6 +19,7 @@ export const useLogin = ( const props = { accessToken: res.data.accessToken, permissions: { + id: res.data.id, isAdmin: res.data.isAdmin, isSuperAdmin: res.data.isSuperAdmin, username: res.data.username, diff --git a/src/renderer/features/servers/components/server-list-item.tsx b/src/renderer/features/servers/components/server-list-item.tsx index e6b5f1b15..9d3bba90e 100644 --- a/src/renderer/features/servers/components/server-list-item.tsx +++ b/src/renderer/features/servers/components/server-list-item.tsx @@ -18,7 +18,7 @@ import { useEnableServerUrl } from '@/renderer/features/servers/mutations/use-en import { useFullScan } from '@/renderer/features/servers/mutations/use-full-scan'; import { useQuickScan } from '@/renderer/features/servers/mutations/use-quick-scan'; import { useUpdateServer } from '@/renderer/features/servers/mutations/use-update-server'; -import { usePermissions } from '@/renderer/features/shared'; +import { ServerPermission, usePermissions } from '@/renderer/features/shared'; import { useTaskList } from '@/renderer/features/tasks'; import { useAuthStore } from '@/renderer/store'; import { Font } from '@/renderer/styles'; @@ -34,6 +34,10 @@ export const ServerListItem = ({ server }: ServerListItemProps) => { const [addCredential, addCredentialHandlers] = useDisclosure(false); const permissions = usePermissions(); + const serverPermission = permissions[ + server.id as keyof typeof permissions + ] as ServerPermission; + const updateServer = useUpdateServer(); const enableServerUrl = useEnableServerUrl(); const disableServerUrl = useDisableServerUrl(); @@ -148,23 +152,27 @@ export const ServerListItem = ({ server }: ServerListItemProps) => { Server details - - + {serverPermission >= ServerPermission.ADMIN && ( + + )} + {serverPermission >= ServerPermission.EDITOR && ( + + )} } @@ -179,50 +187,58 @@ export const ServerListItem = ({ server }: ServerListItemProps) => { URL - Username + {serverPermission >= ServerPermission.EDITOR && ( + Username + )} {server.url} - {server.username} + {serverPermission >= ServerPermission.EDITOR && ( + {server.username} + )} - - - + {serverPermission >= ServerPermission.ADMIN && ( + + + + )} )} + {server.serverFolders?.map((folder) => ( + {folder.name} - - {folder.name} + <> + + handleToggleFolder(folder.id, !e.currentTarget.checked) + } + /> + {serverPermission >= ServerPermission.ADMIN && ( + + )} + - - handleToggleFolder(folder.id, !e.currentTarget.checked) - } - /> ))} + {addCredential ? ( { {serverCredentials?.map((credential) => ( + {credential.username} + + handleToggleCredential( + credential.id, + !e.currentTarget.checked + ) + } + /> - {credential.username} - - handleToggleCredential( - credential.id, - !e.currentTarget.checked - ) - } - /> ))} - {serverUrl.url} + + handleToggleUrl( + serverUrl.id, + !e.currentTarget.checked + ) + } + /> + {serverPermission >= ServerPermission.EDITOR && ( + + )} - - handleToggleUrl(serverUrl.id, !e.currentTarget.checked) - } - /> ))} - + {serverPermission >= ServerPermission.EDITOR && ( + + )} )} - - - Require user credentials - - toggleRequiredCredential(e.currentTarget.checked) - } - /> - - - - + {serverPermission >= ServerPermission.ADMIN && ( + + + Require user credentials + + toggleRequiredCredential(e.currentTarget.checked) + } + /> + + {permissions.isSuperAdmin && ( + <> + + + + )} + + )} ); diff --git a/src/renderer/features/shared/hooks/use-permissions.ts b/src/renderer/features/shared/hooks/use-permissions.ts index 175df1ad9..74670560c 100644 --- a/src/renderer/features/shared/hooks/use-permissions.ts +++ b/src/renderer/features/shared/hooks/use-permissions.ts @@ -1,27 +1,63 @@ import { useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/renderer/api/query-keys'; +import { ServerListResponse } from '@/renderer/api/servers.api'; +import { ServerPermissionType } from '@/renderer/api/types'; +import { UserDetailResponse } from '@/renderer/api/users.api'; import { useAuthStore } from '@/renderer/store'; +export enum ServerPermission { + VIEWER = 0, + EDITOR = 1, + ADMIN = 2, +} + +const SERVER_PERMISSION_MAP = { + [ServerPermissionType.VIEWER]: 0, + [ServerPermissionType.EDITOR]: 1, + [ServerPermissionType.ADMIN]: 2, +}; + export const usePermissions = () => { + const queryClient = useQueryClient(); + const userId = useAuthStore((state) => state.permissions.id); const permissions = useAuthStore((state) => state.permissions); - const permissionSet = useMemo(() => { - const set = { - createServer: permissions.isAdmin, - createServerCredential: true, - createServerUrl: permissions.isAdmin, - deleteServer: permissions.isAdmin, - deleteServerCredential: true, - deleteServerFolder: permissions.isAdmin, - deleteServerUrl: permissions.isAdmin, - editServer: permissions.isAdmin, - editServerFolder: permissions.isAdmin, - isAdmin: permissions.isAdmin, - isSuperAdmin: permissions.isSuperAdmin, - manageUsers: permissions.isAdmin, - }; + const user = queryClient.getQueryData( + queryKeys.users.detail(userId) + ); - return set; - }, [permissions]); + const servers = queryClient.getQueryData( + queryKeys.servers.list() + ); + + const permissionSet: { [key: string]: any } = useMemo(() => { + const serverPermissions: { [key: string]: ServerPermission } = {}; + + servers?.data?.forEach((server) => { + const permission = user?.data?.serverPermissions?.find( + (p) => p.serverId === server.id + )?.type; + + serverPermissions[server.id] = + permissions.isAdmin || permissions.isSuperAdmin + ? ServerPermission.ADMIN + : permission + ? SERVER_PERMISSION_MAP[permission] + : -1; + }); + + return { + isAdmin: permissions.isAdmin || permissions.isSuperAdmin, + isSuperAdmin: permissions.isSuperAdmin, + ...serverPermissions, + }; + }, [ + permissions.isAdmin, + permissions.isSuperAdmin, + servers?.data, + user?.data?.serverPermissions, + ]); return permissionSet; }; diff --git a/src/renderer/features/users/queries/get-user-detail.ts b/src/renderer/features/users/queries/get-user-detail.ts index 53ce9d8b1..5295b3852 100644 --- a/src/renderer/features/users/queries/get-user-detail.ts +++ b/src/renderer/features/users/queries/get-user-detail.ts @@ -1,16 +1,20 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '@/renderer/api'; import { queryKeys } from '@/renderer/api/query-keys'; +import { UserDetailResponse } from '@/renderer/api/users.api'; +import { QueryOptions } from '@/renderer/lib/react-query'; -export const useUserDetail = (options: { userId: string }) => { - const { data, error, isLoading } = useQuery({ - queryFn: () => api.users.getUserDetail({ userId: options.userId }), - queryKey: queryKeys.users.detail(options.userId), +export const useUserDetail = ( + q: { userId: string }, + options?: QueryOptions +) => { + const query = useQuery({ + cacheTime: Infinity, + queryFn: () => api.users.getUserDetail({ userId: q.userId }), + queryKey: queryKeys.users.detail(q.userId), + staleTime: 1000 * 60 * 60 * 24, + ...options, }); - return { - data, - error, - isLoading, - }; + return query; }; diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 4939e6910..951e7190c 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -8,6 +8,7 @@ export interface AuthState { accessToken: string; currentServer: Server | null; permissions: { + id: string; isAdmin: boolean; isSuperAdmin: boolean; username: string; @@ -91,11 +92,17 @@ export const useAuthStore = create()( logout: () => { return set({ accessToken: undefined, - permissions: { isAdmin: false, isSuperAdmin: false, username: '' }, + permissions: { + id: '', + isAdmin: false, + isSuperAdmin: false, + username: '', + }, refreshToken: undefined, }); }, permissions: { + id: '', isAdmin: false, isSuperAdmin: false, username: '',