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..543853b5bb8 --- /dev/null +++ b/public/locales/en/manage/users/edit.json @@ -0,0 +1,55 @@ +{ + "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 new file mode 100644 index 00000000000..f5c94172d87 --- /dev/null +++ b/src/components/Manage/User/Edit/GeneralForm.tsx @@ -0,0 +1,81 @@ +import { Box, Button, Group, TextInput, Title } from '@mantine/core'; +import { useForm, zodResolver } from '@mantine/form'; +import { IconAt, IconCheck, IconLetterCase } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { z } from 'zod'; +import { api } from '~/utils/api'; + +export const ManageUserGeneralForm = ({ + userId, + defaultUsername, + defaultEmail, +}: { + userId: string; + defaultUsername: string; + defaultEmail: string; +}) => { + const form = useForm({ + initialValues: { + username: defaultUsername, + eMail: defaultEmail, + }, + validate: zodResolver( + z.object({ + username: z.string(), + eMail: z.string().email().or(z.literal('')), + }) + ), + validateInputOnBlur: 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(); + form.resetDirty(); + }, + }); + + function handleSubmit() { + mutate({ + userId: userId, + username: form.values.username, + eMail: form.values.eMail, + }); + } + + return ( + + {t('sections.general.title')} +
+ } + label={t('sections.general.inputs.username.label')} + mb="md" + withAsterisk + {...form.getInputProps('username')} + /> + } + 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 new file mode 100644 index 00000000000..c05328d287e --- /dev/null +++ b/src/components/Manage/User/Edit/ManageUserDanger.tsx @@ -0,0 +1,83 @@ +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'; + +export const ManageUserDanger = ({ + userId, + username, +}: { + userId: string; + username: string | null; +}) => { + 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({ + onSuccess: () => { + window.location.href = '/manage/users'; + }, + onSettled: () => { + void apiUtils.user.details.invalidate(); + form.reset(); + }, + }); + + const { t } = useTranslation(['manage/users/edit', 'common']); + + const handleSubmit = () => { + mutate({ + id: userId, + }); + }; + + return ( + + + {t('sections.deletion.title')} +
+ } + label={t('sections.deletion.inputs.confirmUsername.label')} + description={t('sections.deletion.inputs.confirmUsername.description')} + mb="md" + withAsterisk + {...form.getInputProps('username')} + /> + + + + + +
+ ); +}; diff --git a/src/components/Manage/User/Edit/ManageUserRoles.tsx b/src/components/Manage/User/Edit/ManageUserRoles.tsx new file mode 100644 index 00000000000..0a4443b5257 --- /dev/null +++ b/src/components/Manage/User/Edit/ManageUserRoles.tsx @@ -0,0 +1,65 @@ +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/edit', 'manage/users']); + const { data: sessionData } = useSession(); + return ( + + + {t('sections.roles.title')} + + + + {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 ? ( + + ) : ( + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx new file mode 100644 index 00000000000..ac734e4875e --- /dev/null +++ b/src/components/Manage/User/Edit/ManageUserSecurityForm.tsx @@ -0,0 +1,91 @@ +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'; + +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 [checked, setChecked] = useInputState(false); + + const { t } = useTranslation(['manage/users/edit', 'common']); + + 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, + }); + setChecked(false); + }; + + return ( + + + {t('sections.security.title')} +
+ } + label={t('sections.security.inputs.password.label')} + mb="md" + withAsterisk + {...form.getInputProps('password')} + /> + + { + setChecked(event.currentTarget.checked); + }} + {...form.getInputProps('confirm')} + /> + + + + +
+ ); +}; 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 new file mode 100644 index 00000000000..ff1989c7d09 --- /dev/null +++ b/src/pages/manage/users/[userId]/edit.tsx @@ -0,0 +1,90 @@ +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'; +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'; +import { ManageUserRoles } from '~/components/Manage/User/Edit/ManageUserRoles'; + +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} + + + + + + + {t('back')} + + + + + {data?.name?.slice(0, 2).toUpperCase()} + {data?.name} + + + {data ? ( + + + + + + + + + + ) : ( + + )} + + ); +}; + +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 8b90a3505c6..6a2d08fad14 100644 --- a/src/pages/manage/users/index.tsx +++ b/src/pages/manage/users/index.tsx @@ -1,28 +1,34 @@ 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, + 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 { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal'; -import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal'; +import { z } from 'zod'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { getServerAuthSession } from '~/server/auth'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; @@ -30,17 +36,49 @@ import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { api } from '~/utils/api'; +export 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, }); - const { data: sessionData } = useSession(); - const { t } = useTranslation('manage/users'); + const { t } = useTranslation(['manage/users', 'common']); const metaTitle = `${t('metaTitle')} • Homarr`; @@ -51,118 +89,127 @@ 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) => ( - - + - ))} - - {debouncedSearch && debouncedSearch.length > 0 && data.countPages === 0 && ( + )} + {data?.users.length === 0 && ( - )} + {data?.users.map((user, index) => ( + + + + + + ))}
{t('table.header.user')}
- - - - {user.name} - {user.isOwner && ( - - Owner - - )} - {user.isAdmin && ( - - Admin - - )} - - - {user.isAdmin ? ( - - { - openRoleChangeModal({ - ...user, - type: 'demote', - }); - }} - > - - - - ) : ( - - { - openRoleChangeModal({ - ...user, - type: 'promote', - }); - }} - > - - - - )} - - - { - openDeleteUserModal(user); - }} - color="red" - variant="light" - > - - - - + {isLoading && ( +
+ +
- - {t('searchDoesntMatch')} - + + + {t('searchDoesntMatch')} +
+ + + + + + {user.name} + {user.isOwner && ( + + Owner + + )} + {user.isAdmin && ( + + Admin + + )} + + + + {user.email ? ( + {user.email} + ) : ( + No E-Mail + )} + + + + +
+
+ { setActivePage((prev) => prev + 1); }} @@ -172,9 +219,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..fcfb9d93c81 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 { invites, sessions, users, userSettings, UserSettings } from '~/server/db/schema'; import { hashPassword } from '~/utils/security'; import { colorSchemeParser, @@ -13,9 +21,7 @@ import { signUpFormSchema, updateSettingsValidationSchema, } from '~/validations/user'; - -import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants'; -import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; +import { PossibleRoleFilter } from '~/pages/manage/users'; export const userRouter = createTRPCRouter({ createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => { @@ -34,6 +40,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(); }), @@ -42,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({ @@ -75,7 +122,7 @@ export const userRouter = createTRPCRouter({ .input( z.object({ colorScheme: colorSchemeParser, - }) + }), ) .mutation(async ({ ctx, input }) => { await db @@ -122,7 +169,7 @@ export const userRouter = createTRPCRouter({ .input( z.object({ language: z.string(), - }) + }), ) .mutation(async ({ ctx, input }) => { await db @@ -184,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 { @@ -213,17 +284,54 @@ 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 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({ id: z.string(), - }) + }), ) .mutation(async ({ ctx, input }) => { const user = await db.query.users.findFirst({ @@ -259,7 +367,7 @@ const createUserIfNotPresent = async ( options: { defaultSettings?: Partial; isOwner?: boolean; - } | void + } | void, ) => { const existingUser = await db.query.users.findFirst({ where: eq(users.name, input.username), 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'];