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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user