mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
Add initial users manager
This commit is contained in:
@@ -25,7 +25,7 @@ const createUser = async (
|
|||||||
req: TypedRequest<typeof validation.users.createUser>,
|
req: TypedRequest<typeof validation.users.createUser>,
|
||||||
res: Response
|
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] });
|
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
|
||||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,25 +2,48 @@ import express, { Router } from 'express';
|
|||||||
import { controller } from '@controllers/index';
|
import { controller } from '@controllers/index';
|
||||||
import { service } from '@services/index';
|
import { service } from '@services/index';
|
||||||
import { ApiError } from '@utils/index';
|
import { ApiError } from '@utils/index';
|
||||||
|
import { validation } from '@validations/index';
|
||||||
|
import { validateRequest } from '@validations/shared.validation';
|
||||||
import { authenticateAdmin } from '../middleware/authenticate-admin';
|
import { authenticateAdmin } from '../middleware/authenticate-admin';
|
||||||
|
|
||||||
export const router: Router = express.Router({ mergeParams: true });
|
export const router: Router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
router
|
router
|
||||||
.route('/')
|
.route('/')
|
||||||
.get(authenticateAdmin, controller.users.getUserList)
|
.get(
|
||||||
.post(authenticateAdmin, controller.users.createUser);
|
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) => {
|
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();
|
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')
|
||||||
router.route('/:userId/delete').post(controller.users.deleteUser);
|
.get(validateRequest(validation.users.detail), controller.users.getUserDetail)
|
||||||
|
.patch(
|
||||||
|
validateRequest(validation.users.updateUser),
|
||||||
|
controller.users.updateUser
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
validateRequest(validation.users.deleteUser),
|
||||||
|
controller.users.deleteUser
|
||||||
|
);
|
||||||
|
|||||||
@@ -27,61 +27,82 @@ const findMany = async () => {
|
|||||||
return users;
|
return users;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createUser = async (options: {
|
const createUser = async (
|
||||||
displayName?: string;
|
user: AuthUser,
|
||||||
password: string;
|
options: {
|
||||||
username: string;
|
displayName?: string;
|
||||||
}) => {
|
isAdmin?: boolean;
|
||||||
const { password, username, displayName } = options;
|
password: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { password, username, displayName, isAdmin } = options;
|
||||||
|
|
||||||
const [userExists, displayNameExists] = await prisma.$transaction([
|
if (isAdmin && !user.isSuperAdmin) {
|
||||||
prisma.user.findUnique({ where: { username } }),
|
throw ApiError.badRequest('You are not authorized to create an admin.');
|
||||||
prisma.user.findUnique({ where: { displayName } }),
|
}
|
||||||
]);
|
|
||||||
|
const userExists = await prisma.user.findUnique({ where: { username } });
|
||||||
|
|
||||||
if (userExists) {
|
if (userExists) {
|
||||||
throw ApiError.conflict('The user already exists.');
|
throw ApiError.conflict('The user already exists.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayNameExists = await prisma.user.findUnique({
|
||||||
|
where: { displayName },
|
||||||
|
});
|
||||||
|
|
||||||
if (displayNameExists) {
|
if (displayNameExists) {
|
||||||
throw ApiError.conflict('The display name already exists.');
|
throw ApiError.conflict('The display name already exists.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const createdUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
deviceId: `${username}_${randomString(10)}`,
|
deviceId: `${username}_${randomString(10)}`,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
isAdmin,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
username,
|
username,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
return createdUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUser = async (options: { userId: string }) => {
|
const deleteUser = async (options: { userId: string }) => {
|
||||||
const { userId } = options;
|
const { userId } = options;
|
||||||
|
|
||||||
const user = await prisma.user.delete({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
return user;
|
|
||||||
|
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 (
|
const updateUser = async (
|
||||||
options: { userId: string },
|
options: { userId: string },
|
||||||
data: {
|
data: {
|
||||||
|
displayName?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const { userId } = options;
|
const { userId } = options;
|
||||||
const { username, password } = data;
|
const { username, password, isAdmin, displayName } = data;
|
||||||
|
|
||||||
const hashedPassword = password && (await bcrypt.hash(password, 12));
|
const hashedPassword = password && (await bcrypt.hash(password, 12));
|
||||||
|
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
data: { password: hashedPassword, username },
|
data: { displayName, isAdmin, password: hashedPassword, username },
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { idValidation } from './shared.validation';
|
import { idValidation } from './shared.validation';
|
||||||
|
|
||||||
|
const noWhiteSpaces = /^\S*$/;
|
||||||
|
|
||||||
|
const list = {
|
||||||
|
body: z.object({}),
|
||||||
|
params: z.object({}),
|
||||||
|
query: z.object({}),
|
||||||
|
};
|
||||||
|
|
||||||
const detail = {
|
const detail = {
|
||||||
body: z.object({}),
|
body: z.object({}),
|
||||||
params: z.object({ ...idValidation('userId') }),
|
params: z.object({ ...idValidation('userId') }),
|
||||||
@@ -9,9 +17,16 @@ const detail = {
|
|||||||
|
|
||||||
const createUser = {
|
const createUser = {
|
||||||
body: z.object({
|
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),
|
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({}),
|
params: z.object({}),
|
||||||
query: z.object({}),
|
query: z.object({}),
|
||||||
@@ -25,9 +40,18 @@ const deleteUser = {
|
|||||||
|
|
||||||
const updateUser = {
|
const updateUser = {
|
||||||
body: z.object({
|
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)),
|
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') }),
|
params: z.object({ ...idValidation('userId') }),
|
||||||
query: z.object({}),
|
query: z.object({}),
|
||||||
@@ -37,5 +61,6 @@ export const usersValidation = {
|
|||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
detail,
|
detail,
|
||||||
|
list,
|
||||||
updateUser,
|
updateUser,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
detail: (userId: string) => ['users', userId] as const,
|
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'],
|
root: ['users'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -136,10 +136,12 @@ export type RelatedServerPermission = {
|
|||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
displayName?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
flatServerPermissions: string[];
|
flatServerPermissions: string[];
|
||||||
id: string;
|
id: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
serverFolderPermissions: ServerFolderPermission[];
|
serverFolderPermissions: ServerFolderPermission[];
|
||||||
serverPermissions: ServerPermission[];
|
serverPermissions: ServerPermission[];
|
||||||
|
|||||||
@@ -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';
|
import { ax } from '@/renderer/lib/axios';
|
||||||
|
|
||||||
export type UserDetailResponse = BaseResponse<User>;
|
export type UserDetailResponse = BaseResponse<User>;
|
||||||
@@ -9,12 +9,40 @@ const getUserDetail = async (query: { userId: string }) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserList = async () => {
|
const getUserList = async (signal?: AbortSignal) => {
|
||||||
const { data } = await ax.get<UserListResponse>('/users');
|
const { data } = await ax.get<UserListResponse>('/users', { signal });
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUserBody = {
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUser = async (body: CreateUserBody) => {
|
||||||
|
const { data } = await ax.post<UserDetailResponse>('/users', body);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = async (query: { userId: string }) => {
|
||||||
|
const { data } = await ax.delete<NullResponse>(`/users/${query.userId}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateUserBody = Partial<CreateUserBody>;
|
||||||
|
|
||||||
|
const updateUser = async (query: { userId: string }, body: UpdateUserBody) => {
|
||||||
|
const { data } = await ax.patch<UserDetailResponse>(
|
||||||
|
`/users/${query.userId}`,
|
||||||
|
body
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
getUserDetail,
|
getUserDetail,
|
||||||
getUserList,
|
getUserList,
|
||||||
|
updateUser,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const usePermissions = () => {
|
|||||||
editServer: permissions.isAdmin,
|
editServer: permissions.isAdmin,
|
||||||
editServerFolder: permissions.isAdmin,
|
editServerFolder: permissions.isAdmin,
|
||||||
isAdmin: permissions.isAdmin,
|
isAdmin: permissions.isAdmin,
|
||||||
|
isSuperAdmin: permissions.isSuperAdmin,
|
||||||
|
manageUsers: permissions.isAdmin,
|
||||||
};
|
};
|
||||||
|
|
||||||
return set;
|
return set;
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import {
|
|||||||
RiSettings2Line,
|
RiSettings2Line,
|
||||||
RiEdit2Line,
|
RiEdit2Line,
|
||||||
RiUserAddLine,
|
RiUserAddLine,
|
||||||
|
RiProfileLine,
|
||||||
} from 'react-icons/ri';
|
} from 'react-icons/ri';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { queryKeys } from '@/renderer/api/query-keys';
|
import { queryKeys } from '@/renderer/api/query-keys';
|
||||||
import { Button, DropdownMenu, Text } from '@/renderer/components';
|
import { Button, DropdownMenu, Text } from '@/renderer/components';
|
||||||
import { ServerList, useServerList } from '@/renderer/features/servers';
|
import { ServerList, useServerList } from '@/renderer/features/servers';
|
||||||
import { Settings } from '@/renderer/features/settings';
|
import { Settings } from '@/renderer/features/settings';
|
||||||
|
import { usePermissions } from '@/renderer/features/shared';
|
||||||
|
import { UserList } from '@/renderer/features/users';
|
||||||
import { useAuthStore } from '@/renderer/store';
|
import { useAuthStore } from '@/renderer/store';
|
||||||
|
|
||||||
export const AppMenu = () => {
|
export const AppMenu = () => {
|
||||||
@@ -25,6 +28,7 @@ export const AppMenu = () => {
|
|||||||
const currentServer = useAuthStore((state) => state.currentServer);
|
const currentServer = useAuthStore((state) => state.currentServer);
|
||||||
const setCurrentServer = useAuthStore((state) => state.setCurrentServer);
|
const setCurrentServer = useAuthStore((state) => state.setCurrentServer);
|
||||||
const serverCredentials = useAuthStore((state) => state.serverCredentials);
|
const serverCredentials = useAuthStore((state) => state.serverCredentials);
|
||||||
|
const permissions = usePermissions();
|
||||||
const { data: servers } = useServerList();
|
const { data: servers } = useServerList();
|
||||||
|
|
||||||
const serverList =
|
const serverList =
|
||||||
@@ -45,7 +49,18 @@ export const AppMenu = () => {
|
|||||||
children: <ServerList />,
|
children: <ServerList />,
|
||||||
exitTransitionDuration: 300,
|
exitTransitionDuration: 300,
|
||||||
overflow: 'inside',
|
overflow: 'inside',
|
||||||
title: 'Manage servers',
|
title: 'Manage Servers',
|
||||||
|
transition: 'slide-down',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageUsersModal = () => {
|
||||||
|
openModal({
|
||||||
|
centered: true,
|
||||||
|
children: <UserList />,
|
||||||
|
exitTransitionDuration: 300,
|
||||||
|
overflow: 'inside',
|
||||||
|
title: 'Manage Users',
|
||||||
transition: 'slide-down',
|
transition: 'slide-down',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -120,9 +135,17 @@ export const AppMenu = () => {
|
|||||||
Settings
|
Settings
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Divider />
|
<DropdownMenu.Divider />
|
||||||
<DropdownMenu.Item disabled rightSection={<RiUserAddLine />}>
|
<DropdownMenu.Item rightSection={<RiProfileLine />}>
|
||||||
Manage users
|
Edit profile
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
{permissions.manageUsers && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
rightSection={<RiUserAddLine />}
|
||||||
|
onClick={handleManageUsersModal}
|
||||||
|
>
|
||||||
|
Manage users
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
rightSection={<RiEdit2Line />}
|
rightSection={<RiEdit2Line />}
|
||||||
onClick={handleManageServersModal}
|
onClick={handleManageServersModal}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<form onSubmit={handleAddUser}>
|
||||||
|
<Stack ref={focusTrapRef}>
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
required
|
||||||
|
label="Username"
|
||||||
|
{...form.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Display name"
|
||||||
|
{...form.getInputProps('displayName')}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
required
|
||||||
|
label="Password"
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
<Group position="apart">
|
||||||
|
{permissions.isSuperAdmin ? (
|
||||||
|
<Group>
|
||||||
|
Admin
|
||||||
|
<Switch
|
||||||
|
{...form.getInputProps('isAdmin', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
sx={{ height: '1.5rem' }}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleGeneratePassword}
|
||||||
|
>
|
||||||
|
Generate password
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group mt={10} position="right">
|
||||||
|
<Button variant="subtle" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={createUserMutation.isLoading}
|
||||||
|
type="submit"
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<form onSubmit={handleUpdateUser}>
|
||||||
|
<Stack ref={focusTrapRef} spacing="xl">
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
label="Username"
|
||||||
|
{...form.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Display name"
|
||||||
|
{...form.getInputProps('displayName')}
|
||||||
|
/>
|
||||||
|
<PasswordInput label="Password" {...form.getInputProps('password')} />
|
||||||
|
<Group position="apart">
|
||||||
|
{permissions.isAdmin && !user?.isSuperAdmin ? (
|
||||||
|
<Group>
|
||||||
|
Admin
|
||||||
|
<Switch
|
||||||
|
{...form.getInputProps('isAdmin', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
sx={{ height: '1.5rem' }}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleGeneratePassword}
|
||||||
|
>
|
||||||
|
Generate password
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group mt={10} position="right">
|
||||||
|
<Button variant="subtle" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={updateUserMutation.isLoading}
|
||||||
|
type="submit"
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditUserForm.defaultProps = {
|
||||||
|
user: undefined,
|
||||||
|
};
|
||||||
@@ -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) => (
|
||||||
|
<AddUserForm onCancel={() => 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) => (
|
||||||
|
<EditUserForm
|
||||||
|
user={user}
|
||||||
|
onCancel={() => 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 (
|
||||||
|
<Stack>
|
||||||
|
<Group mb={10} position="right">
|
||||||
|
<Button compact variant="default" onClick={handleAddUserModal}>
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{users?.data?.map((u) => (
|
||||||
|
<Group
|
||||||
|
key={u.id}
|
||||||
|
noWrap
|
||||||
|
position="apart"
|
||||||
|
sx={{
|
||||||
|
'&:hover': {
|
||||||
|
background: 'rgba(125, 125, 125, 0.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<Avatar radius="xl" />
|
||||||
|
<Text overflow="hidden">
|
||||||
|
{u.displayName ? u.displayName : u.username}{' '}
|
||||||
|
{u.isAdmin && (
|
||||||
|
<Tooltip label="Admin">
|
||||||
|
<span>
|
||||||
|
<RiAdminLine />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
disabled={!permissions.isAdmin}
|
||||||
|
leftIcon={<RiEdit2Line />}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => handleEditUserModal(u)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
disabled={!permissions.isAdmin}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => handleDeleteUser(u)}
|
||||||
|
>
|
||||||
|
<RiDeleteBin2Line color="var(--danger-color)" size={15} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -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<ApiError>,
|
||||||
|
{ body: CreateUserBody },
|
||||||
|
undefined
|
||||||
|
>({
|
||||||
|
mutationFn: ({ body }) => api.users.createUser(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(queryKeys.users.list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
};
|
||||||
@@ -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<ApiError>,
|
||||||
|
{ 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<UserListResponse>(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;
|
||||||
|
};
|
||||||
@@ -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<ApiError>,
|
||||||
|
{ 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<UserListResponse>(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;
|
||||||
|
};
|
||||||
@@ -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<UserListResponse>) => {
|
||||||
|
const query = useQuery({
|
||||||
|
queryFn: () => api.users.getUserList(),
|
||||||
|
queryKey: queryKeys.users.list(),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
return query;
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ export interface AuthState {
|
|||||||
currentServer: Server | null;
|
currentServer: Server | null;
|
||||||
permissions: {
|
permissions: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@@ -90,12 +91,13 @@ export const useAuthStore = create<AuthSlice>()(
|
|||||||
logout: () => {
|
logout: () => {
|
||||||
return set({
|
return set({
|
||||||
accessToken: undefined,
|
accessToken: undefined,
|
||||||
permissions: { isAdmin: false, username: '' },
|
permissions: { isAdmin: false, isSuperAdmin: false, username: '' },
|
||||||
refreshToken: undefined,
|
refreshToken: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
isSuperAdmin: false,
|
||||||
username: '',
|
username: '',
|
||||||
},
|
},
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user