feat: add user groups (#376)

* feat: add user groups

* wip: add unit tests

* wip: add more tests and normalized name for creation and update

* test: add unit tests for group router

* fix: type issues, missing mysql schema, rename column creator_id to owner_id

* fix: lint and format issues

* fix: deepsource issues

* fix: forgot to add log message

* fix: build not working

* chore: address pull request feedback

* feat: add mysql migration and fix merge conflicts

* fix: format issue and test issue
This commit is contained in:
Meier Lukas
2024-04-29 21:46:30 +02:00
committed by GitHub
parent 621f6c81ae
commit 036925bf78
50 changed files with 3333 additions and 132 deletions

View File

@@ -1,11 +1,12 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import type { SelectProps } from "@mantine/core";
import {
Button,
Flex,
Group,
Loader,
Select,
Stack,
Table,
@@ -241,7 +242,8 @@ interface FormType {
interface InnerProps {
presentUserIds: string[];
onSelect: (props: { id: string; name: string }) => void;
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
@@ -251,40 +253,45 @@ interface UserSelectFormType {
export const UserSelectModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const { data: users } = clientApi.user.selectable.useQuery();
const { data: users, isPending } = clientApi.user.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmit = (values: UserSelectFormType) => {
const handleSubmit = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
innerProps.onSelect({
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={t(
"board.setting.section.access.permission.userSelect.label",
)}
label={t("user.action.select.label")}
searchable
nothingFoundMessage={t(
"board.setting.section.access.permission.userSelect.notFound",
)}
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("user.action.select.notFound")}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button onClick={actions.closeModal}>
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.add")}</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>

View File

@@ -15,6 +15,7 @@ import {
IconTool,
IconUser,
IconUsers,
IconUsersGroup,
} from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server";
@@ -51,6 +52,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconMailForward,
href: "/manage/users/invites",
},
{
label: t("items.users.items.groups"),
icon: IconUsersGroup,
href: "/manage/users/groups",
},
],
},
{

View File

@@ -0,0 +1,80 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface DeleteGroupProps {
group: {
id: string;
name: string;
};
}
export const DeleteGroup = ({ group }: DeleteGroupProps) => {
const router = useRouter();
const { mutateAsync } = clientApi.group.deleteGroup.useMutation();
const { openConfirmModal } = useConfirmModal();
const tDelete = useScopedI18n("group.action.delete");
const tRoot = useI18n();
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tDelete("label"),
children: tDelete("confirm", {
name: group.name,
}),
async onConfirm() {
await mutateAsync(
{
id: group.id,
},
{
onSuccess() {
void revalidatePathAction("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tRoot("common.notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
},
onError() {
showErrorNotification({
title: tRoot("common.notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
},
},
);
},
});
}, [
tDelete,
tRoot,
openConfirmModal,
group.id,
group.name,
mutateAsync,
router,
]);
return (
<Button variant="subtle" color="red" onClick={handleDeletion}>
{tDelete("label")}
</Button>
);
};

View File

@@ -0,0 +1,26 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { NavLink } from "@mantine/core";
interface NavigationLinkProps {
href: string;
label: string;
icon: ReactNode;
}
export const NavigationLink = ({ href, icon, label }: NavigationLinkProps) => {
const pathName = usePathname();
return (
<NavLink
component={Link}
href={href}
active={pathName === href}
label={label}
leftSection={icon}
/>
);
};

View File

@@ -0,0 +1,83 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface RenameGroupFormProps {
group: {
id: string;
name: string;
};
}
export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.updateGroup.useMutation();
const form = useForm<FormType>({
initialValues: {
name: group.name,
},
});
const handleSubmit = useCallback(
(values: FormType) => {
mutate(
{
...values,
id: group.id,
},
{
onSuccess() {
void revalidatePathAction(`/users/groups/${group.id}`);
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("group.action.update.notification.success.message", {
name: values.name,
}),
});
},
onError() {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("group.action.update.notification.error.message", {
name: values.name,
}),
});
},
},
);
},
[group.id, mutate, t],
);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("group.field.name")}
{...form.getInputProps("name")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
interface FormType {
name: string;
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useCallback, useState } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access";
interface TransferGroupOwnershipProps {
group: {
id: string;
name: string;
ownerId: string | null;
};
}
export const TransferGroupOwnership = ({
group,
}: TransferGroupOwnershipProps) => {
const tTransfer = useScopedI18n("group.action.transfer");
const tRoot = useI18n();
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
const { openModal } = useModalAction(UserSelectModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.group.transferOwnership.useMutation();
const handleTransfer = useCallback(() => {
openModal(
{
confirmLabel: tRoot("common.action.continue"),
presentUserIds: innerOwnerId ? [innerOwnerId] : [],
onSelect: ({ id, name }) => {
openConfirmModal({
title: tTransfer("label"),
children: tTransfer("confirm", {
name: group.name,
username: name,
}),
onConfirm: async () => {
await mutateAsync(
{
groupId: group.id,
userId: id,
},
{
onSuccess() {
setInnerOwnerId(id);
showSuccessNotification({
title: tRoot("common.notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: name,
}),
});
},
onError() {
showErrorNotification({
title: tRoot("common.notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
},
);
},
});
},
},
{
title: tTransfer("label"),
},
);
}, [
group.id,
group.name,
innerOwnerId,
mutateAsync,
openConfirmModal,
openModal,
tRoot,
tTransfer,
]);
return (
<Button variant="subtle" color="red" onClick={handleTransfer}>
{tTransfer("label")}
</Button>
);
};

View File

@@ -0,0 +1,76 @@
import type { PropsWithChildren } from "react";
import Link from "next/link";
import {
Button,
Container,
Grid,
GridCol,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
params: { id: string };
}
export default async function Layout({
children,
params,
}: PropsWithChildren<LayoutProps>) {
const t = await getI18n();
const tGroup = await getScopedI18n("management.page.group");
const group = await api.group.getById({ id: params.id });
return (
<Container size="xl">
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={3}>{group.name}</Title>
<Text c="gray.5">{t("group.name")}</Text>
</Stack>
<Button
component={Link}
href="/manage/users/groups"
color="gray"
variant="light"
>
{tGroup("back")}
</Button>
</Group>
</GridCol>
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
<Stack>
<Stack gap={0}>
<NavigationLink
href={`/manage/users/groups/${params.id}`}
label={tGroup("setting.general.title")}
icon={<IconSettings size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/groups/${params.id}/members`}
label={tGroup("setting.members.title")}
icon={<IconUsersGroup size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/groups/${params.id}/permissions`}
label={tGroup("setting.permissions.title")}
icon={<IconLock size="1rem" stroke={1.5} />}
/>
</Stack>
</Stack>
</GridCol>
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
</Grid>
</Container>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface AddGroupMemberProps {
groupId: string;
presentUserIds: string[];
}
export const AddGroupMember = ({
groupId,
presentUserIds,
}: AddGroupMemberProps) => {
const tMembersAdd = useScopedI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const { openModal } = useModalAction(UserSelectModal);
const handleAddMember = useCallback(() => {
openModal(
{
async onSelect({ id }) {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathAction(
`/manage/users/groups/${groupId}}/members`,
);
},
presentUserIds,
},
{
title: tMembersAdd("label"),
},
);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
return (
<Button color="teal" onClick={handleAddMember}>
{tMembersAdd("label")}
</Button>
);
};

View File

@@ -0,0 +1,59 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface RemoveGroupMemberProps {
groupId: string;
user: { id: string; name: string | null };
}
export const RemoveGroupMember = ({
groupId,
user,
}: RemoveGroupMemberProps) => {
const t = useI18n();
const tRemoveMember = useScopedI18n("group.action.removeMember");
const { mutateAsync } = clientApi.group.removeMember.useMutation();
const { openConfirmModal } = useConfirmModal();
const handleRemove = useCallback(() => {
openConfirmModal({
title: tRemoveMember("label"),
children: tRemoveMember("confirm", {
user: user.name ?? "",
}),
onConfirm: async () => {
await mutateAsync({
groupId,
userId: user.id,
});
await revalidatePathAction(`/manage/users/groups/${groupId}/members`);
},
});
}, [
openConfirmModal,
mutateAsync,
groupId,
user.id,
user.name,
tRemoveMember,
]);
return (
<Button
variant="subtle"
color="red.9"
size="compact-sm"
onClick={handleRemove}
>
{t("common.action.remove")}
</Button>
);
};

View File

@@ -0,0 +1,99 @@
import Link from "next/link";
import {
Anchor,
Center,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTr,
Text,
Title,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { SearchInput, UserAvatar } from "@homarr/ui";
import { AddGroupMember } from "./_add-group-member";
import { RemoveGroupMember } from "./_remove-group-member";
interface GroupsDetailPageProps {
params: {
id: string;
};
searchParams: {
search: string | undefined;
};
}
export default async function GroupsDetailPage({
params,
searchParams,
}: GroupsDetailPageProps) {
const tMembers = await getScopedI18n("management.page.group.setting.members");
const group = await api.group.getById({ id: params.id });
const filteredMembers = searchParams.search
? group.members.filter((member) =>
member.name
?.toLowerCase()
.includes(searchParams.search!.trim().toLowerCase()),
)
: group.members;
return (
<Stack>
<Title>{tMembers("title")}</Title>
<Group justify="space-between">
<SearchInput
placeholder={`${tMembers("search")}...`}
defaultValue={searchParams.search}
/>
<AddGroupMember
groupId={group.id}
presentUserIds={group.members.map((member) => member.id)}
/>
</Group>
{filteredMembers.length === 0 && (
<Center py="sm">
<Text fw={500} c="gray.6">
{tMembers("notFound")}
</Text>
</Center>
)}
<Table striped highlightOnHover>
<TableTbody>
{filteredMembers.map((member) => (
<Row key={group.id} member={member} groupId={group.id} />
))}
</TableTbody>
</Table>
</Stack>
);
}
interface RowProps {
member: RouterOutputs["group"]["getPaginated"]["items"][number]["members"][number];
groupId: string;
}
const Row = ({ member, groupId }: RowProps) => {
return (
<TableTr>
<TableTd>
<Group>
<UserAvatar size="sm" user={member} />
<Anchor component={Link} href={`/manage/users/${member.id}`}>
{member.name}
</Anchor>
</Group>
</TableTd>
<TableTd w={100}>
<RemoveGroupMember user={member} groupId={groupId} />
</TableTd>
</TableTr>
);
};

View File

@@ -0,0 +1,75 @@
import {
Card,
CardSection,
Divider,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DeleteGroup } from "./_delete-group";
import { RenameGroupForm } from "./_rename-group-form";
import { TransferGroupOwnership } from "./_transfer-group-ownership";
interface GroupsDetailPageProps {
params: {
id: string;
};
}
export default async function GroupsDetailPage({
params,
}: GroupsDetailPageProps) {
const group = await api.group.getById({ id: params.id });
const tGeneral = await getScopedI18n("management.page.group.setting.general");
const tGroupAction = await getScopedI18n("group.action");
return (
<Stack>
<Title>{tGeneral("title")}</Title>
<RenameGroupForm group={group} />
<Stack gap="sm">
<Title c="red.8" order={2}>
{tGeneral("dangerZone")}
</Title>
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)" }}>
<Stack gap="sm">
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{tGroupAction("transfer.label")}
</Text>
<Text size="sm">{tGroupAction("transfer.description")}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<TransferGroupOwnership group={group} />
</Group>
</Group>
<CardSection>
<Divider />
</CardSection>
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{tGroupAction("delete.label")}
</Text>
<Text size="sm">{tGroupAction("delete.description")}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<DeleteGroup group={group} />
</Group>
</Group>
</Stack>
</Card>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useCallback } from "react";
import type { PropsWithChildren } from "react";
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissionKeys } from "@homarr/definitions";
import { createFormContext } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
interface PermissionFormProps {
initialPermissions: GroupPermissionKey[];
}
export const PermissionForm = ({
children,
initialPermissions,
}: PropsWithChildren<PermissionFormProps>) => {
const form = useForm({
initialValues: groupPermissionKeys.reduce((acc, key) => {
acc[key] = initialPermissions.includes(key);
return acc;
}, {} as FormType),
onValuesChange(values) {
const currentKeys = objectEntries(values)
.filter(([_key, value]) => Boolean(value))
.map(([key]) => key);
if (
currentKeys.every((key) => initialPermissions.includes(key)) &&
initialPermissions.every((key) => currentKeys.includes(key))
) {
form.resetDirty(); // Reset dirty state if all keys are the same as initial
}
},
});
return (
<form>
<FormProvider form={form}>{children}</FormProvider>
</form>
);
};
type FormType = {
[key in GroupPermissionKey]: boolean;
};
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
const form = useFormContext();
const props = form.getInputProps(name, {
withError: false,
type: "checkbox",
});
return <Switch {...props} />;
};
interface SaveAffixProps {
groupId: string;
}
export const SaveAffix = ({ groupId }: SaveAffixProps) => {
const t = useI18n();
const tForm = useScopedI18n("management.page.group.setting.permissions.form");
const tNotification = useScopedI18n(
"group.action.changePermissions.notification",
);
const form = useFormContext();
const { mutate, isPending } = clientApi.group.savePermissions.useMutation();
const handleSubmit = useCallback(() => {
const values = form.getValues();
mutate(
{
permissions: objectEntries(values)
.filter(([_, value]) => value)
.map(([key]) => key),
groupId,
},
{
onSuccess: () => {
// Set new initial values for discard and reset dirty state
form.setInitialValues(values);
showSuccessNotification({
title: tNotification("success.title"),
message: tNotification("success.message"),
});
},
onError() {
showErrorNotification({
title: tNotification("error.title"),
message: tNotification("error.message"),
});
},
},
);
}, [form, groupId, mutate, tNotification]);
return (
<div style={{ position: "sticky", bottom: 20 }}>
<Transition transition="slide-up" mounted={form.isDirty()}>
{(transitionStyles) => (
<Card style={transitionStyles} withBorder>
<Group justify="space-between">
<Text fw={500}>{tForm("unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} onClick={form.reset}>
{t("common.action.discard")}
</Button>
<Button color="teal" loading={isPending} onClick={handleSubmit}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</Transition>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import React from "react";
import {
Card,
CardSection,
Divider,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@homarr/api/server";
import { objectKeys } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissions } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import {
PermissionForm,
PermissionSwitch,
SaveAffix,
} from "./_group-permission-form";
interface GroupPermissionsPageProps {
params: {
id: string;
};
}
export default async function GroupPermissionsPage({
params,
}: GroupPermissionsPageProps) {
const group = await api.group.getById({ id: params.id });
const tPermissions = await getScopedI18n("group.permission");
const t = await getI18n();
return (
<Stack>
<Title>{t("management.page.group.setting.permissions.title")}</Title>
<PermissionForm initialPermissions={group.permissions}>
<Stack pos="relative">
{objectKeys(groupPermissions).map((group) => {
const isDanger = group === "admin";
return (
<Stack key={group} gap="sm">
<Title order={2} c={isDanger ? "red.8" : undefined}>
{tPermissions(`${group}.title`)}
</Title>
<PermissionCard isDanger={isDanger} group={group} />
</Stack>
);
})}
<SaveAffix groupId={group.id} />
</Stack>
</PermissionForm>
</Stack>
);
}
interface PermissionCardProps {
group: keyof typeof groupPermissions;
isDanger: boolean;
}
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
const t = await getScopedI18n(`group.permission.${group}.item`);
const item = groupPermissions[group];
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
return (
<Card
p="md"
withBorder
style={{
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
}}
>
<Stack gap="sm">
{permissions.map((permission, index) => (
<React.Fragment key={permission}>
<PermissionRow
name={createGroupPermissionKey(group, permission)}
label={t(`${permission}.label`)}
description={t(`${permission}.description`)}
/>
{index < permissions.length - 1 && (
<CardSection>
<Divider />
</CardSection>
)}
</React.Fragment>
))}
</Stack>
</Card>
);
};
const createGroupPermissionKey = (
group: keyof typeof groupPermissions,
permission: string,
): GroupPermissionKey => {
if (typeof groupPermissions[group] === "boolean") {
return group as GroupPermissionKey;
}
return `${group}-${permission}` as GroupPermissionKey;
};
interface PermissionRowProps {
name: GroupPermissionKey;
label: string;
description: string;
}
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
return (
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text fw={500}>{label}</Text>
<Text c="gray.5">{description}</Text>
</Stack>
<PermissionSwitch name={name} />
</Group>
);
};

View File

@@ -0,0 +1,81 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
export const AddGroup = () => {
const t = useI18n();
const { openModal } = useModalAction(AddGroupModal);
const handleAddGroup = useCallback(() => {
openModal();
}, [openModal]);
return (
<Button onClick={handleAddGroup} color="teal">
{t("group.action.create.label")}
</Button>
);
};
const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useForm({
initialValues: {
name: "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathAction("/manage/users/groups");
showSuccessNotification({
title: t("common.notification.create.success"),
message: t("group.action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("common.notification.create.error"),
message: t("group.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput
label={t("group.field.name")}
data-autofocus
{...form.getInputProps("name")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button loading={isPending} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});

View File

@@ -0,0 +1,102 @@
import Link from "next/link";
import {
Anchor,
Container,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { z } from "@homarr/validation";
import { AddGroup } from "./_add-group";
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<
TSchema extends Record<string, unknown>,
> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[]
? string[]
: string;
}>;
interface GroupsListPageProps {
searchParams: SearchParamsSchemaInputFromSchema<
z.infer<typeof searchParamsSchema>
>;
}
export default async function GroupsListPage(props: GroupsListPageProps) {
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: groups, totalCount } =
await api.group.getPaginated(searchParams);
return (
<Container size="xl">
<Stack>
<Title>{t("group.title")}</Title>
<Group justify="space-between">
<SearchInput
placeholder={`${t("group.search")}...`}
defaultValue={searchParams.search}
/>
<AddGroup />
</Group>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>{t("group.field.name")}</TableTh>
<TableTh>{t("group.field.members")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{groups.map((group) => (
<Row key={group.id} group={group} />
))}
</TableTbody>
</Table>
<Group justify="end">
<TablePagination
total={Math.ceil(totalCount / searchParams.pageSize)}
/>
</Group>
</Stack>
</Container>
);
}
interface RowProps {
group: RouterOutputs["group"]["getPaginated"]["items"][number];
}
const Row = ({ group }: RowProps) => {
return (
<TableTr>
<TableTd>
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
{group.name}
</Anchor>
</TableTd>
<TableTd>
<UserAvatarGroup users={group.members} size="sm" limit={5} />
</TableTd>
</TableTr>
);
};