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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user