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