From 1fc48f9db05935bc3298f9251b564ed539c48312 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 23 Nov 2024 17:18:29 +0100 Subject: [PATCH] feat: add permission section to create user page (#1524) * feat: add permission section to create user page * fix: deepsource issues --- .../_components/create-user-stepper.tsx | 181 ++++++++++++++++-- .../app/[locale]/manage/users/create/page.tsx | 17 +- .../components/access/group-select-modal.tsx | 9 +- packages/api/src/router/group.ts | 35 +++- packages/api/src/router/user.ts | 8 +- packages/translation/src/lang/en.json | 7 +- packages/validation/src/user.ts | 7 +- 7 files changed, 232 insertions(+), 32 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index fc1425f21..ecf9397ef 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -1,20 +1,48 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; -import { Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core"; -import { IconUserCheck } from "@tabler/icons-react"; +import { startTransition, useCallback, useMemo, useState } from "react"; +import { + Badge, + Button, + Card, + Group, + PasswordInput, + Stack, + Stepper, + Table, + Text, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import { useListState } from "@mantine/hooks"; +import { IconPlus, IconUserCheck } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { everyoneGroup, groupPermissions } from "@homarr/definitions"; +import type { GroupPermissionKey } from "@homarr/definitions"; import { useZodForm } from "@homarr/form"; +import { useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { CustomPasswordInput, UserAvatar } from "@homarr/ui"; import { validation, z } from "@homarr/validation"; import { createCustomErrorParams } from "@homarr/validation/form"; +import { GroupSelectModal } from "~/components/access/group-select-modal"; import { StepperNavigationComponent } from "./stepper-navigation"; -export const UserCreateStepperComponent = () => { +interface GroupWithPermissions { + id: string; + name: string; + permissions: GroupPermissionKey[]; +} + +interface UserCreateStepperComponentProps { + initialGroups: GroupWithPermissions[]; +} + +export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperComponentProps) => { const t = useScopedI18n("management.page.user.create"); const tUserField = useScopedI18n("user.field"); @@ -73,7 +101,18 @@ export const UserCreateStepperComponent = () => { }, ); - const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]); + const groupsForm = useZodForm( + z.object({ + groups: z.array(z.string()), + }), + { + initialValues: { + groups: initialGroups.map((group) => group.id), + }, + }, + ); + + const allForms = useMemo(() => [generalForm, securityForm, groupsForm], [generalForm, securityForm, groupsForm]); const activeForm = allForms[active]; const isCurrentFormValid = activeForm ? activeForm.isValid : () => true; @@ -86,10 +125,11 @@ export const UserCreateStepperComponent = () => { email: generalForm.values.email, password: securityForm.values.password, confirmPassword: securityForm.values.confirmPassword, + groupIds: groupsForm.values.groups, }); } nextStep(); - }, [active, generalForm, mutateAsync, securityForm, nextStep]); + }, [active, generalForm, securityForm, groupsForm, mutateAsync, nextStep]); const reset = useCallback(() => { setActive(0); @@ -144,13 +184,18 @@ export const UserCreateStepperComponent = () => { - - 3 + + + + groupsForm.setValues((value) => ({ groups: value.groups?.concat(groupId) ?? [groupId] })) + } + removeGroup={(groupId) => { + groupsForm.setValues((value) => ({ groups: value.groups?.filter((group) => group !== groupId) ?? [] })); + }} + /> + @@ -183,3 +228,111 @@ export const UserCreateStepperComponent = () => { ); }; + +interface GroupsFormProps { + addGroup: (groupId: string) => void; + removeGroup: (groupId: string) => void; + initialGroups: GroupWithPermissions[]; +} + +const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) => { + const t = useI18n(); + const [groups, { append, filter }] = useListState(initialGroups); + const { openModal } = useModalAction(GroupSelectModal); + + const handleAddClick = () => { + openModal({ + presentGroupIds: groups.map((group) => group.id), + withPermissions: true, + onSelect({ id, name, permissions }) { + if (!permissions) return; + + startTransition(() => { + addGroup(id); + append({ id, name, permissions }); + }); + }, + }); + }; + + const handleGroupRemove = (id: string) => { + filter((group) => group.id !== id); + removeGroup(id); + }; + + return ( +
+ + + + {t("management.page.user.create.step.groups.title")} + + {t("management.page.user.create.step.groups.description", { everyoneGroup })} + + + + + + + + {t("group.field.name")} + {t("permission.title")} + + + + + {groups.map((group) => ( + + {group.name} + + + {Object.entries(groupPermissions) + .flatMap(([key, values]) => + Array.isArray(values) + ? values.map((value) => ({ key, value: value as string })) + : [{ key, value: key }], + ) + .filter(({ key, value }) => + group.permissions.some( + (permission) => permission === (key === value ? key : `${key}-${value}`), + ), + ) + .map(({ key, value }) => ( + + ))} + + + + {group.name !== everyoneGroup && ( + + )} + + + ))} + +
+
+
+ ); +}; + +const PermissionBadge = ({ category, value }: { category: string; value: string }) => { + const t = useI18n(); + + return ( + + + {t(`group.permission.${category}.item.${value}.label` as never)} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx index d076c2e37..4c49b6595 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx @@ -2,6 +2,9 @@ import { notFound } from "next/navigation"; import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; +import { db, inArray } from "@homarr/db"; +import { groups } from "@homarr/db/schema/sqlite"; +import { everyoneGroup } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; @@ -33,10 +36,22 @@ export default async function CreateUserPage() { return notFound(); } + const initialGroups = await db.query.groups.findMany({ + where: inArray(groups.name, [everyoneGroup]), + with: { + permissions: true, + }, + }); + return ( <> - + ({ + ...group, + permissions: group.permissions.map(({ permission }) => permission), + }))} + /> ); } diff --git a/apps/nextjs/src/components/access/group-select-modal.tsx b/apps/nextjs/src/components/access/group-select-modal.tsx index 62b37ab0f..ba0befb21 100644 --- a/apps/nextjs/src/components/access/group-select-modal.tsx +++ b/apps/nextjs/src/components/access/group-select-modal.tsx @@ -2,13 +2,15 @@ import { useState } from "react"; import { Button, Group, Loader, Select, Stack } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; +import type { GroupPermissionKey } from "@homarr/definitions"; import { useForm } from "@homarr/form"; import { createModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; interface InnerProps { + withPermissions?: boolean; presentGroupIds: string[]; - onSelect: (props: { id: string; name: string }) => void | Promise; + onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise; confirmLabel?: string; } @@ -18,7 +20,9 @@ interface GroupSelectFormType { export const GroupSelectModal = createModal(({ actions, innerProps }) => { const t = useI18n(); - const { data: groups, isPending } = clientApi.group.selectable.useQuery(); + const { data: groups, isPending } = clientApi.group.selectable.useQuery({ + withPermissions: innerProps.withPermissions, + }); const [loading, setLoading] = useState(false); const form = useForm(); const handleSubmitAsync = async (values: GroupSelectFormType) => { @@ -28,6 +32,7 @@ export const GroupSelectModal = createModal(({ actions, innerProps } await innerProps.onSelect({ id: currentGroup.id, name: currentGroup.name, + permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined, }); setLoading(false); diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 366c8d590..83a0f8868 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -100,14 +100,33 @@ export const groupRouter = createTRPCRouter({ }; }), // Is protected because also used in board access / integration access forms - selectable: protectedProcedure.query(async ({ ctx }) => { - return await ctx.db.query.groups.findMany({ - columns: { - id: true, - name: true, - }, - }); - }), + selectable: protectedProcedure + .input(z.object({ withPermissions: z.boolean().default(false) }).optional()) + .query(async ({ ctx, input }) => { + const withPermissions = input?.withPermissions && ctx.session.user.permissions.includes("admin"); + + if (!withPermissions) { + return await ctx.db.query.groups.findMany({ + columns: { + id: true, + name: true, + }, + }); + } + + const groups = await ctx.db.query.groups.findMany({ + columns: { + id: true, + name: true, + }, + with: { permissions: { columns: { permission: true } } }, + }); + + return groups.map((group) => ({ + ...group, + permissions: group.permissions.map((permission) => permission.permission), + })); + }), search: permissionRequiredProcedure .requiresPermission("admin") .input( diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 805140540..be6f1abe7 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -78,7 +78,11 @@ export const userRouter = createTRPCRouter({ throwIfCredentialsDisabled(); await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username); - await createUserAsync(ctx.db, input); + const userId = await createUserAsync(ctx.db, input); + + if (input.groupIds.length >= 1) { + await ctx.db.insert(groupMembers).values(input.groupIds.map((groupId) => ({ groupId, userId }))); + } }), setProfileImage: protectedProcedure .input( @@ -459,7 +463,7 @@ export const userRouter = createTRPCRouter({ }), }); -const createUserAsync = async (db: Database, input: z.infer) => { +const createUserAsync = async (db: Database, input: z.infer) => { const salt = await createSaltAsync(); const hashedPassword = await hashPasswordAsync(input.password, salt); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index c50a77c28..d09867d96 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1943,9 +1943,10 @@ "security": { "label": "Security" }, - "permissions": { - "label": "Permissions", - "description": "Coming soon" + "groups": { + "label": "Groups", + "title": "Select all groups user should be member of", + "description": "The {everyoneGroup} group is assigned to all users and can not be removed." }, "review": { "label": "Review" diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 65bac9500..629f15ec4 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -49,7 +49,7 @@ const confirmPasswordRefine = [ // eslint-disable-next-line @typescript-eslint/no-explicit-any ] satisfies [(args: any) => boolean, unknown]; -const createUserSchema = z +const baseCreateUserSchema = z .object({ username: usernameSchema, password: passwordSchema, @@ -58,7 +58,9 @@ const createUserSchema = z }) .refine(confirmPasswordRefine[0], confirmPasswordRefine[1]); -const initUserSchema = createUserSchema; +const createUserSchema = baseCreateUserSchema.and(z.object({ groupIds: z.array(z.string()) })); + +const initUserSchema = baseCreateUserSchema; const signInSchema = z.object({ name: z.string().min(1), @@ -124,6 +126,7 @@ export const userSchemas = { registrationApi: registrationSchemaApi, init: initUserSchema, create: createUserSchema, + baseCreate: baseCreateUserSchema, password: passwordSchema, editProfile: editProfileSchema, changePassword: changePasswordSchema,