From 37efe1743ddd16d42439e766dc48d474c034ea92 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Tue, 5 Dec 2023 19:14:14 +0100 Subject: [PATCH 1/7] feat: #1616 better user manegement --- public/locales/en/manage/users.json | 12 +- public/locales/en/manage/users/edit.json | 3 + .../Manage/User/Edit/GeneralForm.tsx | 54 ++++ .../Manage/User/Edit/ManageUserDanger.tsx | 73 ++++++ .../User/Edit/ManageUserSecurityForm.tsx | 82 ++++++ src/pages/manage/users/[userId]/edit.tsx | 126 +++++++++ src/pages/manage/users/index.tsx | 244 +++++++++++++----- src/server/api/routers/user.ts | 87 ++++++- src/tools/server/translation-namespaces.ts | 1 + 9 files changed, 602 insertions(+), 80 deletions(-) create mode 100644 public/locales/en/manage/users/edit.json create mode 100644 src/components/Manage/User/Edit/GeneralForm.tsx create mode 100644 src/components/Manage/User/Edit/ManageUserDanger.tsx create mode 100644 src/components/Manage/User/Edit/ManageUserSecurityForm.tsx create mode 100644 src/pages/manage/users/[userId]/edit.tsx diff --git a/public/locales/en/manage/users.json b/public/locales/en/manage/users.json index 576f072a612..5e016c946fb 100644 --- a/public/locales/en/manage/users.json +++ b/public/locales/en/manage/users.json @@ -1,13 +1,21 @@ { "metaTitle": "Users", "pageTitle": "Manage users", - "text": "Using users, you can configure who can edit your dashboards. Future versions of Homarr will have even more granular control over permissions and boards.", "buttons": { "create": "Create" }, + "filter": { + "roles": { + "all": "All", + "normal": "Normal", + "admin": "Admin", + "owner": "Owner" + } + }, "table": { "header": { - "user": "User" + "user": "User", + "email": "E-Mail" } }, "tooltips": { diff --git a/public/locales/en/manage/users/edit.json b/public/locales/en/manage/users/edit.json new file mode 100644 index 00000000000..0a2558eb823 --- /dev/null +++ b/public/locales/en/manage/users/edit.json @@ -0,0 +1,3 @@ +{ + "metaTitle": "User {{username}}" +} \ No newline at end of file diff --git a/src/components/Manage/User/Edit/GeneralForm.tsx b/src/components/Manage/User/Edit/GeneralForm.tsx new file mode 100644 index 00000000000..ae964667cb1 --- /dev/null +++ b/src/components/Manage/User/Edit/GeneralForm.tsx @@ -0,0 +1,54 @@ +import { Button, Group, TextInput, Title } from '@mantine/core'; +import { useForm, zodResolver } from '@mantine/form'; +import { IconAt, IconCheck, IconLetterCase } from '@tabler/icons-react'; +import { z } from 'zod'; + +export const ManageUserGeneralForm = ({ + defaultUsername, + defaultEmail, +}: { + defaultUsername: string; + defaultEmail: string; +}) => { + const form = useForm({ + initialValues: { + username: defaultUsername, + eMail: defaultEmail, + }, + validate: zodResolver( + z.object({ + username: z.string(), + eMail: z.string().optional(), + }) + ), + validateInputOnBlur: true, + validateInputOnChange: true + }); + return ( + <> + + General + +
+ } + label="Username" + mb="md" + withAsterisk + {...form.getInputProps('username')} + /> + } label="E-Mail" {...form.getInputProps('eMail')} /> + + + + + + ); +}; diff --git a/src/components/Manage/User/Edit/ManageUserDanger.tsx b/src/components/Manage/User/Edit/ManageUserDanger.tsx new file mode 100644 index 00000000000..254e6527f9e --- /dev/null +++ b/src/components/Manage/User/Edit/ManageUserDanger.tsx @@ -0,0 +1,73 @@ +import { Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; +import { useForm, zodResolver } from '@mantine/form'; +import { IconPassword, IconTrash } from '@tabler/icons-react'; +import { z } from 'zod'; +import { api } from '~/utils/api'; + +export const ManageUserDanger = ({ userId, username }: { userId: string, username: string }) => { + const form = useForm({ + initialValues: { + username: '', + confirm: false, + }, + validate: zodResolver( + z.object({ + username: z.literal(username), + confirm: z.literal(true), + }) + ), + validateInputOnBlur: true, + validateInputOnChange: true, + }); + + const apiUtils = api.useUtils(); + + const { mutate, isLoading } = api.user.deleteUser.useMutation({ + onSettled: () => { + void apiUtils.user.details.invalidate(); + form.reset(); + }, + }); + + const handleSubmit = () => { + mutate({ + id: userId, + }); + }; + + return ( + <> + + + Account deletion + +
+ } + label="Confirm username" + description="Type username to confirm deletion" + mb="md" + withAsterisk + {...form.getInputProps('username')} + /> + + + + + + + ); +}; diff --git a/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx new file mode 100644 index 00000000000..c84a809bb56 --- /dev/null +++ b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx @@ -0,0 +1,82 @@ +import { Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; +import { useForm, zodResolver } from '@mantine/form'; +import { IconAlertTriangle, IconPassword } from '@tabler/icons-react'; +import { z } from 'zod'; +import { api } from '~/utils/api'; + +export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { + const form = useForm({ + initialValues: { + password: '', + terminateExistingSessions: false, + confirm: false, + }, + validate: zodResolver( + z.object({ + password: z.string().min(3), + terminateExistingSessions: z.boolean(), + confirm: z.literal(true), + }) + ), + validateInputOnBlur: true, + validateInputOnChange: true, + }); + + const apiUtils = api.useUtils(); + + const { mutate, isLoading } = api.user.updatePassword.useMutation({ + onSettled: () => { + void apiUtils.user.details.invalidate(); + form.reset(); + }, + }); + + const handleSubmit = (values: { password: string; terminateExistingSessions: boolean }) => { + mutate({ + newPassword: values.password, + terminateExistingSessions: values.terminateExistingSessions, + userId: userId, + }); + }; + + return ( + <> + + + Security + +
+ } + label="Password" + mb="md" + withAsterisk + {...form.getInputProps('password')} + /> + + + + + + + + ); +}; diff --git a/src/pages/manage/users/[userId]/edit.tsx b/src/pages/manage/users/[userId]/edit.tsx new file mode 100644 index 00000000000..6d4efb2db6c --- /dev/null +++ b/src/pages/manage/users/[userId]/edit.tsx @@ -0,0 +1,126 @@ +import { + Avatar, + Card, + Center, + Grid, + Group, + Loader, + Text, + ThemeIcon, + Title, + UnstyledButton, +} from '@mantine/core'; +import { IconArrowLeft } from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { ManageUserGeneralForm } from '~/components/Manage/User/Edit/GeneralForm'; +import { ManageUserDanger } from '~/components/Manage/User/Edit/ManageUserDanger'; +import { ManageUserSecurityForm } from '~/components/Manage/User/Edit/ManageUserSecurityForm'; +import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; +import { manageNamespaces } from '~/tools/server/translation-namespaces'; +import { api } from '~/utils/api'; + +const EditPage = () => { + const { t } = useTranslation('manage/users/edit'); + + const router = useRouter(); + + const { isLoading, data } = api.user.details.useQuery({ userId: router.query.userId as string }); + + const metaTitle = `${t('metaTitle', { + username: data?.name, + })} • Homarr`; + + return ( + + + {metaTitle} + + + + + + + Back to user management + + + + + {data?.name?.slice(0, 2).toUpperCase()} + {data?.name} + + + + + {isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ + + {isLoading || !data ? ( +
+ +
+ ) : ( + + )} +
+
+ + ({ + borderColor: `${theme.colors.red[8]} !important`, + borderWidth: '3px !important' + })} + withBorder + > + {isLoading || !data?.name ? ( +
+ +
+ ) : ( + + )} +
+
+
+
+ ); +}; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const session = await getServerAuthSession(ctx); + const result = checkForSessionOrAskForLogin(ctx, session, () => session?.user.isAdmin == true); + if (result) { + return result; + } + + const translations = await getServerSideTranslations( + manageNamespaces, + ctx.locale, + undefined, + undefined + ); + return { + props: { + ...translations, + }, + }; +}; + +export default EditPage; diff --git a/src/pages/manage/users/index.tsx b/src/pages/manage/users/index.tsx index 4a3b3176b9c..601d9bd7867 100644 --- a/src/pages/manage/users/index.tsx +++ b/src/pages/manage/users/index.tsx @@ -1,26 +1,40 @@ import { ActionIcon, - Autocomplete, Avatar, Badge, - Box, Button, Flex, + Grid, Group, + Loader, + NavLink, Pagination, Table, Text, + TextInput, Title, Tooltip, } from '@mantine/core'; +import { useForm, zodResolver } from '@mantine/form'; import { useDebouncedValue } from '@mantine/hooks'; -import { IconPlus, IconTrash, IconUserDown, IconUserUp } from '@tabler/icons-react'; +import { + IconPencil, + IconTrash, + IconUser, + IconUserDown, + IconUserPlus, + IconUserShield, + IconUserStar, + IconUserUp, + IconX, +} from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; import { useSession } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Link from 'next/link'; import { useState } from 'react'; +import { z } from 'zod'; import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal'; import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; @@ -30,13 +44,46 @@ import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { api } from '~/utils/api'; +const PossibleRoleFilter = [ + { + id: 'all', + icon: IconUser, + }, + { + id: 'owner', + icon: IconUserStar, + }, + { + id: 'admin', + icon: IconUserShield, + }, + { + id: 'normal', + icon: IconUser, + }, +]; + const ManageUsersPage = () => { const [activePage, setActivePage] = useState(0); - const [nonDebouncedSearch, setNonDebouncedSearch] = useState(''); - const [debouncedSearch] = useDebouncedValue(nonDebouncedSearch, 200); - const { data } = api.user.all.useQuery({ + const form = useForm({ + initialValues: { + fullTextSearch: '', + role: PossibleRoleFilter[0].id, + }, + validate: zodResolver( + z.object({ + fullTextSearch: z.string(), + role: z + .string() + .transform((value) => (value.length > 0 ? value : undefined)) + .optional(), + }) + ), + }); + const [debouncedForm] = useDebouncedValue(form, 200); + const { data, isLoading } = api.user.all.useQuery({ page: activePage, - search: debouncedSearch, + search: debouncedForm.values.fullTextSearch, }); const { data: sessionData } = useSession(); @@ -51,39 +98,87 @@ const ManageUsersPage = () => { {t('pageTitle')} - {t('text')} - - user.name).filter((name) => name !== null) as string[]) ?? [] + + { + form.setFieldValue('fullTextSearch', ''); + }} + size="1rem" + /> } - variant="filled" - onChange={(value) => { - setNonDebouncedSearch(value); + style={{ + flexGrow: 1, }} + placeholder="Filter" + variant="filled" + {...form.getInputProps('fullTextSearch')} /> - {data && ( - <> + + + + Roles + + {PossibleRoleFilter.map((role) => ( + } + rightSection={!isLoading && data && {data?.stats.roles[role.id]}} + label={t(`filter.roles.${role.id}`)} + active={form.values.role === role.id} + onClick={() => { + form.setFieldValue('role', role.id); + }} + sx={(theme) => ({ + borderRadius: theme.radius.md, + marginBottom: 5, + })} + /> + ))} + + + + - {data.users.map((user, index) => ( + {isLoading && ( + + + + )} + {data?.users.length === 0 && ( + + + + )} + {data?.users.map((user, index) => ( + + ))} - - {debouncedSearch && debouncedSearch.length > 0 && data.countPages === 0 && ( - - - - )}
{t('table.header.user')}{t('table.header.email')}
+ + + +
+ + {t('searchDoesntMatch')} + +
@@ -101,68 +196,72 @@ const ManageUsersPage = () => { )} - - {user.isAdmin ? ( - - { - openRoleChangeModal({ - ...user, - type: 'demote', - }); - }} - > - - - - ) : ( - - { - openRoleChangeModal({ - ...user, - type: 'promote', - }); - }} - > - - - - )} - - + + + {user.email ? {user.email} : No E-Mail} + + + {user.isAdmin ? ( + { - openDeleteUserModal(user); + openRoleChangeModal({ + ...user, + type: 'demote', + }); }} - color="red" - variant="light" > - + - + ) : ( + + { + openRoleChangeModal({ + ...user, + type: 'promote', + }); + }} + > + + + + )} + + + + +
- - {t('searchDoesntMatch')} - -
+
+ { setActivePage((prev) => prev + 1); }} @@ -172,9 +271,12 @@ const ManageUsersPage = () => { onChange={(targetPage) => { setActivePage(targetPage - 1); }} + total={data?.countPages ?? 0} + value={activePage + 1} + withControls /> - - )} + +
); }; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 236f0a8fae2..77b7f764112 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -1,11 +1,19 @@ import { TRPCError } from '@trpc/server'; + import bcrypt from 'bcryptjs'; + import { randomUUID } from 'crypto'; -import { eq, like, sql } from 'drizzle-orm'; + +import { and, eq, like, sql } from 'drizzle-orm'; + import { z } from 'zod'; + +import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants'; +import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; + import { db } from '~/server/db'; import { getTotalUserCountAsync } from '~/server/db/queries/user'; -import { UserSettings, invites, userSettings, users } from '~/server/db/schema'; +import { UserSettings, invites, sessions, userSettings, users } from '~/server/db/schema'; import { hashPassword } from '~/utils/security'; import { colorSchemeParser, @@ -14,9 +22,6 @@ import { updateSettingsValidationSchema, } from '~/validations/user'; -import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants'; -import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; - export const userRouter = createTRPCRouter({ createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => { const userCount = await getTotalUserCountAsync(); @@ -34,6 +39,47 @@ export const userRouter = createTRPCRouter({ isOwner: true, }); }), + updatePassword: adminProcedure + .input( + z.object({ + userId: z.string(), + newPassword: z.string().min(3), + terminateExistingSessions: z.boolean(), + }) + ) + .mutation(async ({ input, ctx }) => { + const user = await db.query.users.findFirst({ + where: eq(users.id, input.userId), + }); + + if (!user) { + throw new TRPCError({ + code: 'NOT_FOUND', + }); + } + + if (user.isOwner && user.id !== ctx.session.user.id) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Operation not allowed or incorrect user', + }); + } + + const salt = bcrypt.genSaltSync(10); + const hashedPassword = hashPassword(input.newPassword, salt); + + if (input.terminateExistingSessions) { + await db.delete(sessions).where(eq(sessions.userId, input.userId)); + } + + await db + .update(users) + .set({ + password: hashedPassword, + salt: salt, + }) + .where(eq(users.id, input.userId)); + }), count: publicProcedure.query(async () => { return await getTotalUserCountAsync(); }), @@ -213,12 +259,39 @@ export const userRouter = createTRPCRouter({ isOwner: user.isOwner, })), countPages: Math.ceil(countUsers / limit), + stats: { + roles: { + all: (await db.select({ count: sql`count(*)` }).from(users))[0]['count'], + owner: ( + await db + .select({ count: sql`count(*)` }) + .from(users) + .where(eq(users.isOwner, true)) + )[0]['count'], + admin: ( + await db + .select({ count: sql`count(*)` }) + .from(users) + .where(and(eq(users.isAdmin, true), eq(users.isOwner, false))) + )[0]['count'], + normal: ( + await db + .select({ count: sql`count(*)` }) + .from(users) + .where(and(eq(users.isAdmin, false), eq(users.isOwner, false))) + )[0]['count'], + } as Record, + }, }; }), - create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => { + create: adminProcedure.input(createNewUserSchema).mutation(async ({ input }) => { await createUserIfNotPresent(input); }), - + details: adminProcedure.input(z.object({ userId: z.string() })).query(async ({ input }) => { + return await db.query.users.findFirst({ + where: eq(users.id, input.userId), + }); + }), deleteUser: adminProcedure .input( z.object({ diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 910e3022094..f9af2b59330 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -42,6 +42,7 @@ export const manageNamespaces = [ 'manage/users', 'manage/users/invites', 'manage/users/create', + 'manage/users/edit' ]; export const loginNamespaces = ['authentication/login']; From 624aaebcccfd59363031d5e279c945ae236c5585 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:08:50 +0100 Subject: [PATCH 2/7] style: improve design --- .../Manage/User/Edit/GeneralForm.tsx | 8 +- .../Manage/User/Edit/ManageUserDanger.tsx | 10 +-- .../User/Edit/ManageUserSecurityForm.tsx | 8 +- src/pages/manage/users/[userId]/edit.tsx | 77 ++++++------------- 4 files changed, 36 insertions(+), 67 deletions(-) diff --git a/src/components/Manage/User/Edit/GeneralForm.tsx b/src/components/Manage/User/Edit/GeneralForm.tsx index ae964667cb1..e71b81ce3ed 100644 --- a/src/components/Manage/User/Edit/GeneralForm.tsx +++ b/src/components/Manage/User/Edit/GeneralForm.tsx @@ -1,4 +1,4 @@ -import { Button, Group, TextInput, Title } from '@mantine/core'; +import { Box, Button, Group, TextInput, Title } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; import { IconAt, IconCheck, IconLetterCase } from '@tabler/icons-react'; import { z } from 'zod'; @@ -25,8 +25,8 @@ export const ManageUserGeneralForm = ({ validateInputOnChange: true }); return ( - <> - + <Box maw={500}> + <Title order={3}> General
@@ -49,6 +49,6 @@ export const ManageUserGeneralForm = ({ Save - + ); }; diff --git a/src/components/Manage/User/Edit/ManageUserDanger.tsx b/src/components/Manage/User/Edit/ManageUserDanger.tsx index 254e6527f9e..43a395cd119 100644 --- a/src/components/Manage/User/Edit/ManageUserDanger.tsx +++ b/src/components/Manage/User/Edit/ManageUserDanger.tsx @@ -1,10 +1,10 @@ -import { Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; +import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; import { IconPassword, IconTrash } from '@tabler/icons-react'; import { z } from 'zod'; import { api } from '~/utils/api'; -export const ManageUserDanger = ({ userId, username }: { userId: string, username: string }) => { +export const ManageUserDanger = ({ userId, username }: { userId: string, username: string | null }) => { const form = useForm({ initialValues: { username: '', @@ -36,9 +36,9 @@ export const ManageUserDanger = ({ userId, username }: { userId: string, usernam }; return ( - <> + - + <Title order={3}> Account deletion @@ -68,6 +68,6 @@ export const ManageUserDanger = ({ userId, username }: { userId: string, usernam - +
); }; diff --git a/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx index c84a809bb56..5fa63feb5a0 100644 --- a/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx +++ b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx @@ -1,4 +1,4 @@ -import { Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; +import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; import { IconAlertTriangle, IconPassword } from '@tabler/icons-react'; import { z } from 'zod'; @@ -40,9 +40,9 @@ export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { }; return ( - <> + - + <Title order={3}> Security
@@ -77,6 +77,6 @@ export const ManageUserSecurityForm = ({ userId }: { userId: string }) => {
- +
); }; diff --git a/src/pages/manage/users/[userId]/edit.tsx b/src/pages/manage/users/[userId]/edit.tsx index 6d4efb2db6c..28cff00987d 100644 --- a/src/pages/manage/users/[userId]/edit.tsx +++ b/src/pages/manage/users/[userId]/edit.tsx @@ -1,14 +1,12 @@ import { Avatar, - Card, - Center, - Grid, Group, Loader, Text, + Stack, ThemeIcon, Title, - UnstyledButton, + UnstyledButton, Divider, } from '@mantine/core'; import { IconArrowLeft } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; @@ -42,63 +40,34 @@ const EditPage = () => { {metaTitle} - - - - + + + + Back to user management - + {data?.name?.slice(0, 2).toUpperCase()} {data?.name} - - - - {isLoading ? ( -
- -
- ) : ( - - )} -
-
- - - {isLoading || !data ? ( -
- -
- ) : ( - - )} -
-
- - ({ - borderColor: `${theme.colors.red[8]} !important`, - borderWidth: '3px !important' - })} - withBorder - > - {isLoading || !data?.name ? ( -
- -
- ) : ( - - )} -
-
-
+ + {data ? ( + + + + + + + + ) : ( + + )} ); }; @@ -114,7 +83,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { manageNamespaces, ctx.locale, undefined, - undefined + undefined, ); return { props: { From d77b0d747535a0309c31b086f9b6cac77900cb81 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:23:01 +0100 Subject: [PATCH 3/7] refactor: move promote and demote to user details page --- .../Manage/User/Edit/ManageUserRoles.tsx | 64 +++++++++++++++++++ .../Manage/User/change-user-role.modal.tsx | 1 + src/pages/manage/users/[userId]/edit.tsx | 3 + src/pages/manage/users/index.tsx | 43 ------------- 4 files changed, 68 insertions(+), 43 deletions(-) create mode 100644 src/components/Manage/User/Edit/ManageUserRoles.tsx diff --git a/src/components/Manage/User/Edit/ManageUserRoles.tsx b/src/components/Manage/User/Edit/ManageUserRoles.tsx new file mode 100644 index 00000000000..d250376195c --- /dev/null +++ b/src/components/Manage/User/Edit/ManageUserRoles.tsx @@ -0,0 +1,64 @@ +import { ActionIcon, Badge, Box, Group, Title, Text, Tooltip, Button } from '@mantine/core'; +import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal'; +import { IconUserDown, IconUserUp } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { useSession } from 'next-auth/react'; + +export const ManageUserRoles = ({ user }: { + user: { + image: string | null; + id: string; + name: string | null; + password: string | null; + email: string | null; + emailVerified: Date | null; + salt: string | null; + isAdmin: boolean; + isOwner: boolean; + } +}) => { + const { t } = useTranslation('manage/users'); + const { data: sessionData } = useSession(); + return ( + + + Roles + + + + Current role: + {user.isOwner ? (Owner) : user.isAdmin ? (Admin) : (Normal)} + + + {user.isAdmin ? ( + + ) : ( + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/Manage/User/change-user-role.modal.tsx b/src/components/Manage/User/change-user-role.modal.tsx index 45c09a7aa39..97c2efc36e5 100644 --- a/src/components/Manage/User/change-user-role.modal.tsx +++ b/src/components/Manage/User/change-user-role.modal.tsx @@ -11,6 +11,7 @@ export const ChangeUserRoleModal = ({ id, innerProps }: ContextModalProps { await utils.user.all.invalidate(); + await utils.user.details.invalidate(); modals.close(id); }, }); diff --git a/src/pages/manage/users/[userId]/edit.tsx b/src/pages/manage/users/[userId]/edit.tsx index 28cff00987d..590e9988ee0 100644 --- a/src/pages/manage/users/[userId]/edit.tsx +++ b/src/pages/manage/users/[userId]/edit.tsx @@ -23,6 +23,7 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { api } from '~/utils/api'; +import { ManageUserRoles } from '~/components/Manage/User/Edit/ManageUserRoles'; const EditPage = () => { const { t } = useTranslation('manage/users/edit'); @@ -63,6 +64,8 @@ const EditPage = () => { + + ) : ( diff --git a/src/pages/manage/users/index.tsx b/src/pages/manage/users/index.tsx index 601d9bd7867..1f2b6262f23 100644 --- a/src/pages/manage/users/index.tsx +++ b/src/pages/manage/users/index.tsx @@ -85,7 +85,6 @@ const ManageUsersPage = () => { page: activePage, search: debouncedForm.values.fullTextSearch, }); - const { data: sessionData } = useSession(); const { t } = useTranslation('manage/users'); @@ -203,35 +202,6 @@ const ManageUsersPage = () => { - {user.isAdmin ? ( - - { - openRoleChangeModal({ - ...user, - type: 'demote', - }); - }} - > - - - - ) : ( - - { - openRoleChangeModal({ - ...user, - type: 'promote', - }); - }} - > - - - - )} - - - - From 74ee3f4e896be223d7154d01d49cb6b653be00e6 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:31:29 +0100 Subject: [PATCH 4/7] feat: add role filtering in db --- src/pages/manage/users/index.tsx | 171 ++++++++++++++----------------- src/server/api/routers/user.ts | 55 +++++++--- 2 files changed, 118 insertions(+), 108 deletions(-) diff --git a/src/pages/manage/users/index.tsx b/src/pages/manage/users/index.tsx index 1f2b6262f23..c743f37f92f 100644 --- a/src/pages/manage/users/index.tsx +++ b/src/pages/manage/users/index.tsx @@ -1,5 +1,4 @@ import { - ActionIcon, Avatar, Badge, Button, @@ -13,30 +12,16 @@ import { Text, TextInput, Title, - Tooltip, } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; import { useDebouncedValue } from '@mantine/hooks'; -import { - IconPencil, - IconTrash, - IconUser, - IconUserDown, - IconUserPlus, - IconUserShield, - IconUserStar, - IconUserUp, - IconX, -} from '@tabler/icons-react'; +import { IconPencil, IconUser, IconUserPlus, IconUserShield, IconUserStar, IconX } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; -import { useSession } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Link from 'next/link'; import { useState } from 'react'; import { z } from 'zod'; -import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal'; -import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { getServerAuthSession } from '~/server/auth'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; @@ -44,7 +29,7 @@ import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { api } from '~/utils/api'; -const PossibleRoleFilter = [ +export const PossibleRoleFilter = [ { id: 'all', icon: IconUser, @@ -77,13 +62,13 @@ const ManageUsersPage = () => { .string() .transform((value) => (value.length > 0 ? value : undefined)) .optional(), - }) + }), ), }); const [debouncedForm] = useDebouncedValue(form, 200); const { data, isLoading } = api.user.all.useQuery({ page: activePage, - search: debouncedForm.values.fullTextSearch, + search: debouncedForm.values, }); const { t } = useTranslation('manage/users'); @@ -96,32 +81,32 @@ const ManageUsersPage = () => { {metaTitle} - {t('pageTitle')} + {t('pageTitle')} - + { form.setFieldValue('fullTextSearch', ''); }} - size="1rem" + size='1rem' /> } style={{ flexGrow: 1, }} - placeholder="Filter" - variant="filled" + placeholder='Filter' + variant='filled' {...form.getInputProps('fullTextSearch')} /> @@ -129,13 +114,13 @@ const ManageUsersPage = () => { - + Roles {PossibleRoleFilter.map((role) => ( } + icon={} rightSection={!isLoading && data && {data?.stats.roles[role.id]}} label={t(`filter.roles.${role.id}`)} active={form.values.role === role.id} @@ -150,74 +135,74 @@ const ManageUsersPage = () => { ))} - +
- - - - - + + + + + - {isLoading && ( - - - - )} - {data?.users.length === 0 && ( - - - - )} - {data?.users.map((user, index) => ( - - - - + + + )} + {data?.users.length === 0 && ( + + + + )} + {data?.users.map((user, index) => ( + + - - ))} + + + + + + ))}
{t('table.header.user')}{t('table.header.email')}
{t('table.header.user')}{t('table.header.email')}
- - - -
- - {t('searchDoesntMatch')} - -
- - - - {user.name} - {user.isOwner && ( - - Owner - - )} - {user.isAdmin && ( - - Admin - - )} - - - - {user.email ? {user.email} : No E-Mail} - - - + {isLoading && ( +
+ + + +
+ + {t('searchDoesntMatch')} + +
+ + + + {user.name} + {user.isOwner && ( + + Owner + + )} + {user.isAdmin && ( + + Admin + + )} -
+ {user.email ? {user.email} : No E-Mail} + + + + +
- + { setActivePage((prev) => prev + 1); @@ -249,7 +234,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { manageNamespaces, ctx.locale, undefined, - undefined + undefined, ); return { props: { diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 77b7f764112..7b5c60b50e5 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -13,7 +13,7 @@ import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } import { db } from '~/server/db'; import { getTotalUserCountAsync } from '~/server/db/queries/user'; -import { UserSettings, invites, sessions, userSettings, users } from '~/server/db/schema'; +import { invites, sessions, users, userSettings, UserSettings } from '~/server/db/schema'; import { hashPassword } from '~/utils/security'; import { colorSchemeParser, @@ -21,6 +21,7 @@ import { signUpFormSchema, updateSettingsValidationSchema, } from '~/validations/user'; +import { PossibleRoleFilter } from '~/pages/manage/users'; export const userRouter = createTRPCRouter({ createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => { @@ -45,7 +46,7 @@ export const userRouter = createTRPCRouter({ userId: z.string(), newPassword: z.string().min(3), terminateExistingSessions: z.boolean(), - }) + }), ) .mutation(async ({ input, ctx }) => { const user = await db.query.users.findFirst({ @@ -88,8 +89,8 @@ export const userRouter = createTRPCRouter({ signUpFormSchema.and( z.object({ inviteToken: z.string(), - }) - ) + }), + ), ) .mutation(async ({ ctx, input }) => { const invite = await db.query.invites.findFirst({ @@ -121,7 +122,7 @@ export const userRouter = createTRPCRouter({ .input( z.object({ colorScheme: colorSchemeParser, - }) + }), ) .mutation(async ({ ctx, input }) => { await db @@ -168,7 +169,7 @@ export const userRouter = createTRPCRouter({ .input( z.object({ language: z.string(), - }) + }), ) .mutation(async ({ ctx, input }) => { await db @@ -230,24 +231,48 @@ export const userRouter = createTRPCRouter({ z.object({ limit: z.number().min(1).max(100).default(10), page: z.number().min(0), - search: z - .string() - .optional() - .transform((value) => (value === '' ? undefined : value)), - }) + search: z.object({ + fullTextSearch: z + .string() + .optional() + .transform((value) => (value === '' ? undefined : value)), + role: z + .string() + .transform((value) => (value.length > 0 ? value : undefined)) + .optional(), + }), + }), ) .query(async ({ ctx, input }) => { + + const roleFilter = () => { + if (input.search.role === PossibleRoleFilter[1].id) { + return eq(users.isOwner, true); + } + + if (input.search.role === PossibleRoleFilter[2].id) { + return eq(users.isAdmin, true); + } + + if (input.search.role === PossibleRoleFilter[3].id) { + return and(eq(users.isAdmin, false), eq(users.isOwner, false)); + } + + return undefined; + }; + const limit = input.limit; const dbUsers = await db.query.users.findMany({ limit: limit + 1, offset: limit * input.page, - where: input.search ? like(users.name, `%${input.search}%`) : undefined, + where: and(input.search.fullTextSearch ? like(users.name, `%${input.search.fullTextSearch}%`) : undefined, roleFilter()), }); const countUsers = await db .select({ count: sql`count(*)` }) .from(users) - .where(input.search ? like(users.name, `%${input.search}%`) : undefined) + .where(input.search.fullTextSearch ? like(users.name, `%${input.search.fullTextSearch}%`) : undefined) + .where(roleFilter()) .then((rows) => rows[0].count); return { @@ -296,7 +321,7 @@ export const userRouter = createTRPCRouter({ .input( z.object({ id: z.string(), - }) + }), ) .mutation(async ({ ctx, input }) => { const user = await db.query.users.findFirst({ @@ -332,7 +357,7 @@ const createUserIfNotPresent = async ( options: { defaultSettings?: Partial; isOwner?: boolean; - } | void + } | void, ) => { const existingUser = await db.query.users.findFirst({ where: eq(users.name, input.username), From cb0e67db17604a158c93ed9bad713d56235fd052 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:49:11 +0100 Subject: [PATCH 5/7] feat: add translations --- public/locales/en/manage/users/edit.json | 54 ++++++++++++++++++- .../Manage/User/Edit/GeneralForm.tsx | 10 ++-- .../Manage/User/Edit/ManageUserDanger.tsx | 19 ++++--- .../Manage/User/Edit/ManageUserRoles.tsx | 13 ++--- .../User/Edit/ManageUserSecurityForm.tsx | 17 +++--- src/pages/manage/users/[userId]/edit.tsx | 2 +- 6 files changed, 88 insertions(+), 27 deletions(-) diff --git a/public/locales/en/manage/users/edit.json b/public/locales/en/manage/users/edit.json index 0a2558eb823..543853b5bb8 100644 --- a/public/locales/en/manage/users/edit.json +++ b/public/locales/en/manage/users/edit.json @@ -1,3 +1,55 @@ { - "metaTitle": "User {{username}}" + "metaTitle": "User {{username}}", + "back": "Back to user management", + "sections": { + "general": { + "title": "General", + "inputs": { + "username": { + "label": "Username" + }, + "eMail": { + "label": "E-Mail" + } + } + }, + "security": { + "title": "Security", + "inputs": { + "password": { + "label": "New password" + }, + "terminateExistingSessions": { + "label": "Terminate existing sessions", + "description": "Forces user to log in again on their devices" + }, + "confirm": { + "label": "Confirm", + "description": "Password will be updated. Action cannot be reverted." + } + } + }, + "roles": { + "title": "Roles", + "currentRole": "Current role: ", + "badges": { + "owner": "Owner", + "admin": "Admin", + "normal": "Normal" + } + }, + "deletion": { + "title": "Account deletion", + "inputs": { + "confirmUsername": { + "label": "Confirm username", + "description": "Type username to confirm deletion" + }, + "confirm": { + "label": "Delete permanently", + "description": "I am aware that this action is permanent and all account data will be lost." + } + } + } + } } \ No newline at end of file diff --git a/src/components/Manage/User/Edit/GeneralForm.tsx b/src/components/Manage/User/Edit/GeneralForm.tsx index e71b81ce3ed..e1c0b88c3a1 100644 --- a/src/components/Manage/User/Edit/GeneralForm.tsx +++ b/src/components/Manage/User/Edit/GeneralForm.tsx @@ -2,6 +2,7 @@ import { Box, Button, Group, TextInput, Title } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; import { IconAt, IconCheck, IconLetterCase } from '@tabler/icons-react'; import { z } from 'zod'; +import { useTranslation } from 'next-i18next'; export const ManageUserGeneralForm = ({ defaultUsername, @@ -24,20 +25,21 @@ export const ManageUserGeneralForm = ({ validateInputOnBlur: true, validateInputOnChange: true }); + const { t } = useTranslation(['manage/users/edit', 'common']); return ( - General + {t('sections.general.title')}
} - label="Username" + label={t('sections.general.inputs.username.label')} mb="md" withAsterisk {...form.getInputProps('username')} /> - } label="E-Mail" {...form.getInputProps('eMail')} /> + } label={t('sections.general.inputs.eMail.label')} {...form.getInputProps('eMail')} />
diff --git a/src/components/Manage/User/Edit/ManageUserDanger.tsx b/src/components/Manage/User/Edit/ManageUserDanger.tsx index 43a395cd119..f70585673da 100644 --- a/src/components/Manage/User/Edit/ManageUserDanger.tsx +++ b/src/components/Manage/User/Edit/ManageUserDanger.tsx @@ -1,8 +1,9 @@ import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; -import { IconPassword, IconTrash } from '@tabler/icons-react'; +import { IconTextSize, IconTrash } from '@tabler/icons-react'; import { z } from 'zod'; import { api } from '~/utils/api'; +import { useTranslation } from 'next-i18next'; export const ManageUserDanger = ({ userId, username }: { userId: string, username: string | null }) => { const form = useForm({ @@ -29,6 +30,8 @@ export const ManageUserDanger = ({ userId, username }: { userId: string, usernam }, }); + const { t } = useTranslation(['manage/users/edit', 'common']); + const handleSubmit = () => { mutate({ id: userId, @@ -39,20 +42,20 @@ export const ManageUserDanger = ({ userId, username }: { userId: string, usernam - Account deletion + {t('sections.deletion.title')}
} - label="Confirm username" - description="Type username to confirm deletion" + icon={} + label={t('sections.deletion.inputs.confirmUsername.label')} + description={t('sections.deletion.inputs.confirmUsername.description')} mb="md" withAsterisk {...form.getInputProps('username')} /> @@ -64,7 +67,7 @@ export const ManageUserDanger = ({ userId, username }: { userId: string, usernam variant="light" type="submit" > - Delete + {t('common:delete')} diff --git a/src/components/Manage/User/Edit/ManageUserRoles.tsx b/src/components/Manage/User/Edit/ManageUserRoles.tsx index d250376195c..0a4443b5257 100644 --- a/src/components/Manage/User/Edit/ManageUserRoles.tsx +++ b/src/components/Manage/User/Edit/ManageUserRoles.tsx @@ -17,17 +17,18 @@ export const ManageUserRoles = ({ user }: { isOwner: boolean; } }) => { - const { t } = useTranslation('manage/users'); + const { t } = useTranslation(['manage/users/edit', 'manage/users']); const { data: sessionData } = useSession(); return ( - Roles + {t('sections.roles.title')} - Current role: - {user.isOwner ? (Owner) : user.isAdmin ? (Admin) : (Normal)} + {t('sections.roles.currentRole')} + {user.isOwner ? ({t('sections.roles.badges.owner')}) : user.isAdmin ? ( + {t('sections.roles.badges.admin')}) : ({t('sections.roles.badges.normal')})} {user.isAdmin ? ( @@ -42,7 +43,7 @@ export const ManageUserRoles = ({ user }: { }); }} > - {t('tooltips.demoteAdmin')} + {t('manage/users:tooltips.demoteAdmin')} ) : ( )} diff --git a/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx index 5fa63feb5a0..519ac535b9b 100644 --- a/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx +++ b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx @@ -3,6 +3,7 @@ import { useForm, zodResolver } from '@mantine/form'; import { IconAlertTriangle, IconPassword } from '@tabler/icons-react'; import { z } from 'zod'; import { api } from '~/utils/api'; +import { useTranslation } from 'next-i18next'; export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { const form = useForm({ @@ -22,6 +23,8 @@ export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { validateInputOnChange: true, }); + const { t } = useTranslation(['manage/users/edit', 'common']); + const apiUtils = api.useUtils(); const { mutate, isLoading } = api.user.updatePassword.useMutation({ @@ -43,25 +46,25 @@ export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { - Security + {t('sections.security.title')}
} - label="Password" + label={t('sections.security.inputs.password.label')} mb="md" withAsterisk {...form.getInputProps('password')} /> @@ -73,7 +76,7 @@ export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { variant="light" type="submit" > - Update Password + {t('common:save')} diff --git a/src/pages/manage/users/[userId]/edit.tsx b/src/pages/manage/users/[userId]/edit.tsx index 590e9988ee0..133f71f4015 100644 --- a/src/pages/manage/users/[userId]/edit.tsx +++ b/src/pages/manage/users/[userId]/edit.tsx @@ -46,7 +46,7 @@ const EditPage = () => { - Back to user management + {t('back')}
From f83a096faa40713dc3a825b44d01c0a6029bf584 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 17 Dec 2023 18:01:54 +0100 Subject: [PATCH 6/7] feat: add API route for general section --- .../Manage/User/Edit/GeneralForm.tsx | 60 +++++++++++++------ src/pages/manage/users/[userId]/edit.tsx | 12 +--- src/server/api/routers/user.ts | 12 +++- 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/components/Manage/User/Edit/GeneralForm.tsx b/src/components/Manage/User/Edit/GeneralForm.tsx index e1c0b88c3a1..d30ec88fbb5 100644 --- a/src/components/Manage/User/Edit/GeneralForm.tsx +++ b/src/components/Manage/User/Edit/GeneralForm.tsx @@ -3,11 +3,14 @@ import { useForm, zodResolver } from '@mantine/form'; import { IconAt, IconCheck, IconLetterCase } from '@tabler/icons-react'; import { z } from 'zod'; import { useTranslation } from 'next-i18next'; +import { api } from '~/utils/api'; export const ManageUserGeneralForm = ({ - defaultUsername, - defaultEmail, -}: { + userId, + defaultUsername, + defaultEmail, + }: { + userId: string defaultUsername: string; defaultEmail: string; }) => { @@ -20,37 +23,56 @@ export const ManageUserGeneralForm = ({ z.object({ username: z.string(), eMail: z.string().optional(), - }) + }), ), validateInputOnBlur: true, - validateInputOnChange: true + validateInputOnChange: true, }); const { t } = useTranslation(['manage/users/edit', 'common']); + + const utils = api.useUtils(); + + const { mutate, isLoading } = api.user.updateDetails.useMutation({ + onSettled: async () => { + await utils.user.invalidate(); + }, + }); + + function handleSubmit() { + mutate({ + userId: userId, + username: form.values.username, + eMail: form.values.eMail + }); + } + return ( {t('sections.general.title')} -
+ } + icon={} label={t('sections.general.inputs.username.label')} - mb="md" + mb='md' withAsterisk {...form.getInputProps('username')} /> - } label={t('sections.general.inputs.eMail.label')} {...form.getInputProps('eMail')} /> + } + label={t('sections.general.inputs.eMail.label')} {...form.getInputProps('eMail')} /> + + + - - -
); }; diff --git a/src/pages/manage/users/[userId]/edit.tsx b/src/pages/manage/users/[userId]/edit.tsx index 133f71f4015..ff1989c7d09 100644 --- a/src/pages/manage/users/[userId]/edit.tsx +++ b/src/pages/manage/users/[userId]/edit.tsx @@ -1,13 +1,4 @@ -import { - Avatar, - Group, - Loader, - Text, - Stack, - ThemeIcon, - Title, - UnstyledButton, Divider, -} from '@mantine/core'; +import { Avatar, Divider, Group, Loader, Stack, Text, ThemeIcon, Title, UnstyledButton } from '@mantine/core'; import { IconArrowLeft } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; import { useTranslation } from 'next-i18next'; @@ -60,6 +51,7 @@ const EditPage = () => { diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 7b5c60b50e5..fcfb9d93c81 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -313,10 +313,20 @@ export const userRouter = createTRPCRouter({ await createUserIfNotPresent(input); }), details: adminProcedure.input(z.object({ userId: z.string() })).query(async ({ input }) => { - return await db.query.users.findFirst({ + return db.query.users.findFirst({ where: eq(users.id, input.userId), }); }), + updateDetails: adminProcedure.input(z.object({ + userId: z.string(), + username: z.string(), + eMail: z.string().optional().transform(value => value?.length === 0 ? null : value), + })).mutation(async ({ input }) => { + await db.update(users).set({ + name: input.username, + email: input.eMail as string | null, + }).where(eq(users.id, input.userId)); + }), deleteUser: adminProcedure .input( z.object({ From 772f522e5d0250031290f9ce3a3ae1ed86a8b3c2 Mon Sep 17 00:00:00 2001 From: Tagaishi Date: Wed, 20 Dec 2023 09:59:48 +0100 Subject: [PATCH 7/7] Address own review comments (#1751) --- .../Manage/User/Edit/GeneralForm.tsx | 43 ++--- .../Manage/User/Edit/ManageUserDanger.tsx | 17 +- .../User/Edit/ManageUserSecurityForm.tsx | 14 +- src/pages/manage/users/index.tsx | 152 +++++++++--------- 4 files changed, 124 insertions(+), 102 deletions(-) diff --git a/src/components/Manage/User/Edit/GeneralForm.tsx b/src/components/Manage/User/Edit/GeneralForm.tsx index d30ec88fbb5..f5c94172d87 100644 --- a/src/components/Manage/User/Edit/GeneralForm.tsx +++ b/src/components/Manage/User/Edit/GeneralForm.tsx @@ -1,16 +1,16 @@ import { Box, Button, Group, TextInput, Title } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; import { IconAt, IconCheck, IconLetterCase } from '@tabler/icons-react'; -import { z } from 'zod'; import { useTranslation } from 'next-i18next'; +import { z } from 'zod'; import { api } from '~/utils/api'; export const ManageUserGeneralForm = ({ - userId, - defaultUsername, - defaultEmail, - }: { - userId: string + userId, + defaultUsername, + defaultEmail, +}: { + userId: string; defaultUsername: string; defaultEmail: string; }) => { @@ -22,8 +22,8 @@ export const ManageUserGeneralForm = ({ validate: zodResolver( z.object({ username: z.string(), - eMail: z.string().optional(), - }), + eMail: z.string().email().or(z.literal('')), + }) ), validateInputOnBlur: true, validateInputOnChange: true, @@ -35,6 +35,7 @@ export const ManageUserGeneralForm = ({ const { mutate, isLoading } = api.user.updateDetails.useMutation({ onSettled: async () => { await utils.user.invalidate(); + form.resetDirty(); }, }); @@ -42,32 +43,34 @@ export const ManageUserGeneralForm = ({ mutate({ userId: userId, username: form.values.username, - eMail: form.values.eMail + eMail: form.values.eMail, }); } return ( - - {t('sections.general.title')} - + {t('sections.general.title')}
} + icon={} label={t('sections.general.inputs.username.label')} - mb='md' + mb="md" withAsterisk {...form.getInputProps('username')} /> - } - label={t('sections.general.inputs.eMail.label')} {...form.getInputProps('eMail')} /> - + } + label={t('sections.general.inputs.eMail.label')} + {...form.getInputProps('eMail')} + /> + diff --git a/src/components/Manage/User/Edit/ManageUserDanger.tsx b/src/components/Manage/User/Edit/ManageUserDanger.tsx index f70585673da..c05328d287e 100644 --- a/src/components/Manage/User/Edit/ManageUserDanger.tsx +++ b/src/components/Manage/User/Edit/ManageUserDanger.tsx @@ -1,11 +1,17 @@ import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; import { IconTextSize, IconTrash } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; import { z } from 'zod'; import { api } from '~/utils/api'; -import { useTranslation } from 'next-i18next'; -export const ManageUserDanger = ({ userId, username }: { userId: string, username: string | null }) => { +export const ManageUserDanger = ({ + userId, + username, +}: { + userId: string; + username: string | null; +}) => { const form = useForm({ initialValues: { username: '', @@ -24,6 +30,9 @@ export const ManageUserDanger = ({ userId, username }: { userId: string, usernam const apiUtils = api.useUtils(); const { mutate, isLoading } = api.user.deleteUser.useMutation({ + onSuccess: () => { + window.location.href = '/manage/users'; + }, onSettled: () => { void apiUtils.user.details.invalidate(); form.reset(); @@ -41,9 +50,7 @@ export const ManageUserDanger = ({ userId, username }: { userId: string, usernam return ( - - {t('sections.deletion.title')} - + {t('sections.deletion.title')} } diff --git a/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx index 519ac535b9b..ac734e4875e 100644 --- a/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx +++ b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx @@ -1,9 +1,10 @@ import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; +import { useInputState } from '@mantine/hooks'; import { IconAlertTriangle, IconPassword } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; import { z } from 'zod'; import { api } from '~/utils/api'; -import { useTranslation } from 'next-i18next'; export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { const form = useForm({ @@ -23,6 +24,8 @@ export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { validateInputOnChange: true, }); + const [checked, setChecked] = useInputState(false); + const { t } = useTranslation(['manage/users/edit', 'common']); const apiUtils = api.useUtils(); @@ -40,14 +43,13 @@ export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { terminateExistingSessions: values.terminateExistingSessions, userId: userId, }); + setChecked(false); }; return ( - - {t('sections.security.title')} - + {t('sections.security.title')} } @@ -65,6 +67,10 @@ export const ManageUserSecurityForm = ({ userId }: { userId: string }) => { { + setChecked(event.currentTarget.checked); + }} {...form.getInputProps('confirm')} /> diff --git a/src/pages/manage/users/index.tsx b/src/pages/manage/users/index.tsx index e529b6e1cf0..6a2d08fad14 100644 --- a/src/pages/manage/users/index.tsx +++ b/src/pages/manage/users/index.tsx @@ -15,7 +15,14 @@ import { } from '@mantine/core'; import { useForm, zodResolver } from '@mantine/form'; import { useDebouncedValue } from '@mantine/hooks'; -import { IconPencil, IconUser, IconUserPlus, IconUserShield, IconUserStar, IconX } from '@tabler/icons-react'; +import { + IconPencil, + IconUser, + IconUserPlus, + IconUserShield, + IconUserStar, + IconX, +} from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; import { useTranslation } from 'next-i18next'; import Head from 'next/head'; @@ -62,7 +69,7 @@ const ManageUsersPage = () => { .string() .transform((value) => (value.length > 0 ? value : undefined)) .optional(), - }), + }) ), }); const [debouncedForm] = useDebouncedValue(form, 200); @@ -71,7 +78,7 @@ const ManageUsersPage = () => { search: debouncedForm.values, }); - const { t } = useTranslation('manage/users'); + const { t } = useTranslation(['manage/users', 'common']); const metaTitle = `${t('metaTitle')} • Homarr`; @@ -81,32 +88,32 @@ const ManageUsersPage = () => { {metaTitle} - {t('pageTitle')} + {t('pageTitle')} - + { form.setFieldValue('fullTextSearch', ''); }} - size='1rem' + size="1rem" /> } style={{ flexGrow: 1, }} - placeholder='Filter' - variant='filled' + placeholder="Filter" + variant="filled" {...form.getInputProps('fullTextSearch')} /> @@ -114,13 +121,13 @@ const ManageUsersPage = () => { - + Roles {PossibleRoleFilter.map((role) => ( } + icon={} rightSection={!isLoading && data && {data?.stats.roles[role.id]}} label={t(`filter.roles.${role.id}`)} active={form.values.role === role.id} @@ -135,74 +142,73 @@ const ManageUsersPage = () => { ))} - - - - - - - - +
{t('table.header.user')}{t('table.header.email')}
- {isLoading && ( - - - - )} - {data?.users.length === 0 && ( - - - - )} - {data?.users.map((user, index) => ( - - + - - + + )} + {data?.users.length === 0 && ( + + + + )} + {data?.users.map((user, index) => ( + + + + - - ))} + + + ))}
- - - -
- - {t('searchDoesntMatch')} - -
- - - - {user.name} - {user.isOwner && ( - - Owner - - )} - {user.isAdmin && ( - - Admin - - )} + {isLoading && ( +
+ + - - - {user.email ? {user.email} : No E-Mail} - - +
+ + {t('searchDoesntMatch')} + +
+ + + + + + {user.name} + {user.isOwner && ( + + Owner + + )} + {user.isAdmin && ( + + Admin + + )} + + + + {user.email ? ( + {user.email} + ) : ( + No E-Mail + )} + + + - -
- + { setActivePage((prev) => prev + 1);