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:
Meier Lukas
2024-05-04 18:34:41 +02:00
committed by GitHub
parent ca49a01352
commit b1e065f1da
42 changed files with 2375 additions and 423 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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"];

View File

@@ -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"),
});

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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) {

View File

@@ -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"
>;
}

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -4,14 +4,17 @@ import superjson from "superjson";
import type { Database, SQL } from "@homarr/db";
import { and, createId, eq, inArray, or } from "@homarr/db";
import {
boardPermissions,
boardGroupPermissions,
boards,
boardUserPermissions,
groupMembers,
groupPermissions,
integrationItems,
items,
sections,
} from "@homarr/db/schema/sqlite";
import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds } from "@homarr/definitions";
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import {
createSectionSchema,
sharedItemSchema,
@@ -20,42 +23,43 @@ import {
} from "@homarr/validation";
import { zodUnionFromArray } from "../../../validation/src/enums";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
createTRPCRouter,
permissionRequiredProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const filterAddedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter((inputItem) =>
dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
export const boardRouter = createTRPCRouter({
getAllBoards: publicProcedure.query(async ({ ctx }) => {
const permissionsOfCurrentUserWhenPresent =
await ctx.db.query.boardPermissions.findMany({
where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""),
await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
});
const boardIds = permissionsOfCurrentUserWhenPresent.map(
(permission) => permission.boardId,
);
const permissionsOfCurrentUserGroupsWhenPresent =
await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) =>
groupMember.group.boardPermissions.map(
(permission) => permission.boardId,
),
)
.flat(),
);
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
id: true,
@@ -70,19 +74,34 @@ export const boardRouter = createTRPCRouter({
image: true,
},
},
permissions: {
where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""),
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map(
(groupMember) => groupMember.groupId,
),
)
: undefined,
},
},
where: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
// Allow viewing all boards if the user has the permission
where: ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
});
return dbBoards;
}),
createBoard: protectedProcedure
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.input(validation.board.create)
.mutation(async ({ ctx, input }) => {
const boardId = createId();
@@ -377,10 +396,20 @@ export const boardRouter = createTRPCRouter({
"full-access",
);
const permissions = await ctx.db.query.boardPermissions.findMany({
where: eq(boardPermissions.boardId, input.id),
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
where: inArray(
groupPermissions.permission,
getPermissionsWithParents([
"board-view-all",
"board-modify-all",
"board-full-access",
]),
),
columns: {
groupId: false,
},
with: {
user: {
group: {
columns: {
id: true,
name: true,
@@ -388,19 +417,61 @@ export const boardRouter = createTRPCRouter({
},
},
});
return permissions
.map((permission) => ({
const userPermissions = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.boardId, input.id),
with: {
user: {
id: permission.userId,
name: permission.user.name ?? "",
columns: {
id: true,
name: true,
image: true,
},
},
},
});
const dbGroupBoardPermission =
await ctx.db.query.boardGroupPermissions.findMany({
where: eq(boardGroupPermissions.boardId, input.id),
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
permission: permission.permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.user.name.localeCompare(permissionB.user.name);
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
userPermissions: userPermissions
.map(({ user, permission }) => ({
user,
permission,
}))
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(
permissionB.user.name ?? "",
);
}),
groupPermissions: dbGroupBoardPermission
.map(({ group, permission }) => ({
group: {
id: group.id,
name: group.name,
},
permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
}),
saveBoardPermissions: protectedProcedure
saveUserBoardPermissions: protectedProcedure
.input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
@@ -411,14 +482,39 @@ export const boardRouter = createTRPCRouter({
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(boardPermissions)
.where(eq(boardPermissions.boardId, input.id));
.delete(boardUserPermissions)
.where(eq(boardUserPermissions.boardId, input.id));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardPermissions).values(
await transaction.insert(boardUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.user.id,
userId: permission.itemId,
permission: permission.permission,
boardId: input.id,
})),
);
});
}),
saveGroupBoardPermissions: protectedProcedure
.input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(boardGroupPermissions)
.where(eq(boardGroupPermissions.boardId, input.id));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.itemId,
permission: permission.permission,
boardId: input.id,
})),
@@ -458,6 +554,9 @@ const getFullBoardWithWhere = async (
where: SQL<unknown>,
userId: string | null,
) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
});
const board = await db.query.boards.findFirst({
where,
with: {
@@ -465,6 +564,7 @@ const getFullBoardWithWhere = async (
columns: {
id: true,
name: true,
image: true,
},
},
sections: {
@@ -480,12 +580,18 @@ const getFullBoardWithWhere = async (
},
},
},
permissions: {
where: eq(boardPermissions.userId, userId ?? ""),
userPermissions: {
where: eq(boardUserPermissions.userId, userId ?? ""),
columns: {
permission: true,
},
},
groupPermissions: {
where: inArray(
boardGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
@@ -530,3 +636,27 @@ const parseSection = (section: unknown) => {
}
return result.data;
};
const filterAddedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter((inputItem) =>
dbArray.some((dbItem) => dbItem.id === inputItem.id),
);

View File

@@ -3,8 +3,12 @@ import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq } from "@homarr/db";
import { boardPermissions } from "@homarr/db/schema/sqlite";
import { eq, inArray } from "@homarr/db";
import {
boardGroupPermissions,
boardUserPermissions,
groupMembers,
} from "@homarr/db/schema/sqlite";
import type { BoardPermission } from "@homarr/definitions";
/**
@@ -19,6 +23,9 @@ export const throwIfActionForbiddenAsync = async (
permission: "full-access" | BoardPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const board = await db.query.boards.findFirst({
where: boardWhere,
columns: {
@@ -27,8 +34,14 @@ export const throwIfActionForbiddenAsync = async (
isPublic: true,
},
with: {
permissions: {
where: eq(boardPermissions.userId, session?.user.id ?? ""),
userPermissions: {
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
boardGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});

View File

@@ -94,6 +94,14 @@ export const groupRouter = createTRPCRouter({
),
};
}),
selectable: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.groups.findMany({
columns: {
id: true,
name: true,
},
});
}),
createGroup: protectedProcedure
.input(validation.group.create)
.mutation(async ({ input, ctx }) => {

View File

@@ -1,11 +1,16 @@
import SuperJSON from "superjson";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { createId, eq } from "@homarr/db";
import {
boardGroupPermissions,
boards,
boardUserPermissions,
groupMembers,
groupPermissions,
groups,
integrationItems,
integrations,
items,
@@ -13,6 +18,7 @@ import {
users,
} from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
import type { RouterOutputs } from "../..";
import { boardRouter } from "../board";
@@ -23,6 +29,7 @@ const defaultCreatorId = createId();
const defaultSession = {
user: {
id: defaultCreatorId,
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
@@ -30,6 +37,462 @@ const defaultSession = {
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const createRandomUser = async (db: Database) => {
const userId = createId();
await db.insert(users).values({
id: userId,
});
return userId;
};
describe("getAllBoards should return all boards accessable to the current user", () => {
test("without session it should return only public boards", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: null });
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: createId(),
name: "private",
creatorId: user2,
isPublic: false,
},
]);
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(1);
expect(result[0]?.name).toBe("public");
});
test("with session containing board-view-all permission it should return all boards", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({
db,
session: {
user: {
id: defaultCreatorId,
permissions: ["board-view-all"],
},
expires: new Date().toISOString(),
},
});
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: createId(),
name: "private",
creatorId: user2,
isPublic: false,
},
]);
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(2);
expect(result.map((board) => board.name)).toEqual(["public", "private"]);
});
test("with session user beeing creator it should return all private boards of them", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: createId(),
name: "private",
creatorId: user2,
isPublic: false,
},
{
id: createId(),
name: "private2",
creatorId: defaultCreatorId,
isPublic: false,
},
]);
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private2",
]);
});
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
"with %s group board permission it should show board",
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({
db,
session: defaultSession,
});
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: boardId,
name: "private1",
creatorId: user2,
isPublic: false,
},
{
id: createId(),
name: "private2",
creatorId: user2,
isPublic: false,
},
]);
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "group1",
});
await db.insert(groupMembers).values({
userId: defaultSession.user.id,
groupId,
});
await db.insert(boardGroupPermissions).values({
groupId,
permission,
boardId,
});
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private1",
]);
},
);
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
"with %s user board permission it should show board",
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({
db,
session: defaultSession,
});
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: boardId,
name: "private1",
creatorId: user2,
isPublic: false,
},
{
id: createId(),
name: "private2",
creatorId: user2,
isPublic: false,
},
]);
await db.insert(boardUserPermissions).values({
userId: defaultSession.user.id,
permission,
boardId,
});
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private1",
]);
},
);
});
describe("createBoard should create a new board", () => {
test("should create a new board with permission board-create", async () => {
// Arrange
const db = createDb();
const session = {
...defaultSession,
user: {
...defaultSession.user,
permissions: ["board-create"] satisfies GroupPermissionKey[],
},
};
const caller = boardRouter.createCaller({ db, session });
await db.insert(users).values({
id: defaultCreatorId,
});
// Act
await caller.createBoard({ name: "newBoard" });
// Assert
const dbBoard = await db.query.boards.findFirst();
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newBoard");
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
const dbSection = await db.query.sections.findFirst();
expect(dbSection).toBeDefined();
expect(dbSection?.boardId).toBe(dbBoard?.id);
expect(dbSection?.kind).toBe("empty");
});
test("should throw error when user has no board-create permission", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const act = async () => await caller.createBoard({ name: "newBoard" });
// Assert
await expect(act()).rejects.toThrowError("Permission denied");
});
});
describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "oldName",
creatorId: defaultCreatorId,
});
// Act
await caller.renameBoard({ id: boardId, name: "newName" });
// Assert
const dbBoard = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newName");
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
});
test("should throw error when similar board name exists", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "oldName",
creatorId: defaultCreatorId,
});
await db.insert(boards).values({
id: createId(),
name: "newName",
creatorId: defaultCreatorId,
});
// Act
const act = async () =>
await caller.renameBoard({ id: boardId, name: "Newname" });
// Assert
await expect(act()).rejects.toThrowError(
"Board with similar name already exists",
);
});
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const act = async () =>
await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
// Assert
await expect(act()).rejects.toThrowError("Board not found");
});
});
describe("changeBoardVisibility should change board visibility", () => {
test.each([["public"], ["private"]] satisfies ["private" | "public"][])(
"should change board visibility to %s",
async (visibility) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
isPublic: visibility === "public",
});
// Act
await caller.changeBoardVisibility({
id: boardId,
visibility,
});
// Assert
const dbBoard = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.isPublic).toBe(visibility === "public");
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
},
);
});
describe("deleteBoard should delete board", () => {
test("should delete board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
});
// Act
await caller.deleteBoard({ id: boardId });
// Assert
const dbBoard = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
});
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const act = async () =>
await caller.deleteBoard({ id: "nonExistentBoardId" });
// Assert
await expect(act()).rejects.toThrowError("Board not found");
});
});
describe("getDefaultBoard should return default board", () => {
it("should return default board", async () => {
// Arrange
@@ -698,6 +1161,183 @@ describe("saveBoard should save full board", () => {
});
});
describe("getBoardPermissions should return board permissions", () => {
test("should return board permissions", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
});
await db.insert(boardUserPermissions).values([
{
userId: user1,
permission: "board-view",
boardId,
},
{
userId: user2,
permission: "board-change",
boardId,
},
]);
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "group1",
});
await db.insert(boardGroupPermissions).values({
groupId,
permission: "board-view",
boardId,
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
// Act
const result = await caller.getBoardPermissions({ id: boardId });
// Assert
expect(result.groupPermissions).toEqual([
{ group: { id: groupId, name: "group1" }, permission: "board-view" },
]);
expect(result.userPermissions).toEqual(
expect.arrayContaining([
{
user: { id: user1, name: null, image: null },
permission: "board-view",
},
{
user: { id: user2, name: null, image: null },
permission: "board-change",
},
]),
);
expect(result.inherited).toEqual([
{ group: { id: groupId, name: "group1" }, permission: "admin" },
]);
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
});
});
describe("saveUserBoardPermissions should save user board permissions", () => {
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
"should save user board permissions",
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
});
// Act
await caller.saveUserBoardPermissions({
id: boardId,
permissions: [
{
itemId: user1,
permission,
},
],
});
// Assert
const dbUserPermission = await db.query.boardUserPermissions.findFirst({
where: eq(boardUserPermissions.userId, user1),
});
expect(dbUserPermission).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
},
);
});
describe("saveGroupBoardPermissions should save group board permissions", () => {
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
"should save group board permissions",
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
id: defaultCreatorId,
});
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "group1",
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
});
// Act
await caller.saveGroupBoardPermissions({
id: boardId,
permissions: [
{
itemId: groupId,
permission,
},
],
});
// Assert
const dbGroupPermission = await db.query.boardGroupPermissions.findFirst({
where: eq(boardGroupPermissions.groupId, groupId),
});
expect(dbGroupPermission).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
},
);
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getDefaultBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,

View File

@@ -16,6 +16,7 @@ const defaultOwnerId = createId();
const defaultSession = {
user: {
id: defaultOwnerId,
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;

View File

@@ -10,9 +10,10 @@ import { inviteRouter } from "../invite";
const defaultSession = {
user: {
id: createId(),
permissions: [],
},
expires: new Date().toISOString(),
};
} satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => {

View File

@@ -50,6 +50,7 @@ export const userRouter = createTRPCRouter({
columns: {
id: true,
name: true,
image: true,
},
});
}),

View File

@@ -11,6 +11,7 @@ import superjson from "superjson";
import type { Session } from "@homarr/auth";
import { db } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { ZodError } from "@homarr/validation";
@@ -115,3 +116,25 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
*
* If you want a query or mutation to ONLY be accessible to users with a specific permission, use
* this. It verifies that the user has the required permission
*
* @see https://trpc.io/docs/procedures
*/
export const permissionRequiredProcedure = {
requiresPermission: (permission: GroupPermissionKey) => {
return protectedProcedure.use(({ ctx, input, next }) => {
if (!ctx.session?.user.permissions.includes(permission)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Permission denied",
});
}
return next({ input, ctx });
});
},
};

View File

@@ -2,6 +2,11 @@ import { cookies } from "next/headers";
import type { Adapter } from "@auth/core/adapters";
import type { NextAuthConfig } from "next-auth";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, groupPermissions } from "@homarr/db/schema/sqlite";
import { getPermissionsWithChildren } from "@homarr/definitions";
import {
expireDateAfter,
generateSessionToken,
@@ -9,17 +14,44 @@ import {
sessionTokenCookieName,
} from "./session";
export const sessionCallback: NextAuthCallbackOf<"session"> = ({
session,
user,
}) => ({
...session,
user: {
...session.user,
id: user.id,
name: user.name,
},
});
export const getCurrentUserPermissions = async (
db: Database,
userId: string,
) => {
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId);
const dbGroupPermissions = await db
.selectDistinct({
permission: groupPermissions.permission,
})
.from(groupPermissions)
.where(
groupIds.length > 0
? inArray(groupPermissions.groupId, groupIds)
: undefined,
);
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
return getPermissionsWithChildren(permissionKeys);
};
export const createSessionCallback = (
db: Database,
): NextAuthCallbackOf<"session"> => {
return async ({ session, user }) => {
return {
...session,
user: {
...session.user,
id: user.id,
name: user.name,
permissions: await getCurrentUserPermissions(db, user.id),
},
};
};
};
export const createSignInCallback =
(

View File

@@ -5,7 +5,7 @@ import Credentials from "next-auth/providers/credentials";
import { db } from "@homarr/db";
import { createSignInCallback, sessionCallback } from "./callbacks";
import { createSessionCallback, createSignInCallback } from "./callbacks";
import { createCredentialsConfiguration } from "./providers/credentials";
import { EmptyNextAuthProvider } from "./providers/empty";
import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
@@ -33,7 +33,7 @@ export const createConfiguration = (isCredentialsRequest: boolean) =>
EmptyNextAuthProvider(),
],
callbacks: {
session: sessionCallback,
session: createSessionCallback(db),
signIn: createSignInCallback(adapter, isCredentialsRequest),
},
secret: "secret-is-not-defined-yet", // TODO: This should be added later

