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";
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 = () => {
</Card>
</form>
</Stepper.Step>
<Stepper.Step
label={t("step.permissions.label")}
description={t("step.permissions.description")}
allowStepSelect={false}
allowStepClick={false}
>
3
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl">
<GroupsForm
initialGroups={initialGroups}
addGroup={(groupId) =>
groupsForm.setValues((value) => ({ groups: value.groups?.concat(groupId) ?? [groupId] }))
}
removeGroup={(groupId) => {
groupsForm.setValues((value) => ({ groups: value.groups?.filter((group) => group !== groupId) ?? [] }));
}}
/>
</Card>
</Stepper.Step>
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<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 { 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 (
<>
<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 { 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<void>;
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string;
}
@@ -18,7 +20,9 @@ interface GroupSelectFormType {
export const GroupSelectModal = createModal<InnerProps>(({ 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<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
@@ -28,6 +32,7 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);

View File

@@ -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(

View File

@@ -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<typeof validation.user.create>) => {
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.baseCreate>) => {
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);

View File

@@ -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"

View File

@@ -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,