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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"),
|
||||
});
|
||||
102
apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx
Normal file
102
apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user