View File

@@ -1,5 +1,7 @@
import type { DefaultSession } from "@auth/core/types";
import type { GroupPermissionKey } from "@homarr/definitions";
import { createConfiguration } from "./configuration";
export type { Session } from "next-auth";
@@ -8,6 +10,7 @@ declare module "next-auth" {
interface Session {
user: {
id: string;
permissions: GroupPermissionKey[];
} & DefaultSession["user"];
}
}

View File

@@ -37,6 +37,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^8.57.0",

View File

@@ -10,7 +10,10 @@ export type BoardPermissionsProps = (
creatorId: string | null;
}
) & {
permissions: {
userPermissions: {
permission: string;
}[];
groupPermissions: {
permission: string;
}[];
isPublic: boolean;
@@ -23,13 +26,23 @@ export const constructBoardPermissions = (
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
return {
hasFullAccess: session?.user?.id === creatorId,
hasFullAccess:
session?.user?.id === creatorId ||
session?.user.permissions.includes("board-full-access"),
hasChangeAccess:
session?.user?.id === creatorId ||
board.permissions.some(({ permission }) => permission === "board-change"),
board.userPermissions.some(
({ permission }) => permission === "board-change",
) ||
board.groupPermissions.some(
({ permission }) => permission === "board-change",
) ||
session?.user.permissions.includes("board-modify-all"),
hasViewAccess:
session?.user?.id === creatorId ||
board.permissions.length >= 1 ||
board.isPublic,
board.userPermissions.length >= 1 ||
board.groupPermissions.length >= 1 ||
board.isPublic ||
session?.user.permissions.includes("board-view-all"),
};
};

View File

@@ -1,6 +1,8 @@
import type { Session } from "@auth/core/types";
import { describe, expect, test } from "vitest";
import { getPermissionsWithChildren } from "@homarr/definitions";
import { constructBoardPermissions } from "../board-permissions";
describe("constructBoardPermissions", () => {
@@ -10,12 +12,14 @@ describe("constructBoardPermissions", () => {
creator: {
id: "1",
},
permissions: [],
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "1",
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
@@ -29,18 +33,47 @@ describe("constructBoardPermissions", () => {
expect(result.hasViewAccess).toBe(true);
});
test('should return hasChangeAccess as true when board permissions include "board-change"', () => {
test("should return hasFullAccess as true when session permissions include board-full-access", () => {
// Arrange
const board = {
creator: {
id: "1",
},
permissions: [{ permission: "board-change" }],
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["board-full-access"]),
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(true);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasChangeAccess as true when session permissions include board-modify-all", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["board-modify-all"]),
},
expires: new Date().toISOString(),
} satisfies Session;
@@ -54,18 +87,75 @@ describe("constructBoardPermissions", () => {
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when board permissions length is greater than or equal to 1", () => {
test('should return hasChangeAccess as true when board user permissions include "board-change"', () => {
// Arrange
const board = {
creator: {
id: "1",
},
permissions: [{ permission: "board-view" }],
userPermissions: [{ permission: "board-change" }],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasChangeAccess as true when board group permissions include board-change", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [{ permission: "board-change" }],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when session permissions include board-view-all", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["board-view-all"]),
},
expires: new Date().toISOString(),
} satisfies Session;
@@ -79,18 +169,101 @@ describe("constructBoardPermissions", () => {
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when board user permissions length is greater than or equal to 1", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [{ permission: "board-view" }],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when board group permissions length is greater than or equal to 1", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [{ permission: "board-view" }],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(true);
});
test("should return all false when board is not public and session user id is not equal to creator id and no permissions", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(false);
});
test("should return hasViewAccess as true when board is public", () => {
// Arrange
const board = {
creator: {
id: "1",
},
permissions: [],
userPermissions: [],
groupPermissions: [],
isPublic: true,
};
const session = {
user: {
id: "2",
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;

View File

@@ -3,6 +3,8 @@ import type { Session } from "next-auth";
import type { Database } from "@homarr/db";
import { getCurrentUserPermissions } from "./callbacks";
export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
export const sessionTokenCookieName = "next-auth.session-token";
@@ -44,7 +46,10 @@ export const getSessionFromToken = async (
}
return {
user: session.user,
user: {
...session.user,
permissions: await getCurrentUserPermissions(db, session.user.id),
},
expires: session.expires.toISOString(),
};
};

View File

@@ -4,9 +4,63 @@ import { cookies } from "next/headers";
import type { Adapter, AdapterUser } from "@auth/core/adapters";
import type { Account, User } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, test, vi } from "vitest";
import { createSignInCallback, sessionCallback } from "../callbacks";
import {
groupMembers,
groupPermissions,
groups,
users,
} from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import * as definitions from "@homarr/definitions";
import {
createSessionCallback,
createSignInCallback,
getCurrentUserPermissions,
} from "../callbacks";
describe("getCurrentUserPermissions", () => {
test("should return empty permissions when non existing user requested", async () => {
const db = createDb();
await db.insert(users).values({
id: "2",
});
const userId = "1";
const result = await getCurrentUserPermissions(db, userId);
expect(result).toEqual([]);
});
test("should return permissions for user", async () => {
const db = createDb();
const getPermissionsWithChildrenMock = vi
.spyOn(definitions, "getPermissionsWithChildren")
.mockReturnValue(["board-create"]);
const mockId = "1";
await db.insert(users).values({
id: mockId,
});
await db.insert(groups).values({
id: mockId,
name: "test",
});
await db.insert(groupMembers).values({
userId: mockId,
groupId: mockId,
});
await db.insert(groupPermissions).values({
groupId: mockId,
permission: "admin",
});
const result = await getCurrentUserPermissions(db, mockId);
expect(result).toEqual(["board-create"]);
expect(getPermissionsWithChildrenMock).toHaveBeenCalledWith(["admin"]);
});
});
describe("session callback", () => {
it("should add id and name to session user", async () => {
@@ -17,12 +71,15 @@ describe("session callback", () => {
emailVerified: new Date("2023-01-13"),
};
const token: JWT = {};
const result = await sessionCallback({
const db = createDb();
const callback = createSessionCallback(db);
const result = await callback({
session: {
user: {
id: "no-id",
email: "no-email",
emailVerified: new Date("2023-01-13"),
permissions: [],
},
expires: "2023-01-13" as Date & string,
sessionToken: "token",

View File

@@ -22,11 +22,18 @@ CREATE TABLE `app` (
CONSTRAINT `app_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `boardPermission` (
CREATE TABLE `boardGroupPermission` (
`board_id` text NOT NULL,
`group_id` text NOT NULL,
`permission` text NOT NULL,
CONSTRAINT `boardGroupPermission_board_id_group_id_permission_pk` PRIMARY KEY(`board_id`,`group_id`,`permission`)
);
--> statement-breakpoint
CREATE TABLE `boardUserPermission` (
`board_id` text NOT NULL,
`user_id` text NOT NULL,
`permission` text NOT NULL,
CONSTRAINT `boardPermission_board_id_user_id_permission_pk` PRIMARY KEY(`board_id`,`user_id`,`permission`)
CONSTRAINT `boardUserPermission_board_id_user_id_permission_pk` PRIMARY KEY(`board_id`,`user_id`,`permission`)
);
--> statement-breakpoint
CREATE TABLE `board` (
@@ -152,8 +159,10 @@ CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updat
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`userId`);--> statement-breakpoint
ALTER TABLE `account` ADD CONSTRAINT `account_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardPermission` ADD CONSTRAINT `boardPermission_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardPermission` ADD CONSTRAINT `boardPermission_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardGroupPermission` ADD CONSTRAINT `boardGroupPermission_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardGroupPermission` ADD CONSTRAINT `boardGroupPermission_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardUserPermission` ADD CONSTRAINT `boardUserPermission_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardUserPermission` ADD CONSTRAINT `boardUserPermission_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `board` ADD CONSTRAINT `board_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_groupId_group_id_fk` FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint

View File

@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "mysql",
"id": "d0a05e9e-107f-4bed-ac54-a4a41369f0da",
"id": "47dc6887-a308-480d-8125-183412fe7fa7",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@@ -160,8 +160,62 @@
},
"uniqueConstraints": {}
},
"boardPermission": {
"name": "boardPermission",
"boardGroupPermission": {
"name": "boardGroupPermission",
"columns": {
"board_id": {
"name": "board_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"boardGroupPermission_board_id_board_id_fk": {
"name": "boardGroupPermission_board_id_board_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"boardGroupPermission_group_id_group_id_fk": {
"name": "boardGroupPermission_group_id_group_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"boardGroupPermission_board_id_group_id_permission_pk": {
"name": "boardGroupPermission_board_id_group_id_permission_pk",
"columns": ["board_id", "group_id", "permission"]
}
},
"uniqueConstraints": {}
},
"boardUserPermission": {
"name": "boardUserPermission",
"columns": {
"board_id": {
"name": "board_id",
@@ -187,18 +241,18 @@
},
"indexes": {},
"foreignKeys": {
"boardPermission_board_id_board_id_fk": {
"name": "boardPermission_board_id_board_id_fk",
"tableFrom": "boardPermission",
"boardUserPermission_board_id_board_id_fk": {
"name": "boardUserPermission_board_id_board_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"boardPermission_user_id_user_id_fk": {
"name": "boardPermission_user_id_user_id_fk",
"tableFrom": "boardPermission",
"boardUserPermission_user_id_user_id_fk": {
"name": "boardUserPermission_user_id_user_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
@@ -207,8 +261,8 @@
}
},
"compositePrimaryKeys": {
"boardPermission_board_id_user_id_permission_pk": {
"name": "boardPermission_board_id_user_id_permission_pk",
"boardUserPermission_board_id_user_id_permission_pk": {
"name": "boardUserPermission_board_id_user_id_permission_pk",
"columns": ["board_id", "user_id", "permission"]
}
},

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1714414260766,
"tag": "0000_chubby_darkhawk",
"when": 1714817536714,
"tag": "0000_hot_mandrill",
"breakpoints": true
}
]

View File

@@ -22,7 +22,16 @@ CREATE TABLE `app` (
`href` text
);
--> statement-breakpoint
CREATE TABLE `boardPermission` (
CREATE TABLE `boardGroupPermission` (
`board_id` text NOT NULL,
`group_id` text NOT NULL,
`permission` text NOT NULL,
PRIMARY KEY(`board_id`, `group_id`, `permission`),
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `boardUserPermission` (
`board_id` text NOT NULL,
`user_id` text NOT NULL,
`permission` text NOT NULL,

View File

@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "sqlite",
"id": "e3ff4a97-d357-4a64-989b-78668b36c82d",
"id": "116fcd87-09c7-4c7c-b590-0ed5681ffdc5",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@@ -155,8 +155,62 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"boardPermission": {
"name": "boardPermission",
"boardGroupPermission": {
"name": "boardGroupPermission",
"columns": {
"board_id": {
"name": "board_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"boardGroupPermission_board_id_board_id_fk": {
"name": "boardGroupPermission_board_id_board_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"boardGroupPermission_group_id_group_id_fk": {
"name": "boardGroupPermission_group_id_group_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"boardGroupPermission_board_id_group_id_permission_pk": {
"columns": ["board_id", "group_id", "permission"],
"name": "boardGroupPermission_board_id_group_id_permission_pk"
}
},
"uniqueConstraints": {}
},
"boardUserPermission": {
"name": "boardUserPermission",
"columns": {
"board_id": {
"name": "board_id",
@@ -182,18 +236,18 @@
},
"indexes": {},
"foreignKeys": {
"boardPermission_board_id_board_id_fk": {
"name": "boardPermission_board_id_board_id_fk",
"tableFrom": "boardPermission",
"boardUserPermission_board_id_board_id_fk": {
"name": "boardUserPermission_board_id_board_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"boardPermission_user_id_user_id_fk": {
"name": "boardPermission_user_id_user_id_fk",
"tableFrom": "boardPermission",
"boardUserPermission_user_id_user_id_fk": {
"name": "boardUserPermission_user_id_user_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
@@ -202,9 +256,9 @@
}
},
"compositePrimaryKeys": {
"boardPermission_board_id_user_id_permission_pk": {
"boardUserPermission_board_id_user_id_permission_pk": {
"columns": ["board_id", "permission", "user_id"],
"name": "boardPermission_board_id_user_id_permission_pk"
"name": "boardUserPermission_board_id_user_id_permission_pk"
}
},
"uniqueConstraints": {}

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1714414359385,
"tag": "0000_abnormal_kree",
"when": 1714817544524,
"tag": "0000_premium_forgotten_one",
"breakpoints": true
}
]

View File

@@ -20,8 +20,8 @@
"migration:sqlite:generate": "drizzle-kit generate:sqlite --config ./sqlite.config.ts",
"migration:run": "tsx ./migrate.ts",
"migration:mysql:generate": "drizzle-kit generate:mysql --config ./mysql.config.ts",
"push": "drizzle-kit push:sqlite",
"studio": "drizzle-kit studio",
"push": "drizzle-kit push:sqlite --config ./sqlite.config.ts",
"studio": "drizzle-kit studio --config ./sqlite.config.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -201,8 +201,8 @@ export const boards = mysqlTable("board", {
columnCount: int("column_count").default(10).notNull(),
});
export const boardPermissions = mysqlTable(
"boardPermission",
export const boardUserPermissions = mysqlTable(
"boardUserPermission",
{
boardId: text("board_id")
.notNull()
@@ -219,6 +219,24 @@ export const boardPermissions = mysqlTable(
}),
);
export const boardGroupPermissions = mysqlTable(
"boardGroupPermission",
{
boardId: text("board_id")
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
groupId: text("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.boardId, table.groupId, table.permission],
}),
}),
);
export const sections = mysqlTable("section", {
id: varchar("id", { length: 256 }).notNull().primaryKey(),
boardId: varchar("board_id", { length: 256 })
@@ -277,7 +295,7 @@ export const accountRelations = relations(accounts, ({ one }) => ({
export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardPermissions),
boardPermissions: many(boardUserPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
@@ -310,6 +328,7 @@ export const groupMemberRelations = relations(groupMembers, ({ one }) => ({
export const groupRelations = relations(groups, ({ one, many }) => ({
permissions: many(groupPermissions),
boardPermissions: many(boardGroupPermissions),
members: many(groupMembers),
owner: one(users, {
fields: [groups.ownerId],
@@ -327,15 +346,29 @@ export const groupPermissionRelations = relations(
}),
);
export const boardPermissionRelations = relations(
boardPermissions,
export const boardUserPermissionRelations = relations(
boardUserPermissions,
({ one }) => ({
user: one(users, {
fields: [boardPermissions.userId],
fields: [boardUserPermissions.userId],
references: [users.id],
}),
board: one(boards, {
fields: [boardPermissions.boardId],
fields: [boardUserPermissions.boardId],
references: [boards.id],
}),
}),
);
export const boardGroupPermissionRelations = relations(
boardGroupPermissions,
({ one }) => ({
group: one(groups, {
fields: [boardGroupPermissions.groupId],
references: [groups.id],
}),
board: one(boards, {
fields: [boardGroupPermissions.boardId],
references: [boards.id],
}),
}),
@@ -362,7 +395,8 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
fields: [boards.creatorId],
references: [users.id],
}),
permissions: many(boardPermissions),
userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions),
}));
export const sectionRelations = relations(sections, ({ many, one }) => ({

View File

@@ -198,8 +198,8 @@ export const boards = sqliteTable("board", {
columnCount: int("column_count").default(10).notNull(),
});
export const boardPermissions = sqliteTable(
"boardPermission",
export const boardUserPermissions = sqliteTable(
"boardUserPermission",
{
boardId: text("board_id")
.notNull()
@@ -216,6 +216,24 @@ export const boardPermissions = sqliteTable(
}),
);
export const boardGroupPermissions = sqliteTable(
"boardGroupPermission",
{
boardId: text("board_id")
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
groupId: text("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.boardId, table.groupId, table.permission],
}),
}),
);
export const sections = sqliteTable("section", {
id: text("id").notNull().primaryKey(),
boardId: text("board_id")
@@ -274,7 +292,7 @@ export const accountRelations = relations(accounts, ({ one }) => ({
export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardPermissions),
boardPermissions: many(boardUserPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
@@ -307,6 +325,7 @@ export const groupMemberRelations = relations(groupMembers, ({ one }) => ({
export const groupRelations = relations(groups, ({ one, many }) => ({
permissions: many(groupPermissions),
boardPermissions: many(boardGroupPermissions),
members: many(groupMembers),
owner: one(users, {
fields: [groups.ownerId],
@@ -324,15 +343,29 @@ export const groupPermissionRelations = relations(
}),
);
export const boardPermissionRelations = relations(
boardPermissions,
export const boardUserPermissionRelations = relations(
boardUserPermissions,
({ one }) => ({
user: one(users, {
fields: [boardPermissions.userId],
fields: [boardUserPermissions.userId],
references: [users.id],
}),
board: one(boards, {
fields: [boardPermissions.boardId],
fields: [boardUserPermissions.boardId],
references: [boards.id],
}),
}),
);
export const boardGroupPermissionRelations = relations(
boardGroupPermissions,
({ one }) => ({
group: one(groups, {
fields: [boardGroupPermissions.groupId],
references: [groups.id],
}),
board: one(boards, {
fields: [boardGroupPermissions.boardId],
references: [boards.id],
}),
}),
@@ -359,7 +392,8 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
fields: [boards.creatorId],
references: [users.id],
}),
permissions: many(boardPermissions),
userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions),
}));
export const sectionRelations = relations(sections, ({ many, one }) => ({

View File

@@ -1,4 +1,4 @@
import { objectKeys } from "@homarr/common";
import { objectEntries, objectKeys } from "@homarr/common";
export const boardPermissions = ["board-view", "board-change"] as const;
export const groupPermissions = {
@@ -20,6 +20,21 @@ const groupPermissionParents = {
admin: ["board-full-access", "integration-full-access"],
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
export const getPermissionsWithParents = (
permissions: GroupPermissionKey[],
): GroupPermissionKey[] => {
const res = permissions.map((permission) => {
return objectEntries(groupPermissionParents)
.filter(([_key, value]: [string, GroupPermissionKey[]]) =>
value.includes(permission),
)
.map(([key]) => getPermissionsWithParents([key]))
.flat();
});
return permissions.concat(res.flat());
};
const getPermissionsInner = (
permissionSet: Set<GroupPermissionKey>,
permissions: GroupPermissionKey[],

View File

@@ -0,0 +1,90 @@
import { describe, expect, test } from "vitest";
import type { GroupPermissionKey } from "../permissions";
import {
getPermissionsWithChildren,
getPermissionsWithParents,
} from "../permissions";
describe("getPermissionsWithParents should return the correct permissions", () => {
test.each([
[
["board-view-all"],
["board-view-all", "board-modify-all", "board-full-access", "admin"],
],
[["board-modify-all"], ["board-modify-all", "board-full-access", "admin"]],
[["board-create"], ["board-create", "board-full-access", "admin"]],
[["board-full-access"], ["board-full-access", "admin"]],
[
["integration-use-all"],
[
"integration-use-all",
"integration-interact-all",
"integration-full-access",
"admin",
],
],
[
["integration-create"],
["integration-create", "integration-full-access", "admin"],
],
[
["integration-interact-all"],
["integration-interact-all", "integration-full-access", "admin"],
],
[["integration-full-access"], ["integration-full-access", "admin"]],
[["admin"], ["admin"]],
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])(
"expect %s to return %s",
(input, expectedOutput) => {
expect(getPermissionsWithParents(input)).toEqual(
expect.arrayContaining(expectedOutput),
);
},
);
});
describe("getPermissionsWithChildren should return the correct permissions", () => {
test.each([
[["board-view-all"], ["board-view-all"]],
[["board-modify-all"], ["board-view-all", "board-modify-all"]],
[["board-create"], ["board-create"]],
[
["board-full-access"],
["board-full-access", "board-modify-all", "board-view-all"],
],
[["integration-use-all"], ["integration-use-all"]],
[["integration-create"], ["integration-create"]],
[
["integration-interact-all"],
["integration-interact-all", "integration-use-all"],
],
[
["integration-full-access"],
[
"integration-full-access",
"integration-interact-all",
"integration-use-all",
],
],
[
["admin"],
[
"admin",
"board-full-access",
"board-modify-all",
"board-view-all",
"integration-full-access",
"integration-interact-all",
"integration-use-all",
],
],
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])(
"expect %s to return %s",
(input, expectedOutput) => {
expect(getPermissionsWithChildren(input)).toEqual(
expect.arrayContaining(expectedOutput),
);
},
);
});

View File

@@ -173,6 +173,10 @@ export default {
},
},
},
select: {
label: "Select group",
notFound: "No group found",
},
},
},
app: {
@@ -874,10 +878,21 @@ export default {
userSelect: {
title: "Add user permission",
},
groupSelect: {
title: "Add group permission",
},
tab: {
user: "Users",
group: "Groups",
inherited: "Inherited groups",
},
field: {
user: {
label: "User",
},
group: {
label: "Group",
},
permission: {
label: "Permission",
},

View File

@@ -75,10 +75,7 @@ const savePermissionsSchema = z.object({
id: z.string(),
permissions: z.array(
z.object({
user: z.object({
id: z.string(),
name: z.string(),
}),
itemId: z.string(),
permission: zodEnumFromArray(boardPermissions),
}),
),

3
pnpm-lock.yaml generated
View File

@@ -425,6 +425,9 @@ importers:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
devDependencies:
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/eslint-config':
specifier: workspace:^0.2.0
version: link:../../tooling/eslint