feat: board access group permissions (#422)
* fix: cache is not exportet from react * fix: format issue * wip: add usage of group permissions * feat: show inherited groups and add manage group * refactor: improve board access management * chore: address pull request feedback * fix: type issues * fix: migrations * test: add unit tests for board permissions, permissions and board router * test: add unit tests for board router and get current user permissions method * fix: format issues * fix: deepsource issue
This commit is contained in:
@@ -1,40 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Group, Stack, Tabs } from "@mantine/core";
|
||||
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { boardPermissions } from "@homarr/definitions";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { CountBadge } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { GroupsForm } from "./_access/group-access";
|
||||
import { InheritTable } from "./_access/inherit-access";
|
||||
import { UsersForm } from "./_access/user-access";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
@@ -54,251 +33,73 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
permissions: permissions.sort((permissionA, permissionB) => {
|
||||
if (permissionA.user.id === board.creatorId) return -1;
|
||||
if (permissionB.user.id === board.creatorId) return 1;
|
||||
return permissionA.user.name.localeCompare(permissionB.user.name);
|
||||
}),
|
||||
},
|
||||
const [counts, setCounts] = useState({
|
||||
user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
|
||||
group: initialPermissions.groupPermissions.length,
|
||||
});
|
||||
const { mutate, isPending } =
|
||||
clientApi.board.saveBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: FormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: board.id,
|
||||
permissions: values.permissions,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.board.getBoardPermissions.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[board.id, mutate, utils.board.getBoardPermissions],
|
||||
);
|
||||
|
||||
const handleAddUser = useCallback(() => {
|
||||
const presentUserIds = form.values.permissions.map(
|
||||
(permission) => permission.user.id,
|
||||
);
|
||||
|
||||
openModal({
|
||||
presentUserIds: board.creatorId
|
||||
? presentUserIds.concat(board.creatorId)
|
||||
: presentUserIds,
|
||||
onSelect: (user) => {
|
||||
form.setFieldValue("permissions", [
|
||||
...form.values.permissions,
|
||||
{
|
||||
user,
|
||||
permission: "board-view",
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
}, [form, openModal, board.creatorId]);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>
|
||||
{t("board.setting.section.access.permission.field.user.label")}
|
||||
</TableTh>
|
||||
<TableTh>
|
||||
{t(
|
||||
"board.setting.section.access.permission.field.permission.label",
|
||||
)}
|
||||
</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{board.creator && <CreatorRow user={board.creator} />}
|
||||
{form.values.permissions.map((row, index) => {
|
||||
const Icon = icons[row.permission];
|
||||
return (
|
||||
<TableTr key={row.user.id}>
|
||||
<TableTd>{row.user.name}</TableTd>
|
||||
<TableTd>
|
||||
<Group wrap="nowrap">
|
||||
<Select
|
||||
flex="1"
|
||||
leftSection={<Icon size="1rem" />}
|
||||
renderOption={RenderOption}
|
||||
variant="unstyled"
|
||||
data={boardPermissions.map((permission) => ({
|
||||
value: permission,
|
||||
label: t(
|
||||
`board.setting.section.access.permission.item.${permission}.label`,
|
||||
),
|
||||
}))}
|
||||
{...form.getInputProps(
|
||||
`permissions.${index}.permission`,
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setFieldValue(
|
||||
"permissions",
|
||||
form.values.permissions.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
})}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
rightSection={<IconPlus size="1rem" />}
|
||||
variant="light"
|
||||
onClick={handleAddUser}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreatorRowProps {
|
||||
user: Exclude<Board["creator"], null>;
|
||||
}
|
||||
|
||||
const CreatorRow = ({ user }: CreatorRowProps) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>{user.name}</TableTd>
|
||||
<TableTd>
|
||||
<Group gap={0}>
|
||||
<Flex w={34} h={34} align="center" justify="center">
|
||||
<IconSettings
|
||||
size="1rem"
|
||||
color="var(--input-section-color, var(--mantine-color-dimmed))"
|
||||
/>
|
||||
</Flex>
|
||||
<Text size="sm">
|
||||
{t("board.setting.section.access.permission.item.board-full.label")}
|
||||
</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
const icons = {
|
||||
"board-change": IconPencil,
|
||||
"board-view": IconEye,
|
||||
} satisfies Record<BoardPermission, TablerIcon>;
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: "1rem",
|
||||
};
|
||||
|
||||
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
||||
const Icon = icons[option.value as BoardPermission];
|
||||
return (
|
||||
<Group flex="1" gap="xs">
|
||||
<Icon {...iconProps} />
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormType {
|
||||
permissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
presentUserIds: string[];
|
||||
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface UserSelectFormType {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const UserSelectModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: users, isPending } = clientApi.user.selectable.useQuery();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<UserSelectFormType>();
|
||||
const handleSubmit = async (values: UserSelectFormType) => {
|
||||
const currentUser = users?.find((user) => user.id === values.userId);
|
||||
if (!currentUser) return;
|
||||
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((values) => void handleSubmit(values))}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("userId")}
|
||||
label={t("user.action.select.label")}
|
||||
searchable
|
||||
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 ?? "" }))}
|
||||
<Stack>
|
||||
<Tabs color="red" defaultValue="user">
|
||||
<Tabs.List grow>
|
||||
<TabItem value="user" count={counts.user} icon={IconUser} />
|
||||
<TabItem value="group" count={counts.group} icon={IconUsersGroup} />
|
||||
<TabItem
|
||||
value="inherited"
|
||||
count={initialPermissions.inherited.length}
|
||||
icon={IconUserDown}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) =>
|
||||
t("board.setting.section.access.permission.userSelect.title"),
|
||||
});
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="user">
|
||||
<UsersForm
|
||||
board={board}
|
||||
initialPermissions={permissions}
|
||||
onCountChange={(callback) =>
|
||||
setCounts(({ user, ...others }) => ({
|
||||
user: callback(user),
|
||||
...others,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="group">
|
||||
<GroupsForm
|
||||
board={board}
|
||||
initialPermissions={permissions}
|
||||
onCountChange={(callback) =>
|
||||
setCounts(({ group, ...others }) => ({
|
||||
group: callback(group),
|
||||
...others,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="inherited">
|
||||
<InheritTable initialPermissions={permissions} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface TabItemProps {
|
||||
value: "user" | "group" | "inherited";
|
||||
count: number;
|
||||
icon: TablerIcon;
|
||||
}
|
||||
|
||||
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
|
||||
const t = useScopedI18n("board.setting.section.access.permission");
|
||||
|
||||
return (
|
||||
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
|
||||
<Group gap="sm">
|
||||
{t(`tab.${value}`)}
|
||||
<CountBadge count={count} />
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Select,
|
||||
TableTd,
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconPencil,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { boardPermissions } from "@homarr/definitions";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { OnCountChange } from "./form";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
const icons = {
|
||||
"board-change": IconPencil,
|
||||
"board-view": IconEye,
|
||||
"board-full": IconSettings,
|
||||
} satisfies Record<BoardPermission | "board-full", TablerIcon>;
|
||||
|
||||
interface BoardAccessSelectRowProps {
|
||||
itemContent: ReactNode;
|
||||
permission: BoardPermission;
|
||||
index: number;
|
||||
onCountChange: OnCountChange;
|
||||
}
|
||||
|
||||
export const BoardAccessSelectRow = ({
|
||||
itemContent,
|
||||
permission,
|
||||
index,
|
||||
onCountChange,
|
||||
}: BoardAccessSelectRowProps) => {
|
||||
const tRoot = useI18n();
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
const form = useFormContext();
|
||||
const Icon = icons[permission];
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
form.setFieldValue(
|
||||
"items",
|
||||
form.values.items.filter((_, i) => i !== index),
|
||||
);
|
||||
onCountChange((prev) => prev - 1);
|
||||
}, [form, index, onCountChange]);
|
||||
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
|
||||
<TableTd>
|
||||
<Flex
|
||||
direction={{ base: "column", xs: "row" }}
|
||||
align={{ base: "end", xs: "center" }}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
flex="1"
|
||||
leftSection={<Icon size="1rem" />}
|
||||
renderOption={RenderOption}
|
||||
variant="unstyled"
|
||||
data={boardPermissions.map((permission) => ({
|
||||
value: permission,
|
||||
label: tPermissions(`item.${permission}.label`),
|
||||
}))}
|
||||
{...form.getInputProps(`items.${index}.permission`)}
|
||||
/>
|
||||
|
||||
<Button size="xs" variant="subtle" onClick={handleRemove}>
|
||||
{tRoot("common.action.remove")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
interface BoardAccessDisplayRowProps {
|
||||
itemContent: ReactNode;
|
||||
permission: BoardPermission | "board-full";
|
||||
}
|
||||
|
||||
export const BoardAccessDisplayRow = ({
|
||||
itemContent,
|
||||
permission,
|
||||
}: BoardAccessDisplayRowProps) => {
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
const Icon = icons[permission];
|
||||
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
|
||||
<TableTd>
|
||||
<Group gap={0}>
|
||||
<Flex w={34} h={34} align="center" justify="center">
|
||||
<Icon
|
||||
size="1rem"
|
||||
color="var(--input-section-color, var(--mantine-color-dimmed))"
|
||||
/>
|
||||
</Flex>
|
||||
<Text size="sm">{tPermissions(`item.${permission}.label`)}</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: "1rem",
|
||||
};
|
||||
|
||||
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
||||
const Icon = icons[option.value as BoardPermission];
|
||||
return (
|
||||
<Group flex="1" gap="xs" wrap="nowrap">
|
||||
<Icon {...iconProps} />
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { createFormContext } from "@homarr/form";
|
||||
|
||||
export interface BoardAccessFormType {
|
||||
items: {
|
||||
itemId: string;
|
||||
permission: BoardPermission;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const [FormProvider, useFormContext, useForm] =
|
||||
createFormContext<BoardAccessFormType>();
|
||||
|
||||
export type OnCountChange = (callback: (prev: number) => number) => void;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardAccessSelectRow } from "./board-access-table-rows";
|
||||
import type { BoardAccessFormType } from "./form";
|
||||
import { FormProvider, useForm } from "./form";
|
||||
import { GroupSelectModal } from "./group-select-modal";
|
||||
import type { FormProps } from "./user-access";
|
||||
|
||||
export const GroupsForm = ({
|
||||
board,
|
||||
initialPermissions,
|
||||
onCountChange,
|
||||
}: FormProps) => {
|
||||
const { mutate, isPending } =
|
||||
clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [groups, setGroups] = useState<Map<string, Group>>(
|
||||
new Map(
|
||||
initialPermissions.groupPermissions.map(({ group }) => [group.id, group]),
|
||||
),
|
||||
);
|
||||
const { openModal } = useModalAction(GroupSelectModal);
|
||||
const t = useI18n();
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
items: initialPermissions.groupPermissions.map(
|
||||
({ group, permission }) => ({
|
||||
itemId: group.id,
|
||||
permission,
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: BoardAccessFormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: board.id,
|
||||
permissions: values.items,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.board.getBoardPermissions.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[board.id, mutate, utils.board.getBoardPermissions],
|
||||
);
|
||||
|
||||
const handleAddUser = useCallback(() => {
|
||||
openModal({
|
||||
presentGroupIds: form.values.items.map(({ itemId: id }) => id),
|
||||
onSelect: (group) => {
|
||||
setGroups((prev) => new Map(prev).set(group.id, group));
|
||||
form.setFieldValue("items", [
|
||||
{
|
||||
itemId: group.id,
|
||||
permission: "board-view",
|
||||
},
|
||||
...form.values.items,
|
||||
]);
|
||||
onCountChange((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
}, [form, openModal, onCountChange]);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<FormProvider form={form}>
|
||||
<Stack pt="sm">
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh style={{ whiteSpace: "nowrap" }}>
|
||||
{tPermissions("field.group.label")}
|
||||
</TableTh>
|
||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{form.values.items.map((row, index) => (
|
||||
<BoardAccessSelectRow
|
||||
key={row.itemId}
|
||||
itemContent={
|
||||
<GroupItemContent group={groups.get(row.itemId)!} />
|
||||
}
|
||||
permission={row.permission}
|
||||
index={index}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
rightSection={<IconPlus size="1rem" />}
|
||||
variant="light"
|
||||
onClick={handleAddUser}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupItemContent = ({ group }: { group: Group }) => {
|
||||
return (
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/manage/users/groups/${group.id}`}
|
||||
size="sm"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
|
||||
type Group =
|
||||
RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface InnerProps {
|
||||
presentGroupIds: string[];
|
||||
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface GroupSelectFormType {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export const GroupSelectModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<GroupSelectFormType>();
|
||||
const handleSubmit = async (values: GroupSelectFormType) => {
|
||||
const currentGroup = groups?.find((group) => group.id === values.groupId);
|
||||
if (!currentGroup) return;
|
||||
setLoading(true);
|
||||
await innerProps.onSelect({
|
||||
id: currentGroup.id,
|
||||
name: currentGroup.name,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("groupId")}
|
||||
label={t("group.action.select.label")}
|
||||
clearable
|
||||
searchable
|
||||
leftSection={isPending ? <Loader size="xs" /> : undefined}
|
||||
nothingFoundMessage={t("group.action.select.notFound")}
|
||||
limit={5}
|
||||
data={groups
|
||||
?.filter(
|
||||
(group) => !innerProps.presentGroupIds.includes(group.id),
|
||||
)
|
||||
.map((group) => ({ value: group.id, label: group.name }))}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) =>
|
||||
t("board.setting.section.access.permission.groupSelect.title"),
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
} from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardAccessDisplayRow } from "./board-access-table-rows";
|
||||
import { GroupItemContent } from "./group-access";
|
||||
|
||||
export interface InheritTableProps {
|
||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
}
|
||||
|
||||
const mapPermissions = {
|
||||
"board-full-access": "board-full",
|
||||
"board-modify-all": "board-change",
|
||||
"board-view-all": "board-view",
|
||||
} satisfies Partial<Record<GroupPermissionKey, BoardPermission | "board-full">>;
|
||||
|
||||
export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
return (
|
||||
<Stack pt="sm">
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{initialPermissions.inherited.map(({ group, permission }) => {
|
||||
const boardPermission =
|
||||
permission in mapPermissions
|
||||
? mapPermissions[permission as keyof typeof mapPermissions]
|
||||
: getPermissionsWithChildren([permission]).includes(
|
||||
"board-full-access",
|
||||
)
|
||||
? "board-full"
|
||||
: null;
|
||||
|
||||
if (!boardPermission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BoardAccessDisplayRow
|
||||
key={group.id}
|
||||
itemContent={<GroupItemContent group={group} />}
|
||||
permission={boardPermission}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "../../../_types";
|
||||
import {
|
||||
BoardAccessDisplayRow,
|
||||
BoardAccessSelectRow,
|
||||
} from "./board-access-table-rows";
|
||||
import type { BoardAccessFormType, OnCountChange } from "./form";
|
||||
import { FormProvider, useForm } from "./form";
|
||||
import { UserSelectModal } from "./user-select-modal";
|
||||
|
||||
export interface FormProps {
|
||||
board: Pick<Board, "id" | "creatorId" | "creator">;
|
||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
onCountChange: OnCountChange;
|
||||
}
|
||||
|
||||
export const UsersForm = ({
|
||||
board,
|
||||
initialPermissions,
|
||||
onCountChange,
|
||||
}: FormProps) => {
|
||||
const { mutate, isPending } =
|
||||
clientApi.board.saveUserBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [users, setUsers] = useState<Map<string, User>>(
|
||||
new Map(
|
||||
initialPermissions.userPermissions.map(({ user }) => [user.id, user]),
|
||||
),
|
||||
);
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
const t = useI18n();
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
items: initialPermissions.userPermissions.map(({ user, permission }) => ({
|
||||
itemId: user.id,
|
||||
permission,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: BoardAccessFormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: board.id,
|
||||
permissions: values.items,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.board.getBoardPermissions.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[board.id, mutate, utils.board.getBoardPermissions],
|
||||
);
|
||||
|
||||
const handleAddUser = useCallback(() => {
|
||||
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
|
||||
|
||||
openModal({
|
||||
presentUserIds: board.creatorId
|
||||
? presentUserIds.concat(board.creatorId)
|
||||
: presentUserIds,
|
||||
onSelect: (user) => {
|
||||
setUsers((prev) => new Map(prev).set(user.id, user));
|
||||
form.setFieldValue("items", [
|
||||
{
|
||||
itemId: user.id,
|
||||
permission: "board-view",
|
||||
},
|
||||
...form.values.items,
|
||||
]);
|
||||
onCountChange((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
}, [form, openModal, board.creatorId, onCountChange]);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<FormProvider form={form}>
|
||||
<Stack pt="sm">
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{board.creator && (
|
||||
<BoardAccessDisplayRow
|
||||
itemContent={<UserItemContent user={board.creator} />}
|
||||
permission="board-full"
|
||||
/>
|
||||
)}
|
||||
{form.values.items.map((row, index) => (
|
||||
<BoardAccessSelectRow
|
||||
key={row.itemId}
|
||||
itemContent={
|
||||
<UserItemContent user={users.get(row.itemId)!} />
|
||||
}
|
||||
permission={row.permission}
|
||||
index={index}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
rightSection={<IconPlus size="1rem" />}
|
||||
variant="light"
|
||||
onClick={handleAddUser}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const UserItemContent = ({ user }: { user: User }) => {
|
||||
return (
|
||||
<Group wrap="nowrap">
|
||||
<Box visibleFrom="xs">
|
||||
<UserAvatar user={user} size="sm" />
|
||||
</Box>
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/manage/users/${user.id}`}
|
||||
size="sm"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{user.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
interface InnerProps {
|
||||
presentUserIds: string[];
|
||||
onSelect: (props: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
}) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface UserSelectFormType {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const UserSelectModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: users, isPending } = clientApi.user.selectable.useQuery();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<UserSelectFormType>();
|
||||
const handleSubmit = async (values: UserSelectFormType) => {
|
||||
const currentUser = users?.find((user) => user.id === values.userId);
|
||||
if (!currentUser) return;
|
||||
setLoading(true);
|
||||
await innerProps.onSelect({
|
||||
id: currentUser.id,
|
||||
name: currentUser.name ?? "",
|
||||
image: currentUser.image ?? "",
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
|
||||
const currentUser = users?.find((user) => user.id === form.values.userId);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("userId")}
|
||||
label={t("user.action.select.label")}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={
|
||||
isPending ? (
|
||||
<Loader size="xs" />
|
||||
) : currentUser ? (
|
||||
<UserAvatar user={currentUser} size="xs" />
|
||||
) : undefined
|
||||
}
|
||||
nothingFoundMessage={t("user.action.select.notFound")}
|
||||
renderOption={createRenderOption(users ?? [])}
|
||||
limit={5}
|
||||
data={users
|
||||
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
||||
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) =>
|
||||
t("board.setting.section.access.permission.userSelect.title"),
|
||||
});
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: "1rem",
|
||||
};
|
||||
|
||||
const createRenderOption = (
|
||||
users: RouterOutputs["user"]["selectable"],
|
||||
): SelectProps["renderOption"] =>
|
||||
function InnerRenderRoot({ option, checked }) {
|
||||
const user = users.find((user) => user.id === option.value);
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Group flex="1" gap="xs">
|
||||
<UserAvatar user={user} size="xs" />
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -48,7 +48,14 @@ interface Props {
|
||||
const getBoardAndPermissions = async (params: Props["params"]) => {
|
||||
try {
|
||||
const board = await api.board.getBoardByName({ name: params.name });
|
||||
const permissions = await api.board.getBoardPermissions({ id: board.id });
|
||||
const { hasFullAccess } = await getBoardPermissions(board);
|
||||
const permissions = hasFullAccess
|
||||
? await api.board.getBoardPermissions({ id: board.id })
|
||||
: {
|
||||
userPermissions: [],
|
||||
groupPermissions: [],
|
||||
inherited: [],
|
||||
};
|
||||
|
||||
return { board, permissions };
|
||||
} catch (error) {
|
||||
|
||||
@@ -21,7 +21,12 @@ const iconProps = {
|
||||
interface BoardCardMenuDropdownProps {
|
||||
board: Pick<
|
||||
RouterOutputs["board"]["getAllBoards"][number],
|
||||
"id" | "name" | "creator" | "permissions" | "isPublic"
|
||||
| "id"
|
||||
| "name"
|
||||
| "creator"
|
||||
| "userPermissions"
|
||||
| "groupPermissions"
|
||||
| "isPublic"
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access";
|
||||
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
|
||||
|
||||
interface TransferGroupOwnershipProps {
|
||||
group: {
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
|
||||
interface AddGroupMemberProps {
|
||||
|
||||
Reference in New Issue
Block a user