diff --git a/server/controllers/users.controller.ts b/server/controllers/users.controller.ts index c7bc2cae9..aaf676987 100644 --- a/server/controllers/users.controller.ts +++ b/server/controllers/users.controller.ts @@ -25,7 +25,7 @@ const createUser = async ( req: TypedRequest, res: Response ) => { - const user = await service.users.createUser(req.body); + const user = await service.users.createUser(req.authUser, req.body); const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] }); return res.status(success.statusCode).json(getSuccessResponse(success)); }; diff --git a/server/routes/users.route.ts b/server/routes/users.route.ts index 60d72ed7b..2f568f023 100644 --- a/server/routes/users.route.ts +++ b/server/routes/users.route.ts @@ -2,25 +2,48 @@ import express, { Router } from 'express'; import { controller } from '@controllers/index'; import { service } from '@services/index'; import { ApiError } from '@utils/index'; +import { validation } from '@validations/index'; +import { validateRequest } from '@validations/shared.validation'; import { authenticateAdmin } from '../middleware/authenticate-admin'; export const router: Router = express.Router({ mergeParams: true }); router .route('/') - .get(authenticateAdmin, controller.users.getUserList) - .post(authenticateAdmin, controller.users.createUser); + .get( + authenticateAdmin, + validateRequest(validation.users.list), + controller.users.getUserList + ) + .post( + authenticateAdmin, + validateRequest(validation.users.createUser), + controller.users.createUser + ); router.param('userId', async (req, _res, next, userId) => { - await service.users.findById(req.authUser, { id: userId }); + const user = await service.users.findById(req.authUser, { id: userId }); - if (req.authUser.isAdmin || req.authUser.id === userId) { + if (req.authUser.id === userId) { return next(); } - throw ApiError.forbidden('You are not allowed to access this resource'); + // Only superadmins can modify other admins + if (user.isAdmin && !req.authUser.isSuperAdmin) { + throw ApiError.forbidden('You are not authorized to access this resource'); + } + + return next(); }); -router.route('/:userId/update').post(controller.users.updateUser); - -router.route('/:userId/delete').post(controller.users.deleteUser); +router + .route('/:userId') + .get(validateRequest(validation.users.detail), controller.users.getUserDetail) + .patch( + validateRequest(validation.users.updateUser), + controller.users.updateUser + ) + .delete( + validateRequest(validation.users.deleteUser), + controller.users.deleteUser + ); diff --git a/server/services/users.service.ts b/server/services/users.service.ts index 4ec8eca66..c4a5a466b 100644 --- a/server/services/users.service.ts +++ b/server/services/users.service.ts @@ -27,61 +27,82 @@ const findMany = async () => { return users; }; -const createUser = async (options: { - displayName?: string; - password: string; - username: string; -}) => { - const { password, username, displayName } = options; +const createUser = async ( + user: AuthUser, + options: { + displayName?: string; + isAdmin?: boolean; + password: string; + username: string; + } +) => { + const { password, username, displayName, isAdmin } = options; - const [userExists, displayNameExists] = await prisma.$transaction([ - prisma.user.findUnique({ where: { username } }), - prisma.user.findUnique({ where: { displayName } }), - ]); + if (isAdmin && !user.isSuperAdmin) { + throw ApiError.badRequest('You are not authorized to create an admin.'); + } + + const userExists = await prisma.user.findUnique({ where: { username } }); if (userExists) { throw ApiError.conflict('The user already exists.'); } + const displayNameExists = await prisma.user.findUnique({ + where: { displayName }, + }); + if (displayNameExists) { throw ApiError.conflict('The display name already exists.'); } const hashedPassword = await bcrypt.hash(password, 12); - const user = await prisma.user.create({ + const createdUser = await prisma.user.create({ data: { deviceId: `${username}_${randomString(10)}`, enabled: false, + isAdmin, password: hashedPassword, username, }, }); - return user; + return createdUser; }; const deleteUser = async (options: { userId: string }) => { const { userId } = options; - const user = await prisma.user.delete({ where: { id: userId } }); - return user; + const user = await prisma.user.findUnique({ where: { id: userId } }); + + if (!user) { + throw ApiError.notFound('The user does not exist.'); + } + + if (user?.isSuperAdmin) { + throw ApiError.badRequest('You cannot delete a superadmin.'); + } + + await prisma.user.delete({ where: { id: userId } }); }; const updateUser = async ( options: { userId: string }, data: { + displayName?: string; + isAdmin?: boolean; password?: string; username?: string; } ) => { const { userId } = options; - const { username, password } = data; + const { username, password, isAdmin, displayName } = data; const hashedPassword = password && (await bcrypt.hash(password, 12)); const user = await prisma.user.update({ - data: { password: hashedPassword, username }, + data: { displayName, isAdmin, password: hashedPassword, username }, where: { id: userId }, }); diff --git a/server/validations/users.validation.ts b/server/validations/users.validation.ts index 149ce3fca..6acf455f7 100644 --- a/server/validations/users.validation.ts +++ b/server/validations/users.validation.ts @@ -1,6 +1,14 @@ import { z } from 'zod'; import { idValidation } from './shared.validation'; +const noWhiteSpaces = /^\S*$/; + +const list = { + body: z.object({}), + params: z.object({}), + query: z.object({}), +}; + const detail = { body: z.object({}), params: z.object({ ...idValidation('userId') }), @@ -9,9 +17,16 @@ const detail = { const createUser = { body: z.object({ - displayName: z.optional(z.string()), + displayName: z.optional(z.string().min(0).max(100)), + isAdmin: z.optional(z.boolean()), password: z.string().min(6).max(255), - username: z.string().min(2).max(255), + username: z + .string() + .min(2) + .max(30) + .refine((value) => noWhiteSpaces.test(value), { + message: 'No white spaces allowed', + }), }), params: z.object({}), query: z.object({}), @@ -25,9 +40,18 @@ const deleteUser = { const updateUser = { body: z.object({ - displayName: z.optional(z.string().min(2).max(255)), + displayName: z.optional(z.string().min(0).max(100)), + isAdmin: z.optional(z.boolean()), password: z.optional(z.string().min(6).max(255)), - username: z.optional(z.string().min(2).max(255)), + username: z.optional( + z + .string() + .min(2) + .max(30) + .refine((value) => noWhiteSpaces.test(value), { + message: 'No white spaces allowed', + }) + ), }), params: z.object({ ...idValidation('userId') }), query: z.object({}), @@ -37,5 +61,6 @@ export const usersValidation = { createUser, deleteUser, detail, + list, updateUser, }; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index a8a073662..965ff38f9 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -26,7 +26,7 @@ export const queryKeys = { }, users: { detail: (userId: string) => ['users', userId] as const, - list: (params: any) => ['users', 'list', params] as const, + list: (params?: any) => ['users', 'list', params] as const, root: ['users'], }, }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index b731255c7..d8da91700 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -136,10 +136,12 @@ export type RelatedServerPermission = { export type User = { createdAt: string; + displayName?: string; enabled: boolean; flatServerPermissions: string[]; id: string; isAdmin: boolean; + isSuperAdmin: boolean; password?: string; serverFolderPermissions: ServerFolderPermission[]; serverPermissions: ServerPermission[]; diff --git a/src/renderer/api/users.api.ts b/src/renderer/api/users.api.ts index c631d439d..5d48188e1 100644 --- a/src/renderer/api/users.api.ts +++ b/src/renderer/api/users.api.ts @@ -1,4 +1,4 @@ -import { BaseResponse, User } from '@/renderer/api/types'; +import { BaseResponse, NullResponse, User } from '@/renderer/api/types'; import { ax } from '@/renderer/lib/axios'; export type UserDetailResponse = BaseResponse; @@ -9,12 +9,40 @@ const getUserDetail = async (query: { userId: string }) => { return data; }; -const getUserList = async () => { - const { data } = await ax.get('/users'); +const getUserList = async (signal?: AbortSignal) => { + const { data } = await ax.get('/users', { signal }); + return data; +}; + +export type CreateUserBody = { + password: string; + username: string; +}; + +const createUser = async (body: CreateUserBody) => { + const { data } = await ax.post('/users', body); + return data; +}; + +const deleteUser = async (query: { userId: string }) => { + const { data } = await ax.delete(`/users/${query.userId}`); + return data; +}; + +export type UpdateUserBody = Partial; + +const updateUser = async (query: { userId: string }, body: UpdateUserBody) => { + const { data } = await ax.patch( + `/users/${query.userId}`, + body + ); return data; }; export const usersApi = { + createUser, + deleteUser, getUserDetail, getUserList, + updateUser, }; diff --git a/src/renderer/features/shared/hooks/use-permissions.ts b/src/renderer/features/shared/hooks/use-permissions.ts index e98a01cfb..175df1ad9 100644 --- a/src/renderer/features/shared/hooks/use-permissions.ts +++ b/src/renderer/features/shared/hooks/use-permissions.ts @@ -16,6 +16,8 @@ export const usePermissions = () => { editServer: permissions.isAdmin, editServerFolder: permissions.isAdmin, isAdmin: permissions.isAdmin, + isSuperAdmin: permissions.isSuperAdmin, + manageUsers: permissions.isAdmin, }; return set; diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index e009ae141..8cf626a61 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -10,12 +10,15 @@ import { RiSettings2Line, RiEdit2Line, RiUserAddLine, + RiProfileLine, } from 'react-icons/ri'; import { useNavigate } from 'react-router'; import { queryKeys } from '@/renderer/api/query-keys'; import { Button, DropdownMenu, Text } from '@/renderer/components'; import { ServerList, useServerList } from '@/renderer/features/servers'; import { Settings } from '@/renderer/features/settings'; +import { usePermissions } from '@/renderer/features/shared'; +import { UserList } from '@/renderer/features/users'; import { useAuthStore } from '@/renderer/store'; export const AppMenu = () => { @@ -25,6 +28,7 @@ export const AppMenu = () => { const currentServer = useAuthStore((state) => state.currentServer); const setCurrentServer = useAuthStore((state) => state.setCurrentServer); const serverCredentials = useAuthStore((state) => state.serverCredentials); + const permissions = usePermissions(); const { data: servers } = useServerList(); const serverList = @@ -45,7 +49,18 @@ export const AppMenu = () => { children: , exitTransitionDuration: 300, overflow: 'inside', - title: 'Manage servers', + title: 'Manage Servers', + transition: 'slide-down', + }); + }; + + const handleManageUsersModal = () => { + openModal({ + centered: true, + children: , + exitTransitionDuration: 300, + overflow: 'inside', + title: 'Manage Users', transition: 'slide-down', }); }; @@ -120,9 +135,17 @@ export const AppMenu = () => { Settings - }> - Manage users + }> + Edit profile + {permissions.manageUsers && ( + } + onClick={handleManageUsersModal} + > + Manage users + + )} } onClick={handleManageServersModal} diff --git a/src/renderer/features/users/components/add-user-form.tsx b/src/renderer/features/users/components/add-user-form.tsx new file mode 100644 index 000000000..53ed40fa4 --- /dev/null +++ b/src/renderer/features/users/components/add-user-form.tsx @@ -0,0 +1,115 @@ +import { Stack, Group } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useClipboard, useFocusTrap } from '@mantine/hooks'; +import { + Button, + PasswordInput, + TextInput, + Switch, + toast, +} from '@/renderer/components'; +import { usePermissions } from '@/renderer/features/shared'; +import { useCreateUser } from '@/renderer/features/users/mutations/create-user'; +import { randomString } from '@/renderer/utils'; + +interface AddUserFormProps { + onCancel: () => void; +} + +export const AddUserForm = ({ onCancel }: AddUserFormProps) => { + const permissions = usePermissions(); + const focusTrapRef = useFocusTrap(true); + const clipboard = useClipboard({ timeout: 1000 }); + + const form = useForm({ + initialValues: { + displayName: '', + isAdmin: false, + password: '', + username: '', + }, + }); + + const handleGeneratePassword = () => { + const pass = randomString(); + form.setFieldValue('password', pass); + clipboard.copy(pass); + toast.info({ message: 'Password copied to clipboard' }); + }; + + const createUserMutation = useCreateUser(); + + const handleAddUser = form.onSubmit((values) => { + const body = { + ...values, + displayName: values.displayName || undefined, + }; + + createUserMutation.mutate( + { body }, + { + onError: (err) => + toast.error({ message: err.response?.data?.error?.message }), + onSuccess: () => { + toast.success({ message: 'User created' }); + onCancel(); + }, + } + ); + }); + + return ( +
+ + + + + + {permissions.isSuperAdmin ? ( + + Admin + + + ) : ( + + )} + + + + + + + + +
+ ); +}; diff --git a/src/renderer/features/users/components/edit-user-form.tsx b/src/renderer/features/users/components/edit-user-form.tsx new file mode 100644 index 000000000..6dd9980f2 --- /dev/null +++ b/src/renderer/features/users/components/edit-user-form.tsx @@ -0,0 +1,122 @@ +import { Stack, Group } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useClipboard, useFocusTrap } from '@mantine/hooks'; +import { User } from '@/renderer/api/types'; +import { + Button, + PasswordInput, + TextInput, + Switch, + toast, +} from '@/renderer/components'; +import { usePermissions } from '@/renderer/features/shared'; +import { useUpdateUser } from '@/renderer/features/users/mutations/update-user'; +import { randomString } from '@/renderer/utils'; + +interface AddUserFormProps { + onCancel: () => void; + user?: User; +} + +export const EditUserForm = ({ user, onCancel }: AddUserFormProps) => { + const permissions = usePermissions(); + const focusTrapRef = useFocusTrap(true); + const clipboard = useClipboard({ timeout: 1000 }); + + const form = useForm({ + initialValues: { + displayName: user?.displayName || '', + isAdmin: user?.isAdmin || false, + password: '', + username: user?.username || '', + }, + }); + + const handleGeneratePassword = () => { + const pass = randomString(); + form.setFieldValue('password', pass); + clipboard.copy(pass); + toast.info({ message: 'Password copied to clipboard' }); + }; + + const updateUserMutation = useUpdateUser(); + + const handleUpdateUser = form.onSubmit((values) => { + if (!user) return; + + const body = { + ...values, + displayName: values.displayName || undefined, + password: values.password || undefined, + }; + + updateUserMutation.mutate( + { + body, + query: { userId: user.id }, + }, + { + onError: (err) => + toast.error({ message: err.response?.data?.error?.message }), + onSuccess: () => { + toast.success({ message: 'User updated' }); + onCancel(); + }, + } + ); + }); + + return ( +
+ + + + + + {permissions.isAdmin && !user?.isSuperAdmin ? ( + + Admin + + + ) : ( + + )} + + + + + + + + +
+ ); +}; + +EditUserForm.defaultProps = { + user: undefined, +}; diff --git a/src/renderer/features/users/components/user-list.tsx b/src/renderer/features/users/components/user-list.tsx new file mode 100644 index 000000000..c7b7fd16d --- /dev/null +++ b/src/renderer/features/users/components/user-list.tsx @@ -0,0 +1,128 @@ +import { Avatar, Group, Stack } from '@mantine/core'; +import { openContextModal } from '@mantine/modals'; +import { RiAdminLine, RiDeleteBin2Line, RiEdit2Line } from 'react-icons/ri'; +import { User } from '@/renderer/api/types'; +import { + Button, + ContextModalVars, + Text, + toast, + Tooltip, +} from '@/renderer/components'; +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 { useDeleteUser } from '../mutations/delete-user'; +import { useUserList } from '../queries/get-user-list'; + +export const UserList = () => { + const permissions = usePermissions(); + const { data: users } = useUserList(); + + const handleAddUserModal = () => { + openContextModal({ + centered: true, + exitTransitionDuration: 300, + innerProps: { + modalBody: (vars: ContextModalVars) => ( + vars.context.closeModal(vars.id)} /> + ), + }, + modal: 'base', + overflow: 'inside', + title: 'Add User', + transition: 'slide-down', + }); + }; + + const handleEditUserModal = (user: User) => { + openContextModal({ + centered: true, + exitTransitionDuration: 300, + innerProps: { + modalBody: (vars: ContextModalVars) => ( + vars.context.closeModal(vars.id)} + /> + ), + }, + modal: 'base', + overflow: 'inside', + title: `Edit User`, + transition: 'slide-down', + }); + }; + + const deleteUserMutation = useDeleteUser(); + + const handleDeleteUser = (user: User) => { + deleteUserMutation.mutate( + { query: { userId: user.id } }, + { + onError: (err) => + toast.error({ message: err.response?.data?.error?.message }), + onSuccess: () => + toast.success({ + message: `${user.username} was successfully deleted.`, + title: 'User deleted', + }), + } + ); + }; + + return ( + + + + + {users?.data?.map((u) => ( + + + + + {u.displayName ? u.displayName : u.username}{' '} + {u.isAdmin && ( + + + + + + )} + + + + + + + + ))} + + ); +}; diff --git a/src/renderer/features/users/index.ts b/src/renderer/features/users/index.ts new file mode 100644 index 000000000..d07e03bc0 --- /dev/null +++ b/src/renderer/features/users/index.ts @@ -0,0 +1,7 @@ +export * from './mutations/create-user'; +export * from './mutations/delete-user'; +export * from './mutations/update-user'; +export * from './components/add-user-form'; +export * from './components/user-list'; +export * from './queries/get-user-detail'; +export * from './queries/get-user-list'; diff --git a/src/renderer/features/users/mutations/create-user.ts b/src/renderer/features/users/mutations/create-user.ts new file mode 100644 index 000000000..0fc656ead --- /dev/null +++ b/src/renderer/features/users/mutations/create-user.ts @@ -0,0 +1,24 @@ +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 } from '@/renderer/api/types'; +import { CreateUserBody, UserDetailResponse } from '@/renderer/api/users.api'; + +export const useCreateUser = () => { + const queryClient = useQueryClient(); + + const mutation = useMutation< + UserDetailResponse, + AxiosError, + { body: CreateUserBody }, + undefined + >({ + mutationFn: ({ body }) => api.users.createUser(body), + onSuccess: () => { + queryClient.invalidateQueries(queryKeys.users.list()); + }, + }); + + return mutation; +}; diff --git a/src/renderer/features/users/mutations/delete-user.ts b/src/renderer/features/users/mutations/delete-user.ts new file mode 100644 index 000000000..c69bc1f8b --- /dev/null +++ b/src/renderer/features/users/mutations/delete-user.ts @@ -0,0 +1,45 @@ +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'; +import { UserListResponse } from '@/renderer/api/users.api'; + +export const useDeleteUser = () => { + const queryClient = useQueryClient(); + + const mutation = useMutation< + NullResponse, + AxiosError, + { query: { userId: string } }, + { previous: UserListResponse | undefined } + >({ + mutationFn: ({ query }) => api.users.deleteUser({ userId: query.userId }), + onError: (_err, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.users.list(), context.previous); + } + }, + onMutate: async (variables) => { + const queryKey = queryKeys.users.list(); + + await queryClient.cancelQueries(queryKey); + const previous = queryClient.getQueryData(queryKey); + + if (!previous) return undefined; + + const data = previous.data.filter( + (user) => user.id !== variables.query.userId + ); + + queryClient.setQueryData(queryKey, { ...previous, data }); + + return { previous }; + }, + onSettled: () => { + queryClient.invalidateQueries(queryKeys.users.list()); + }, + }); + + return mutation; +}; diff --git a/src/renderer/features/users/mutations/update-user.ts b/src/renderer/features/users/mutations/update-user.ts new file mode 100644 index 000000000..ae49a28d0 --- /dev/null +++ b/src/renderer/features/users/mutations/update-user.ts @@ -0,0 +1,45 @@ +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 } from '@/renderer/api/types'; +import { + UpdateUserBody, + UserDetailResponse, + UserListResponse, +} from '@/renderer/api/users.api'; + +export const useUpdateUser = () => { + const queryClient = useQueryClient(); + + const mutation = useMutation< + UserDetailResponse, + AxiosError, + { body: UpdateUserBody; query: { userId: string } }, + { previous: UserListResponse | undefined } + >({ + mutationFn: ({ query, body }) => api.users.updateUser(query, body), + onMutate: async (variables) => { + const queryKey = queryKeys.users.list(); + + await queryClient.cancelQueries(queryKey); + const previous = queryClient.getQueryData(queryKey); + + if (!previous) return undefined; + + const data = previous.data.map((user) => { + if (user.id !== variables.query.userId) return user; + return { ...user, username: variables.body.username }; + }); + + queryClient.setQueryData(queryKey, { ...previous, data }); + + return { previous }; + }, + onSuccess: () => { + queryClient.invalidateQueries(queryKeys.users.list()); + }, + }); + + return mutation; +}; diff --git a/src/renderer/features/users/queries/use-user-detail.ts b/src/renderer/features/users/queries/get-user-detail.ts similarity index 100% rename from src/renderer/features/users/queries/use-user-detail.ts rename to src/renderer/features/users/queries/get-user-detail.ts diff --git a/src/renderer/features/users/queries/get-user-list.ts b/src/renderer/features/users/queries/get-user-list.ts new file mode 100644 index 000000000..24ff5e3eb --- /dev/null +++ b/src/renderer/features/users/queries/get-user-list.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/renderer/api'; +import { queryKeys } from '@/renderer/api/query-keys'; +import { UserListResponse } from '@/renderer/api/users.api'; +import { QueryOptions } from '@/renderer/lib/react-query'; + +export const useUserList = (options?: QueryOptions) => { + const query = useQuery({ + queryFn: () => api.users.getUserList(), + queryKey: queryKeys.users.list(), + ...options, + }); + + return query; +}; diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index af25d27c2..4939e6910 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -9,6 +9,7 @@ export interface AuthState { currentServer: Server | null; permissions: { isAdmin: boolean; + isSuperAdmin: boolean; username: string; }; refreshToken: string; @@ -90,12 +91,13 @@ export const useAuthStore = create()( logout: () => { return set({ accessToken: undefined, - permissions: { isAdmin: false, username: '' }, + permissions: { isAdmin: false, isSuperAdmin: false, username: '' }, refreshToken: undefined, }); }, permissions: { isAdmin: false, + isSuperAdmin: false, username: '', }, refreshToken: '',