Add initial users manager

This commit is contained in:
jeffvli
2022-11-08 18:46:56 -08:00
parent df8e38cedd
commit 445e4b56b7
19 changed files with 664 additions and 37 deletions
+1 -1
View File
@@ -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'],
},
};
+2
View File
@@ -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[];
+31 -3
View File
@@ -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<User>;
@@ -9,12 +9,40 @@ const getUserDetail = async (query: { userId: string }) => {
return data;
};
const getUserList = async () => {
const { data } = await ax.get<UserListResponse>('/users');
const getUserList = async (signal?: AbortSignal) => {
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;
};
export const usersApi = {
createUser,
deleteUser,
getUserDetail,
getUserList,
updateUser,
};
@@ -16,6 +16,8 @@ export const usePermissions = () => {
editServer: permissions.isAdmin,
editServerFolder: permissions.isAdmin,
isAdmin: permissions.isAdmin,
isSuperAdmin: permissions.isSuperAdmin,
manageUsers: permissions.isAdmin,
};
return set;
@@ -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: <ServerList />,
exitTransitionDuration: 300,
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',
});
};
@@ -120,9 +135,17 @@ export const AppMenu = () => {
Settings
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item disabled rightSection={<RiUserAddLine />}>
Manage users
<DropdownMenu.Item rightSection={<RiProfileLine />}>
Edit profile
</DropdownMenu.Item>
{permissions.manageUsers && (
<DropdownMenu.Item
rightSection={<RiUserAddLine />}
onClick={handleManageUsersModal}
>
Manage users
</DropdownMenu.Item>
)}
<DropdownMenu.Item
rightSection={<RiEdit2Line />}
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>
);
};
+7
View File
@@ -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;
};
+3 -1
View File
@@ -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<AuthSlice>()(
logout: () => {
return set({
accessToken: undefined,
permissions: { isAdmin: false, username: '' },
permissions: { isAdmin: false, isSuperAdmin: false, username: '' },
refreshToken: undefined,
});
},
permissions: {
isAdmin: false,
isSuperAdmin: false,
username: '',
},
refreshToken: '',