feat: add permission section to create user page (#1524)

* feat: add permission section to create user page

* fix: deepsource issues
This commit is contained in:
Meier Lukas
2024-11-23 17:18:29 +01:00
committed by GitHub
parent 8fea983c2e
commit 1fc48f9db0
7 changed files with 232 additions and 32 deletions

View File

@@ -1,20 +1,48 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { startTransition, useCallback, useMemo, useState } from "react";
import { Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core"; import {
import { IconUserCheck } from "@tabler/icons-react"; 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 { clientApi } from "@homarr/api/client";
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications"; 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 { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { validation, z } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form"; import { createCustomErrorParams } from "@homarr/validation/form";
import { GroupSelectModal } from "~/components/access/group-select-modal";
import { StepperNavigationComponent } from "./stepper-navigation"; 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 t = useScopedI18n("management.page.user.create");
const tUserField = useScopedI18n("user.field"); 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 activeForm = allForms[active];
const isCurrentFormValid = activeForm ? activeForm.isValid : () => true; const isCurrentFormValid = activeForm ? activeForm.isValid : () => true;
@@ -86,10 +125,11 @@ export const UserCreateStepperComponent = () => {
email: generalForm.values.email, email: generalForm.values.email,
password: securityForm.values.password, password: securityForm.values.password,
confirmPassword: securityForm.values.confirmPassword, confirmPassword: securityForm.values.confirmPassword,
groupIds: groupsForm.values.groups,
}); });
} }
nextStep(); nextStep();
}, [active, generalForm, mutateAsync, securityForm, nextStep]); }, [active, generalForm, securityForm, groupsForm, mutateAsync, nextStep]);
const reset = useCallback(() => { const reset = useCallback(() => {
setActive(0); setActive(0);
@@ -144,13 +184,18 @@ export const UserCreateStepperComponent = () => {
</Card> </Card>
</form> </form>
</Stepper.Step> </Stepper.Step>
<Stepper.Step <Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
label={t("step.permissions.label")} <Card p="xl">
description={t("step.permissions.description")} <GroupsForm
allowStepSelect={false} initialGroups={initialGroups}
allowStepClick={false} addGroup={(groupId) =>
> groupsForm.setValues((value) => ({ groups: value.groups?.concat(groupId) ?? [groupId] }))
3 }
removeGroup={(groupId) => {
groupsForm.setValues((value) => ({ groups: value.groups?.filter((group) => group !== groupId) ?? [] }));
}}
/>
</Card>
</Stepper.Step> </Stepper.Step>
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl"> <Card p="xl">
@@ -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<GroupWithPermissions>(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 (
<form>
<Stack>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={500}>{t("management.page.user.create.step.groups.title")}</Text>
<Text size="sm" c="gray.6">
{t("management.page.user.create.step.groups.description", { everyoneGroup })}
</Text>
</Stack>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
onClick={handleAddClick}
>
{t("common.action.add")}
</Button>
</Group>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("group.field.name")}</Table.Th>
<Table.Th>{t("permission.title")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{groups.map((group) => (
<Table.Tr key={group.id}>
<Table.Td>{group.name}</Table.Td>
<Table.Td w="100%">
<Group gap="xs">
{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 }) => (
<PermissionBadge key={`${key}-${value}`} category={key} value={value} />
))}
</Group>
</Table.Td>
<Table.Td>
{group.name !== everyoneGroup && (
<Button variant="subtle" onClick={() => handleGroupRemove(group.id)}>
{t("common.action.remove")}
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</form>
);
};
const PermissionBadge = ({ category, value }: { category: string; value: string }) => {
const t = useI18n();
return (
<Tooltip label={t(`group.permission.${category}.item.${value}.description` as never)}>
<Badge color={category === "admin" ? "red" : "blue"} size="sm" variant="dot">
{t(`group.permission.${category}.item.${value}.label` as never)}
</Badge>
</Tooltip>
);
};

View File

@@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; 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 { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -33,10 +36,22 @@ export default async function CreateUserPage() {
return notFound(); return notFound();
} }
const initialGroups = await db.query.groups.findMany({
where: inArray(groups.name, [everyoneGroup]),
with: {
permissions: true,
},
});
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<UserCreateStepperComponent /> <UserCreateStepperComponent
initialGroups={initialGroups.map((group) => ({
...group,
permissions: group.permissions.map(({ permission }) => permission),
}))}
/>
</> </>
); );
} }

View File

@@ -2,13 +2,15 @@ import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core"; import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals"; import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface InnerProps { interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[]; presentGroupIds: string[];
onSelect: (props: { id: string; name: string }) => void | Promise<void>; onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -18,7 +20,9 @@ interface GroupSelectFormType {
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n(); 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 [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>(); const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => { const handleSubmitAsync = async (values: GroupSelectFormType) => {
@@ -28,6 +32,7 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
await innerProps.onSelect({ await innerProps.onSelect({
id: currentGroup.id, id: currentGroup.id,
name: currentGroup.name, name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
}); });
setLoading(false); setLoading(false);

View File

@@ -100,14 +100,33 @@ export const groupRouter = createTRPCRouter({
}; };
}), }),
// Is protected because also used in board access / integration access forms // Is protected because also used in board access / integration access forms
selectable: protectedProcedure.query(async ({ ctx }) => { selectable: protectedProcedure
return await ctx.db.query.groups.findMany({ .input(z.object({ withPermissions: z.boolean().default(false) }).optional())
columns: { .query(async ({ ctx, input }) => {
id: true, const withPermissions = input?.withPermissions && ctx.session.user.permissions.includes("admin");
name: true,
}, 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 search: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.input( .input(

View File

@@ -78,7 +78,11 @@ export const userRouter = createTRPCRouter({
throwIfCredentialsDisabled(); throwIfCredentialsDisabled();
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username); 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 setProfileImage: protectedProcedure
.input( .input(
@@ -459,7 +463,7 @@ export const userRouter = createTRPCRouter({
}), }),
}); });
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => { const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.baseCreate>) => {
const salt = await createSaltAsync(); const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt); const hashedPassword = await hashPasswordAsync(input.password, salt);

View File

@@ -1943,9 +1943,10 @@
"security": { "security": {
"label": "Security" "label": "Security"
}, },
"permissions": { "groups": {
"label": "Permissions", "label": "Groups",
"description": "Coming soon" "title": "Select all groups user should be member of",
"description": "The {everyoneGroup} group is assigned to all users and can not be removed."
}, },
"review": { "review": {
"label": "Review" "label": "Review"

View File

@@ -49,7 +49,7 @@ const confirmPasswordRefine = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
] satisfies [(args: any) => boolean, unknown]; ] satisfies [(args: any) => boolean, unknown];
const createUserSchema = z const baseCreateUserSchema = z
.object({ .object({
username: usernameSchema, username: usernameSchema,
password: passwordSchema, password: passwordSchema,
@@ -58,7 +58,9 @@ const createUserSchema = z
}) })
.refine(confirmPasswordRefine[0], confirmPasswordRefine[1]); .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({ const signInSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@@ -124,6 +126,7 @@ export const userSchemas = {
registrationApi: registrationSchemaApi, registrationApi: registrationSchemaApi,
init: initUserSchema, init: initUserSchema,
create: createUserSchema, create: createUserSchema,
baseCreate: baseCreateUserSchema,
password: passwordSchema, password: passwordSchema,
editProfile: editProfileSchema, editProfile: editProfileSchema,
changePassword: changePasswordSchema, changePassword: changePasswordSchema,