feat: add integration access settings (#725)
* feat: add integration access settings * fix: typecheck and test issues * fix: test timeout * chore: address pull request feedback * chore: add throw if action forbidden for integration permissions * fix: unable to create new migrations because of duplicate prevId in sqlite snapshots * chore: add sqlite migration for integration permissions * test: add unit tests for integration access * test: add permission checks to integration router tests * test: add unit test for integration permissions * chore: add mysql migration * fix: format issues
This commit is contained in:
@@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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 { 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;
|
|
||||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
|
||||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
|
||||||
{
|
|
||||||
id: board.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initialData: initialPermissions,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [counts, setCounts] = useState({
|
|
||||||
user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
|
|
||||||
group: initialPermissions.groupPermissions.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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} />
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
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,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { boardPermissions, boardPermissionsMap } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { AccessSettings } from "~/components/access/access-settings";
|
||||||
|
import type { Board } from "../../_types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
board: Board;
|
||||||
|
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||||
|
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||||
|
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
||||||
|
{
|
||||||
|
id: board.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialPermissions,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessSettings
|
||||||
|
entity={{
|
||||||
|
id: board.id,
|
||||||
|
ownerId: board.creatorId,
|
||||||
|
owner: board.creator,
|
||||||
|
}}
|
||||||
|
query={{
|
||||||
|
invalidate: () => utils.board.getBoardPermissions.invalidate(),
|
||||||
|
data: permissions,
|
||||||
|
}}
|
||||||
|
groupsMutation={{
|
||||||
|
mutate: groupMutation.mutate,
|
||||||
|
isPending: groupMutation.isPending,
|
||||||
|
}}
|
||||||
|
usersMutation={{
|
||||||
|
mutate: userMutation.mutate,
|
||||||
|
isPending: userMutation.isPending,
|
||||||
|
}}
|
||||||
|
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
|
||||||
|
permission={{
|
||||||
|
items: boardPermissions,
|
||||||
|
default: "view",
|
||||||
|
fullAccessGroupPermission: "board-full-all",
|
||||||
|
groupPermissionMapping: boardPermissionsMap,
|
||||||
|
icons: {
|
||||||
|
modify: IconPencil,
|
||||||
|
view: IconEye,
|
||||||
|
full: IconSettings,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,8 +20,8 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||||
import { AccessSettingsContent } from "./_access";
|
|
||||||
import { BackgroundSettingsContent } from "./_background";
|
import { BackgroundSettingsContent } from "./_background";
|
||||||
|
import { BoardAccessSettings } from "./_board-access";
|
||||||
import { ColorSettingsContent } from "./_colors";
|
import { ColorSettingsContent } from "./_colors";
|
||||||
import { CustomCssSettingsContent } from "./_customCss";
|
import { CustomCssSettingsContent } from "./_customCss";
|
||||||
import { DangerZoneSettingsContent } from "./_danger";
|
import { DangerZoneSettingsContent } from "./_danger";
|
||||||
@@ -44,8 +44,8 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
|||||||
const permissions = hasFullAccess
|
const permissions = hasFullAccess
|
||||||
? await api.board.getBoardPermissions({ id: board.id })
|
? await api.board.getBoardPermissions({ id: board.id })
|
||||||
: {
|
: {
|
||||||
userPermissions: [],
|
users: [],
|
||||||
groupPermissions: [],
|
groups: [],
|
||||||
inherited: [],
|
inherited: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
|||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
<AccordionItemFor value="access" icon={IconUser}>
|
<AccordionItemFor value="access" icon={IconUser}>
|
||||||
<AccessSettingsContent board={board} initialPermissions={permissions} />
|
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||||
<DangerZoneSettingsContent />
|
<DangerZoneSettingsContent />
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconPlayerPlay, IconSelector, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { integrationPermissions, integrationPermissionsMap } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { AccessSettings } from "~/components/access/access-settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
integration: RouterOutputs["integration"]["byId"];
|
||||||
|
initialPermissions: RouterOutputs["integration"]["getIntegrationPermissions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
|
||||||
|
{
|
||||||
|
id: integration.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
initialData: initialPermissions,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const usersMutation = clientApi.integration.saveUserIntegrationPermissions.useMutation();
|
||||||
|
const groupsMutation = clientApi.integration.saveGroupIntegrationPermissions.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessSettings
|
||||||
|
entity={{
|
||||||
|
id: integration.id,
|
||||||
|
ownerId: null,
|
||||||
|
owner: null,
|
||||||
|
}}
|
||||||
|
permission={{
|
||||||
|
items: integrationPermissions,
|
||||||
|
default: "use",
|
||||||
|
fullAccessGroupPermission: "integration-full-all",
|
||||||
|
icons: {
|
||||||
|
use: IconSelector,
|
||||||
|
interact: IconPlayerPlay,
|
||||||
|
full: IconSettings,
|
||||||
|
},
|
||||||
|
groupPermissionMapping: integrationPermissionsMap,
|
||||||
|
}}
|
||||||
|
translate={(key) => t(`integration.permission.${key}`)}
|
||||||
|
query={{
|
||||||
|
data,
|
||||||
|
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
|
||||||
|
}}
|
||||||
|
groupsMutation={groupsMutation}
|
||||||
|
usersMutation={usersMutation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
||||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
import { IntegrationAvatar } from "../../_integration-avatar";
|
||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ interface EditIntegrationPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
||||||
const t = await getScopedI18n("integration.page.edit");
|
const editT = await getScopedI18n("integration.page.edit");
|
||||||
|
const t = await getI18n();
|
||||||
const integration = await api.integration.byId({ id: params.id });
|
const integration = await api.integration.byId({ id: params.id });
|
||||||
|
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -23,9 +26,14 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||||
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
<Title>{editT("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<EditIntegrationForm integration={integration} />
|
<EditIntegrationForm integration={integration} />
|
||||||
|
|
||||||
|
<Title order={2}>{t("permission.title")}</Title>
|
||||||
|
<Fieldset>
|
||||||
|
<IntegrationAccessSettings integration={integration} initialPermissions={integrationPermissions} />
|
||||||
|
</Fieldset>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("integration.page.create");
|
const tCreate = await getScopedI18n("integration.page.create");
|
||||||
|
|
||||||
const currentKind = result.data;
|
const currentKind = result.data;
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={currentKind} size="md" />
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<NewIntegrationForm searchParams={searchParams} />
|
<NewIntegrationForm searchParams={searchParams} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
|
|||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
|
import { UserSelectModal } from "~/components/access/user-select-modal";
|
||||||
|
|
||||||
interface TransferGroupOwnershipProps {
|
interface TransferGroupOwnershipProps {
|
||||||
group: {
|
group: {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
|
import { UserSelectModal } from "~/components/access/user-select-modal";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
|
||||||
interface AddGroupMemberProps {
|
interface AddGroupMemberProps {
|
||||||
|
|||||||
189
apps/nextjs/src/components/access/access-settings.tsx
Normal file
189
apps/nextjs/src/components/access/access-settings.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Group, Stack, Tabs } from "@mantine/core";
|
||||||
|
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
import { CountBadge } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { AccessProvider } from "./context";
|
||||||
|
import type { AccessFormType } from "./form";
|
||||||
|
import { GroupAccessForm } from "./group-access-form";
|
||||||
|
import { InheritAccessTable } from "./inherit-access-table";
|
||||||
|
import { UsersAccessForm } from "./user-access-form";
|
||||||
|
|
||||||
|
interface GroupAccessPermission<TPermission extends string> {
|
||||||
|
permission: TPermission;
|
||||||
|
group: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserAccessPermission<TPermission extends string> {
|
||||||
|
permission: TPermission;
|
||||||
|
user: {
|
||||||
|
name: string | null;
|
||||||
|
image: string | null;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimpleMutation<TPermission extends string> {
|
||||||
|
mutate: (
|
||||||
|
props: { entityId: string; permissions: { principalId: string; permission: TPermission }[] },
|
||||||
|
options: { onSuccess: () => void },
|
||||||
|
) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessQueryData<TPermission extends string> {
|
||||||
|
inherited: GroupAccessPermission<GroupPermissionKey>[];
|
||||||
|
groups: GroupAccessPermission<TPermission>[];
|
||||||
|
users: UserAccessPermission<TPermission>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<TPermission extends string> {
|
||||||
|
permission: {
|
||||||
|
items: readonly TPermission[];
|
||||||
|
default: TPermission;
|
||||||
|
icons: Record<TPermission, TablerIcon>;
|
||||||
|
groupPermissionMapping: Record<TPermission, GroupPermissionKey>;
|
||||||
|
fullAccessGroupPermission: GroupPermissionKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
query: {
|
||||||
|
data: AccessQueryData<TPermission>;
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
};
|
||||||
|
groupsMutation: SimpleMutation<TPermission>;
|
||||||
|
usersMutation: SimpleMutation<TPermission>;
|
||||||
|
entity: {
|
||||||
|
id: string;
|
||||||
|
ownerId: string | null;
|
||||||
|
owner: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
image: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
translate: (key: TPermission) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessSettings = <TPermission extends string>({
|
||||||
|
permission,
|
||||||
|
query,
|
||||||
|
groupsMutation,
|
||||||
|
usersMutation,
|
||||||
|
entity,
|
||||||
|
translate,
|
||||||
|
}: Props<TPermission>) => {
|
||||||
|
const [counts, setCounts] = useState({
|
||||||
|
user: query.data.users.length + (entity.owner ? 1 : 0),
|
||||||
|
group: query.data.groups.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGroupSubmit = (values: AccessFormType<TPermission>) => {
|
||||||
|
groupsMutation.mutate(
|
||||||
|
{
|
||||||
|
entityId: entity.id,
|
||||||
|
permissions: values.items,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
void query.invalidate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserSubmit = (values: AccessFormType<TPermission>) => {
|
||||||
|
usersMutation.mutate(
|
||||||
|
{
|
||||||
|
entityId: entity.id,
|
||||||
|
permissions: values.items,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
void query.invalidate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessProvider<TPermission>
|
||||||
|
defaultPermission={permission.default}
|
||||||
|
icons={permission.icons}
|
||||||
|
permissions={permission.items}
|
||||||
|
translate={translate}
|
||||||
|
>
|
||||||
|
<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={query.data.inherited.length} icon={IconUserDown} />
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="user">
|
||||||
|
<UsersAccessForm<TPermission>
|
||||||
|
entity={entity}
|
||||||
|
accessQueryData={query.data}
|
||||||
|
handleCountChange={(callback) =>
|
||||||
|
setCounts(({ user, ...others }) => ({
|
||||||
|
user: callback(user),
|
||||||
|
...others,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
handleSubmit={handleUserSubmit}
|
||||||
|
isPending={usersMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="group">
|
||||||
|
<GroupAccessForm<TPermission>
|
||||||
|
accessQueryData={query.data}
|
||||||
|
handleCountChange={(callback) =>
|
||||||
|
setCounts(({ group, ...others }) => ({
|
||||||
|
group: callback(group),
|
||||||
|
...others,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
handleSubmit={handleGroupSubmit}
|
||||||
|
isPending={groupsMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="inherited">
|
||||||
|
<InheritAccessTable<TPermission>
|
||||||
|
accessQueryData={query.data}
|
||||||
|
fullAccessGroupPermission={permission.fullAccessGroupPermission}
|
||||||
|
mapPermissions={permission.groupPermissionMapping}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
</AccessProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TabItemProps {
|
||||||
|
value: "user" | "group" | "inherited";
|
||||||
|
count: number;
|
||||||
|
icon: TablerIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
|
||||||
|
const t = useScopedI18n("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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,43 +1,36 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
import type { SelectProps } from "@mantine/core";
|
import type { SelectProps } from "@mantine/core";
|
||||||
import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
|
import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
|
||||||
import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
|
import { Icon123, IconCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { BoardPermission } from "@homarr/definitions";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { boardPermissions } from "@homarr/definitions";
|
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
|
||||||
|
|
||||||
import type { OnCountChange } from "./form";
|
import { useAccessContext } from "./context";
|
||||||
|
import type { HandleCountChange } from "./form";
|
||||||
import { useFormContext } from "./form";
|
import { useFormContext } from "./form";
|
||||||
|
|
||||||
const icons = {
|
interface AccessSelectRowProps {
|
||||||
"board-change": IconPencil,
|
|
||||||
"board-view": IconEye,
|
|
||||||
"board-full": IconSettings,
|
|
||||||
} satisfies Record<BoardPermission | "board-full", TablerIcon>;
|
|
||||||
|
|
||||||
interface BoardAccessSelectRowProps {
|
|
||||||
itemContent: ReactNode;
|
itemContent: ReactNode;
|
||||||
permission: BoardPermission;
|
permission: string;
|
||||||
index: number;
|
index: number;
|
||||||
onCountChange: OnCountChange;
|
handleCountChange: HandleCountChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => {
|
export const AccessSelectRow = ({ itemContent, permission, index, handleCountChange }: AccessSelectRowProps) => {
|
||||||
const tRoot = useI18n();
|
const tRoot = useI18n();
|
||||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
const { icons, getSelectData } = useAccessContext();
|
||||||
const form = useFormContext();
|
const form = useFormContext();
|
||||||
const Icon = icons[permission];
|
|
||||||
|
|
||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(() => {
|
||||||
form.setFieldValue(
|
form.setFieldValue(
|
||||||
"items",
|
"items",
|
||||||
form.values.items.filter((_, i) => i !== index),
|
form.values.items.filter((_, i) => i !== index),
|
||||||
);
|
);
|
||||||
onCountChange((prev) => prev - 1);
|
handleCountChange((prev) => prev - 1);
|
||||||
}, [form, index, onCountChange]);
|
}, [form, index, handleCountChange]);
|
||||||
|
|
||||||
|
const Icon = icons[permission] ?? Icon123;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
@@ -50,10 +43,7 @@ export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountCh
|
|||||||
leftSection={<Icon size="1rem" />}
|
leftSection={<Icon size="1rem" />}
|
||||||
renderOption={RenderOption}
|
renderOption={RenderOption}
|
||||||
variant="unstyled"
|
variant="unstyled"
|
||||||
data={boardPermissions.map((permission) => ({
|
data={getSelectData()}
|
||||||
value: permission,
|
|
||||||
label: tPermissions(`item.${permission}.label`),
|
|
||||||
}))}
|
|
||||||
{...form.getInputProps(`items.${index}.permission`)}
|
{...form.getInputProps(`items.${index}.permission`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -66,30 +56,6 @@ export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountCh
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = {
|
const iconProps = {
|
||||||
stroke: 1.5,
|
stroke: 1.5,
|
||||||
color: "currentColor",
|
color: "currentColor",
|
||||||
@@ -98,7 +64,9 @@ const iconProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
||||||
const Icon = icons[option.value as BoardPermission];
|
const { icons } = useAccessContext();
|
||||||
|
|
||||||
|
const Icon = icons[option.value] ?? Icon123;
|
||||||
return (
|
return (
|
||||||
<Group flex="1" gap="xs" wrap="nowrap">
|
<Group flex="1" gap="xs" wrap="nowrap">
|
||||||
<Icon {...iconProps} />
|
<Icon {...iconProps} />
|
||||||
@@ -107,3 +75,27 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AccessDisplayRowProps {
|
||||||
|
itemContent: ReactNode;
|
||||||
|
permission: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessDisplayRow = ({ itemContent, permission }: AccessDisplayRowProps) => {
|
||||||
|
const { icons, translate } = useAccessContext();
|
||||||
|
const Icon = icons[permission] ?? Icon123;
|
||||||
|
|
||||||
|
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">{translate(permission)}</Text>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
apps/nextjs/src/components/access/context.tsx
Normal file
53
apps/nextjs/src/components/access/context.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { TablerIcon } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
const AccessContext = createContext<{
|
||||||
|
permissions: readonly string[];
|
||||||
|
icons: Record<string, TablerIcon>;
|
||||||
|
translate: (key: string) => string;
|
||||||
|
defaultPermission: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
export const useAccessContext = <TPermission extends string>() => {
|
||||||
|
const context = useContext(AccessContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAccessContext must be used within a AccessProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icons: context.icons as Record<TPermission, TablerIcon>,
|
||||||
|
getSelectData: () =>
|
||||||
|
context.permissions.map((permission) => ({ value: permission, label: context.translate(permission) })),
|
||||||
|
permissions: context.permissions as readonly TPermission[],
|
||||||
|
translate: context.translate as (key: TPermission) => string,
|
||||||
|
defaultPermission: context.defaultPermission as TPermission,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccessProvider = <TPermission extends string>({
|
||||||
|
defaultPermission,
|
||||||
|
permissions,
|
||||||
|
icons,
|
||||||
|
translate,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
defaultPermission: TPermission;
|
||||||
|
permissions: readonly TPermission[];
|
||||||
|
icons: Record<TPermission, TablerIcon>;
|
||||||
|
translate: (key: TPermission) => string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AccessContext.Provider
|
||||||
|
value={{
|
||||||
|
defaultPermission,
|
||||||
|
permissions,
|
||||||
|
icons,
|
||||||
|
translate: translate as (key: string) => string,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AccessContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
apps/nextjs/src/components/access/form.ts
Normal file
12
apps/nextjs/src/components/access/form.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createFormContext } from "@homarr/form";
|
||||||
|
|
||||||
|
export interface AccessFormType<TPermission extends string> {
|
||||||
|
items: {
|
||||||
|
principalId: string;
|
||||||
|
permission: TPermission;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const [FormProvider, useFormContext, useForm] = createFormContext<AccessFormType<string>>();
|
||||||
|
|
||||||
|
export type HandleCountChange = (callback: (prev: number) => number) => void;
|
||||||
@@ -1,73 +1,60 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { BoardAccessSelectRow } from "./board-access-table-rows";
|
import type { AccessQueryData } from "./access-settings";
|
||||||
import type { BoardAccessFormType } from "./form";
|
import { AccessSelectRow } from "./access-table-rows";
|
||||||
|
import { useAccessContext } from "./context";
|
||||||
|
import type { AccessFormType } from "./form";
|
||||||
import { FormProvider, useForm } from "./form";
|
import { FormProvider, useForm } from "./form";
|
||||||
import { GroupSelectModal } from "./group-select-modal";
|
import { GroupSelectModal } from "./group-select-modal";
|
||||||
import type { FormProps } from "./user-access";
|
import type { FormProps } from "./user-access-form";
|
||||||
|
|
||||||
export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
|
export const GroupAccessForm = <TPermission extends string>({
|
||||||
const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation();
|
accessQueryData,
|
||||||
const utils = clientApi.useUtils();
|
handleCountChange,
|
||||||
const [groups, setGroups] = useState<Map<string, Group>>(
|
handleSubmit,
|
||||||
new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])),
|
isPending,
|
||||||
|
}: Omit<FormProps<TPermission>, "entity">) => {
|
||||||
|
const { defaultPermission } = useAccessContext();
|
||||||
|
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
|
||||||
|
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
|
||||||
);
|
);
|
||||||
const { openModal } = useModalAction(GroupSelectModal);
|
const { openModal } = useModalAction(GroupSelectModal);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
const tPermissions = useScopedI18n("permission");
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
items: initialPermissions.groupPermissions.map(({ group, permission }) => ({
|
items: accessQueryData.groups.map(({ group, permission }) => ({
|
||||||
itemId: group.id,
|
principalId: group.id,
|
||||||
permission,
|
permission,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleAddUser = () => {
|
||||||
(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({
|
openModal({
|
||||||
presentGroupIds: form.values.items.map(({ itemId: id }) => id),
|
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
|
||||||
onSelect: (group) => {
|
onSelect: (group) => {
|
||||||
setGroups((prev) => new Map(prev).set(group.id, group));
|
setGroups((prev) => new Map(prev).set(group.id, group));
|
||||||
form.setFieldValue("items", [
|
form.setFieldValue("items", [
|
||||||
{
|
{
|
||||||
itemId: group.id,
|
principalId: group.id,
|
||||||
permission: "board-view",
|
permission: defaultPermission,
|
||||||
},
|
},
|
||||||
...form.values.items,
|
...form.values.items,
|
||||||
]);
|
]);
|
||||||
onCountChange((prev) => prev + 1);
|
handleCountChange((prev) => prev + 1);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [form, openModal, onCountChange]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
|
||||||
<FormProvider form={form}>
|
<FormProvider form={form}>
|
||||||
<Stack pt="sm">
|
<Stack pt="sm">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -79,13 +66,13 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro
|
|||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{form.values.items.map((row, index) => (
|
{form.values.items.map((row, index) => (
|
||||||
<BoardAccessSelectRow
|
<AccessSelectRow
|
||||||
key={row.itemId}
|
key={row.principalId}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
itemContent={<GroupItemContent group={groups.get(row.itemId)!} />}
|
itemContent={<GroupItemContent group={groups.get(row.principalId)!} />}
|
||||||
permission={row.permission}
|
permission={row.permission}
|
||||||
index={index}
|
index={index}
|
||||||
onCountChange={onCountChange}
|
handleCountChange={handleCountChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableTbody>
|
</TableTbody>
|
||||||
@@ -96,7 +83,7 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro
|
|||||||
{t("common.action.add")}
|
{t("common.action.add")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={isPending} color="teal">
|
<Button type="submit" loading={isPending} color="teal">
|
||||||
{t("common.action.saveChanges")}
|
{t("permission.action.saveGroup")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -105,12 +92,10 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GroupItemContent = ({ group }: { group: Group }) => {
|
export const GroupItemContent = ({ group }: { group: AccessQueryData<string>["groups"][number]["group"] }) => {
|
||||||
return (
|
return (
|
||||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
<Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
|
|
||||||
@@ -63,5 +63,5 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"),
|
defaultTitle: (t) => t("permission.groupSelect.title"),
|
||||||
});
|
});
|
||||||
57
apps/nextjs/src/components/access/inherit-access-table.tsx
Normal file
57
apps/nextjs/src/components/access/inherit-access-table.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
|
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { AccessQueryData } from "./access-settings";
|
||||||
|
import { AccessDisplayRow } from "./access-table-rows";
|
||||||
|
import { GroupItemContent } from "./group-access-form";
|
||||||
|
|
||||||
|
export interface InheritTableProps<TPermission extends string> {
|
||||||
|
accessQueryData: AccessQueryData<TPermission>;
|
||||||
|
mapPermissions: Partial<Record<GroupPermissionKey, TPermission>>;
|
||||||
|
fullAccessGroupPermission: GroupPermissionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InheritAccessTable = <TPermission extends string>({
|
||||||
|
accessQueryData,
|
||||||
|
mapPermissions,
|
||||||
|
fullAccessGroupPermission,
|
||||||
|
}: InheritTableProps<TPermission>) => {
|
||||||
|
const tPermissions = useScopedI18n("permission");
|
||||||
|
return (
|
||||||
|
<Stack pt="sm">
|
||||||
|
<Table>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
||||||
|
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{accessQueryData.inherited.map(({ group, permission }) => {
|
||||||
|
const entityPermission =
|
||||||
|
permission in mapPermissions
|
||||||
|
? mapPermissions[permission]
|
||||||
|
: getPermissionsWithChildren([permission]).includes(fullAccessGroupPermission)
|
||||||
|
? "full"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!entityPermission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessDisplayRow
|
||||||
|
key={group.id}
|
||||||
|
itemContent={<GroupItemContent group={group} />}
|
||||||
|
permission={entityPermission}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
136
apps/nextjs/src/components/access/user-access-form.tsx
Normal file
136
apps/nextjs/src/components/access/user-access-form.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { 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 { useModalAction } from "@homarr/modals";
|
||||||
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import type { AccessQueryData } from "./access-settings";
|
||||||
|
import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
|
||||||
|
import { useAccessContext } from "./context";
|
||||||
|
import type { AccessFormType, HandleCountChange } from "./form";
|
||||||
|
import { FormProvider, useForm } from "./form";
|
||||||
|
import { UserSelectModal } from "./user-select-modal";
|
||||||
|
|
||||||
|
export interface FormProps<TPermission extends string> {
|
||||||
|
entity: {
|
||||||
|
id: string;
|
||||||
|
ownerId: string | null;
|
||||||
|
owner: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
image: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
accessQueryData: AccessQueryData<TPermission>;
|
||||||
|
handleCountChange: HandleCountChange;
|
||||||
|
handleSubmit: (values: AccessFormType<TPermission>) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UsersAccessForm = <TPermission extends string>({
|
||||||
|
entity,
|
||||||
|
accessQueryData,
|
||||||
|
handleCountChange,
|
||||||
|
handleSubmit,
|
||||||
|
isPending,
|
||||||
|
}: FormProps<TPermission>) => {
|
||||||
|
const { defaultPermission } = useAccessContext();
|
||||||
|
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
|
||||||
|
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
|
||||||
|
);
|
||||||
|
const { openModal } = useModalAction(UserSelectModal);
|
||||||
|
const t = useI18n();
|
||||||
|
const tPermissions = useScopedI18n("permission");
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
items: accessQueryData.users.map(({ user, permission }) => ({
|
||||||
|
principalId: user.id,
|
||||||
|
permission,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddUser = () => {
|
||||||
|
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds,
|
||||||
|
onSelect: (user) => {
|
||||||
|
setUsers((prev) => new Map(prev).set(user.id, user));
|
||||||
|
form.setFieldValue("items", [
|
||||||
|
{
|
||||||
|
principalId: user.id,
|
||||||
|
permission: defaultPermission,
|
||||||
|
},
|
||||||
|
...form.values.items,
|
||||||
|
]);
|
||||||
|
handleCountChange((prev) => prev + 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
|
||||||
|
<FormProvider form={form}>
|
||||||
|
<Stack pt="sm">
|
||||||
|
<Table>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
||||||
|
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{entity.owner && (
|
||||||
|
<AccessDisplayRow itemContent={<UserItemContent user={entity.owner} />} permission="full" />
|
||||||
|
)}
|
||||||
|
{form.values.items.map((row, index) => (
|
||||||
|
<AccessSelectRow
|
||||||
|
key={row.principalId}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
itemContent={<UserItemContent user={users.get(row.principalId)!} />}
|
||||||
|
permission={row.permission}
|
||||||
|
index={index}
|
||||||
|
handleCountChange={handleCountChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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("permission.action.saveUser")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</FormProvider>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UserItemContentProps {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserItemContent = ({ user }: UserItemContentProps) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -72,7 +72,7 @@ export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps })
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"),
|
defaultTitle: (t) => t("permission.userSelect.title"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
@@ -113,7 +113,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
|
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||||
|
|
||||||
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
|
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
changeBoardVisibility: protectedProcedure
|
changeBoardVisibility: protectedProcedure
|
||||||
.input(validation.board.changeVisibility)
|
.input(validation.board.changeVisibility)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(boards)
|
.update(boards)
|
||||||
@@ -130,12 +130,12 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.where(eq(boards.id, input.id));
|
.where(eq(boards.id, input.id));
|
||||||
}),
|
}),
|
||||||
deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||||
|
|
||||||
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
||||||
}),
|
}),
|
||||||
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-view");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||||
|
|
||||||
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||||
}),
|
}),
|
||||||
@@ -148,20 +148,20 @@ export const boardRouter = createTRPCRouter({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const boardWhere = user?.homeBoardId ? eq(boards.id, user.homeBoardId) : eq(boards.name, "home");
|
const boardWhere = user?.homeBoardId ? eq(boards.id, user.homeBoardId) : eq(boards.name, "home");
|
||||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||||
|
|
||||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||||
}),
|
}),
|
||||||
getBoardByName: publicProcedure.input(validation.board.byName).query(async ({ input, ctx }) => {
|
getBoardByName: publicProcedure.input(validation.board.byName).query(async ({ input, ctx }) => {
|
||||||
const boardWhere = eq(boards.name, input.name);
|
const boardWhere = eq(boards.name, input.name);
|
||||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||||
|
|
||||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||||
}),
|
}),
|
||||||
savePartialBoardSettings: protectedProcedure
|
savePartialBoardSettings: protectedProcedure
|
||||||
.input(validation.board.savePartialSettings.and(z.object({ id: z.string() })))
|
.input(validation.board.savePartialSettings.and(z.object({ id: z.string() })))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-change");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(boards)
|
.update(boards)
|
||||||
@@ -192,7 +192,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.where(eq(boards.id, input.id));
|
.where(eq(boards.id, input.id));
|
||||||
}),
|
}),
|
||||||
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
|
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-change");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||||
|
|
||||||
await ctx.db.transaction(async (transaction) => {
|
await ctx.db.transaction(async (transaction) => {
|
||||||
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
|
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
|
||||||
@@ -332,12 +332,12 @@ export const boardRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getBoardPermissions: protectedProcedure.input(validation.board.permissions).query(async ({ input, ctx }) => {
|
getBoardPermissions: protectedProcedure.input(validation.board.permissions).query(async ({ input, ctx }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||||
|
|
||||||
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
|
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
|
||||||
where: inArray(
|
where: inArray(
|
||||||
groupPermissions.permission,
|
groupPermissions.permission,
|
||||||
getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-access"]),
|
getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-all"]),
|
||||||
),
|
),
|
||||||
columns: {
|
columns: {
|
||||||
groupId: false,
|
groupId: false,
|
||||||
@@ -381,7 +381,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||||
}),
|
}),
|
||||||
userPermissions: userPermissions
|
users: userPermissions
|
||||||
.map(({ user, permission }) => ({
|
.map(({ user, permission }) => ({
|
||||||
user,
|
user,
|
||||||
permission,
|
permission,
|
||||||
@@ -389,7 +389,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.sort((permissionA, permissionB) => {
|
.sort((permissionA, permissionB) => {
|
||||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||||
}),
|
}),
|
||||||
groupPermissions: dbGroupBoardPermission
|
groups: dbGroupBoardPermission
|
||||||
.map(({ group, permission }) => ({
|
.map(({ group, permission }) => ({
|
||||||
group: {
|
group: {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -405,18 +405,18 @@ export const boardRouter = createTRPCRouter({
|
|||||||
saveUserBoardPermissions: protectedProcedure
|
saveUserBoardPermissions: protectedProcedure
|
||||||
.input(validation.board.savePermissions)
|
.input(validation.board.savePermissions)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
|
||||||
|
|
||||||
await ctx.db.transaction(async (transaction) => {
|
await ctx.db.transaction(async (transaction) => {
|
||||||
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.id));
|
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId));
|
||||||
if (input.permissions.length === 0) {
|
if (input.permissions.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transaction.insert(boardUserPermissions).values(
|
await transaction.insert(boardUserPermissions).values(
|
||||||
input.permissions.map((permission) => ({
|
input.permissions.map((permission) => ({
|
||||||
userId: permission.itemId,
|
userId: permission.principalId,
|
||||||
permission: permission.permission,
|
permission: permission.permission,
|
||||||
boardId: input.id,
|
boardId: input.entityId,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -424,18 +424,18 @@ export const boardRouter = createTRPCRouter({
|
|||||||
saveGroupBoardPermissions: protectedProcedure
|
saveGroupBoardPermissions: protectedProcedure
|
||||||
.input(validation.board.savePermissions)
|
.input(validation.board.savePermissions)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
|
||||||
|
|
||||||
await ctx.db.transaction(async (transaction) => {
|
await ctx.db.transaction(async (transaction) => {
|
||||||
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.id));
|
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId));
|
||||||
if (input.permissions.length === 0) {
|
if (input.permissions.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transaction.insert(boardGroupPermissions).values(
|
await transaction.insert(boardGroupPermissions).values(
|
||||||
input.permissions.map((permission) => ({
|
input.permissions.map((permission) => ({
|
||||||
groupId: permission.itemId,
|
groupId: permission.principalId,
|
||||||
permission: permission.permission,
|
permission: permission.permission,
|
||||||
boardId: input.id,
|
boardId: input.entityId,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { BoardPermission } from "@homarr/definitions";
|
|||||||
export const throwIfActionForbiddenAsync = async (
|
export const throwIfActionForbiddenAsync = async (
|
||||||
ctx: { db: Database; session: Session | null },
|
ctx: { db: Database; session: Session | null },
|
||||||
boardWhere: SQL<unknown>,
|
boardWhere: SQL<unknown>,
|
||||||
permission: "full-access" | BoardPermission,
|
permission: BoardPermission,
|
||||||
) => {
|
) => {
|
||||||
const { db, session } = ctx;
|
const { db, session } = ctx;
|
||||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||||
@@ -49,11 +49,11 @@ export const throwIfActionForbiddenAsync = async (
|
|||||||
return; // As full access is required and user has full access, allow
|
return; // As full access is required and user has full access, allow
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["board-change", "board-view"].includes(permission) && hasChangeAccess) {
|
if (["modify", "view"].includes(permission) && hasChangeAccess) {
|
||||||
return; // As change access is required and user has change access, allow
|
return; // As change access is required and user has change access, allow
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permission === "board-view" && hasViewAccess) {
|
if (permission === "view" && hasViewAccess) {
|
||||||
return; // As view access is required and user has view access, allow
|
return; // As view access is required and user has view access, allow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
packages/api/src/router/integration/integration-access.ts
Normal file
73
packages/api/src/router/integration/integration-access.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import type { Session } from "@homarr/auth";
|
||||||
|
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||||
|
import type { Database, SQL } from "@homarr/db";
|
||||||
|
import { eq, inArray } from "@homarr/db";
|
||||||
|
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema/sqlite";
|
||||||
|
import type { IntegrationPermission } from "@homarr/definitions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws NOT_FOUND if user is not allowed to perform action on integration
|
||||||
|
* @param ctx trpc router context
|
||||||
|
* @param integrationWhere where clause for the integration
|
||||||
|
* @param permission permission required to perform action on integration
|
||||||
|
*/
|
||||||
|
export const throwIfActionForbiddenAsync = async (
|
||||||
|
ctx: { db: Database; session: Session | null },
|
||||||
|
integrationWhere: SQL<unknown>,
|
||||||
|
permission: IntegrationPermission,
|
||||||
|
) => {
|
||||||
|
const { db, session } = ctx;
|
||||||
|
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||||
|
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||||
|
});
|
||||||
|
const integration = await db.query.integrations.findFirst({
|
||||||
|
where: integrationWhere,
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
userPermissions: {
|
||||||
|
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
|
||||||
|
},
|
||||||
|
groupPermissions: {
|
||||||
|
where: inArray(
|
||||||
|
integrationGroupPermissions.groupId,
|
||||||
|
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
notAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
if (hasFullAccess) {
|
||||||
|
return; // As full access is required and user has full access, allow
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["interact", "use"].includes(permission) && hasInteractAccess) {
|
||||||
|
return; // As interact access is required and user has interact access, allow
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "use" && hasUseAccess) {
|
||||||
|
return; // As use access is required and user has use access, allow
|
||||||
|
}
|
||||||
|
|
||||||
|
notAllowed();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method returns NOT_FOUND to prevent snooping on board existence
|
||||||
|
* A function is used to use the method without return statement
|
||||||
|
*/
|
||||||
|
function notAllowed(): never {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Integration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,17 +2,24 @@ import { TRPCError } from "@trpc/server";
|
|||||||
|
|
||||||
import { decryptSecret, encryptSecret } from "@homarr/common";
|
import { decryptSecret, encryptSecret } from "@homarr/common";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq } from "@homarr/db";
|
import { and, createId, eq, inArray } from "@homarr/db";
|
||||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
import {
|
||||||
|
groupPermissions,
|
||||||
|
integrationGroupPermissions,
|
||||||
|
integrations,
|
||||||
|
integrationSecrets,
|
||||||
|
integrationUserPermissions,
|
||||||
|
} from "@homarr/db/schema/sqlite";
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
|
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||||
|
import { throwIfActionForbiddenAsync } from "./integration-access";
|
||||||
import { testConnectionAsync } from "./integration-test-connection";
|
import { testConnectionAsync } from "./integration-test-connection";
|
||||||
|
|
||||||
export const integrationRouter = createTRPCRouter({
|
export const integrationRouter = createTRPCRouter({
|
||||||
all: publicProcedure.query(async ({ ctx }) => {
|
all: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const integrations = await ctx.db.query.integrations.findMany();
|
const integrations = await ctx.db.query.integrations.findMany();
|
||||||
return integrations
|
return integrations
|
||||||
.map((integration) => ({
|
.map((integration) => ({
|
||||||
@@ -26,7 +33,8 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
byId: publicProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
|
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
|
||||||
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||||
const integration = await ctx.db.query.integrations.findFirst({
|
const integration = await ctx.db.query.integrations.findFirst({
|
||||||
where: eq(integrations.id, input.id),
|
where: eq(integrations.id, input.id),
|
||||||
with: {
|
with: {
|
||||||
@@ -60,34 +68,39 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
create: publicProcedure.input(validation.integration.create).mutation(async ({ ctx, input }) => {
|
create: permissionRequiredProcedure
|
||||||
await testConnectionAsync({
|
.requiresPermission("integration-create")
|
||||||
id: "new",
|
.input(validation.integration.create)
|
||||||
name: input.name,
|
.mutation(async ({ ctx, input }) => {
|
||||||
url: input.url,
|
await testConnectionAsync({
|
||||||
kind: input.kind,
|
id: "new",
|
||||||
secrets: input.secrets,
|
name: input.name,
|
||||||
});
|
url: input.url,
|
||||||
|
kind: input.kind,
|
||||||
|
secrets: input.secrets,
|
||||||
|
});
|
||||||
|
|
||||||
const integrationId = createId();
|
const integrationId = createId();
|
||||||
await ctx.db.insert(integrations).values({
|
await ctx.db.insert(integrations).values({
|
||||||
id: integrationId,
|
id: integrationId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (input.secrets.length >= 1) {
|
||||||
|
await ctx.db.insert(integrationSecrets).values(
|
||||||
|
input.secrets.map((secret) => ({
|
||||||
|
kind: secret.kind,
|
||||||
|
value: encryptSecret(secret.value),
|
||||||
|
integrationId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
update: protectedProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
|
||||||
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||||
|
|
||||||
if (input.secrets.length >= 1) {
|
|
||||||
await ctx.db.insert(integrationSecrets).values(
|
|
||||||
input.secrets.map((secret) => ({
|
|
||||||
kind: secret.kind,
|
|
||||||
value: encryptSecret(secret.value),
|
|
||||||
integrationId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
update: publicProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
|
|
||||||
const integration = await ctx.db.query.integrations.findFirst({
|
const integration = await ctx.db.query.integrations.findFirst({
|
||||||
where: eq(integrations.id, input.id),
|
where: eq(integrations.id, input.id),
|
||||||
with: {
|
with: {
|
||||||
@@ -146,7 +159,9 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
delete: publicProcedure.input(validation.integration.delete).mutation(async ({ ctx, input }) => {
|
delete: protectedProcedure.input(validation.integration.delete).mutation(async ({ ctx, input }) => {
|
||||||
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||||
|
|
||||||
const integration = await ctx.db.query.integrations.findFirst({
|
const integration = await ctx.db.query.integrations.findFirst({
|
||||||
where: eq(integrations.id, input.id),
|
where: eq(integrations.id, input.id),
|
||||||
});
|
});
|
||||||
@@ -160,6 +175,119 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
||||||
}),
|
}),
|
||||||
|
getIntegrationPermissions: protectedProcedure.input(validation.board.permissions).query(async ({ input, ctx }) => {
|
||||||
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||||
|
|
||||||
|
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
|
||||||
|
where: inArray(
|
||||||
|
groupPermissions.permission,
|
||||||
|
getPermissionsWithParents(["integration-use-all", "integration-interact-all", "integration-full-all"]),
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
groupId: false,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
group: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userPermissions = await ctx.db.query.integrationUserPermissions.findMany({
|
||||||
|
where: eq(integrationUserPermissions.integrationId, input.id),
|
||||||
|
with: {
|
||||||
|
user: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbGroupIntegrationPermission = await ctx.db.query.integrationGroupPermissions.findMany({
|
||||||
|
where: eq(integrationGroupPermissions.integrationId, input.id),
|
||||||
|
with: {
|
||||||
|
group: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||||
|
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||||
|
}),
|
||||||
|
users: userPermissions
|
||||||
|
.map(({ user, permission }) => ({
|
||||||
|
user,
|
||||||
|
permission,
|
||||||
|
}))
|
||||||
|
.sort((permissionA, permissionB) => {
|
||||||
|
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||||
|
}),
|
||||||
|
groups: dbGroupIntegrationPermission
|
||||||
|
.map(({ group, permission }) => ({
|
||||||
|
group: {
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
},
|
||||||
|
permission,
|
||||||
|
}))
|
||||||
|
.sort((permissionA, permissionB) => {
|
||||||
|
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
saveUserIntegrationPermissions: protectedProcedure
|
||||||
|
.input(validation.integration.savePermissions)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
|
||||||
|
|
||||||
|
await ctx.db.transaction(async (transaction) => {
|
||||||
|
await transaction
|
||||||
|
.delete(integrationUserPermissions)
|
||||||
|
.where(eq(integrationUserPermissions.integrationId, input.entityId));
|
||||||
|
if (input.permissions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await transaction.insert(integrationUserPermissions).values(
|
||||||
|
input.permissions.map((permission) => ({
|
||||||
|
userId: permission.principalId,
|
||||||
|
permission: permission.permission,
|
||||||
|
integrationId: input.entityId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
saveGroupIntegrationPermissions: protectedProcedure
|
||||||
|
.input(validation.integration.savePermissions)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
|
||||||
|
|
||||||
|
await ctx.db.transaction(async (transaction) => {
|
||||||
|
await transaction
|
||||||
|
.delete(integrationGroupPermissions)
|
||||||
|
.where(eq(integrationGroupPermissions.integrationId, input.entityId));
|
||||||
|
if (input.permissions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await transaction.insert(integrationGroupPermissions).values(
|
||||||
|
input.permissions.map((permission) => ({
|
||||||
|
groupId: permission.principalId,
|
||||||
|
permission: permission.permission,
|
||||||
|
integrationId: input.entityId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface UpdateSecretInput {
|
interface UpdateSecretInput {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
|||||||
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private2"]);
|
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
|
||||||
"with %s group board permission it should show board",
|
"with %s group board permission it should show board",
|
||||||
async (permission) => {
|
async (permission) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -222,7 +222,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
|
||||||
"with %s user board permission it should show board",
|
"with %s user board permission it should show board",
|
||||||
async (permission) => {
|
async (permission) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -347,7 +347,7 @@ describe("rename board should rename board", () => {
|
|||||||
});
|
});
|
||||||
expect(dbBoard).toBeDefined();
|
expect(dbBoard).toBeDefined();
|
||||||
expect(dbBoard?.name).toBe("newName");
|
expect(dbBoard?.name).toBe("newName");
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error when similar board name exists", async () => {
|
test("should throw error when similar board name exists", async () => {
|
||||||
@@ -422,7 +422,7 @@ describe("changeBoardVisibility should change board visibility", () => {
|
|||||||
});
|
});
|
||||||
expect(dbBoard).toBeDefined();
|
expect(dbBoard).toBeDefined();
|
||||||
expect(dbBoard?.isPublic).toBe(visibility === "public");
|
expect(dbBoard?.isPublic).toBe(visibility === "public");
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -452,7 +452,7 @@ describe("deleteBoard should delete board", () => {
|
|||||||
where: eq(boards.id, boardId),
|
where: eq(boards.id, boardId),
|
||||||
});
|
});
|
||||||
expect(dbBoard).toBeUndefined();
|
expect(dbBoard).toBeUndefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error when board not found", async () => {
|
test("should throw error when board not found", async () => {
|
||||||
@@ -485,7 +485,7 @@ describe("getHomeBoard should return home board", () => {
|
|||||||
name: "home",
|
name: "home",
|
||||||
...fullBoardProps,
|
...fullBoardProps,
|
||||||
});
|
});
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-view");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -506,7 +506,7 @@ describe("getBoardByName should return board by name", () => {
|
|||||||
name,
|
name,
|
||||||
...fullBoardProps,
|
...fullBoardProps,
|
||||||
});
|
});
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-view");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error when not present", async () => {
|
it("should throw error when not present", async () => {
|
||||||
@@ -583,7 +583,7 @@ describe("savePartialBoardSettings should save general settings", () => {
|
|||||||
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
|
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
|
||||||
expect(dbBoard?.secondaryColor).toBe(newSecondaryColor);
|
expect(dbBoard?.secondaryColor).toBe(newSecondaryColor);
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error when board not found", async () => {
|
it("should throw error when board not found", async () => {
|
||||||
@@ -638,7 +638,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
expect(definedBoard.sections.length).toBe(1);
|
expect(definedBoard.sections.length).toBe(1);
|
||||||
expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
|
expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
|
||||||
expect(section).toBeUndefined();
|
expect(section).toBeUndefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
it("should remove item when not present in input", async () => {
|
it("should remove item when not present in input", async () => {
|
||||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||||
@@ -692,7 +692,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
expect(firstSection.items.length).toBe(1);
|
expect(firstSection.items.length).toBe(1);
|
||||||
expect(firstSection.items[0]?.id).not.toBe(itemId);
|
expect(firstSection.items[0]?.id).not.toBe(itemId);
|
||||||
expect(item).toBeUndefined();
|
expect(item).toBeUndefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
it("should remove integration reference when not present in input", async () => {
|
it("should remove integration reference when not present in input", async () => {
|
||||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||||
@@ -759,7 +759,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
expect(firstItem.integrations.length).toBe(1);
|
expect(firstItem.integrations.length).toBe(1);
|
||||||
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
|
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
|
||||||
expect(integration).toBeUndefined();
|
expect(integration).toBeUndefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, name: "My first category" }]])(
|
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, name: "My first category" }]])(
|
||||||
"should add section when present in input",
|
"should add section when present in input",
|
||||||
@@ -811,7 +811,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
expect(addedSection.name).toBe(partialSection.name);
|
expect(addedSection.name).toBe(partialSection.name);
|
||||||
}
|
}
|
||||||
expect(section).toBeDefined();
|
expect(section).toBeDefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it("should add item when present in input", async () => {
|
it("should add item when present in input", async () => {
|
||||||
@@ -875,7 +875,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
expect(addedItem.xOffset).toBe(3);
|
expect(addedItem.xOffset).toBe(3);
|
||||||
expect(addedItem.yOffset).toBe(2);
|
expect(addedItem.yOffset).toBe(2);
|
||||||
expect(item).toBeDefined();
|
expect(item).toBeDefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
it("should add integration reference when present in input", async () => {
|
it("should add integration reference when present in input", async () => {
|
||||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||||
@@ -942,7 +942,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
expect(firstItem.integrations.length).toBe(1);
|
expect(firstItem.integrations.length).toBe(1);
|
||||||
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
|
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
|
||||||
expect(integrationItem).toBeDefined();
|
expect(integrationItem).toBeDefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
it("should update section when present in input", async () => {
|
it("should update section when present in input", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -1052,7 +1052,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
expect(firstItem.width).toBe(2);
|
expect(firstItem.width).toBe(2);
|
||||||
expect(firstItem.xOffset).toBe(7);
|
expect(firstItem.xOffset).toBe(7);
|
||||||
expect(firstItem.yOffset).toBe(5);
|
expect(firstItem.yOffset).toBe(5);
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
it("should fail when board not found", async () => {
|
it("should fail when board not found", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -1091,12 +1091,12 @@ describe("getBoardPermissions should return board permissions", () => {
|
|||||||
await db.insert(boardUserPermissions).values([
|
await db.insert(boardUserPermissions).values([
|
||||||
{
|
{
|
||||||
userId: user1,
|
userId: user1,
|
||||||
permission: "board-view",
|
permission: "view",
|
||||||
boardId,
|
boardId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: user2,
|
userId: user2,
|
||||||
permission: "board-change",
|
permission: "modify",
|
||||||
boardId,
|
boardId,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -1109,7 +1109,7 @@ describe("getBoardPermissions should return board permissions", () => {
|
|||||||
|
|
||||||
await db.insert(boardGroupPermissions).values({
|
await db.insert(boardGroupPermissions).values({
|
||||||
groupId,
|
groupId,
|
||||||
permission: "board-view",
|
permission: "view",
|
||||||
boardId,
|
boardId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1122,26 +1122,26 @@ describe("getBoardPermissions should return board permissions", () => {
|
|||||||
const result = await caller.getBoardPermissions({ id: boardId });
|
const result = await caller.getBoardPermissions({ id: boardId });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.groupPermissions).toEqual([{ group: { id: groupId, name: "group1" }, permission: "board-view" }]);
|
expect(result.groups).toEqual([{ group: { id: groupId, name: "group1" }, permission: "view" }]);
|
||||||
expect(result.userPermissions).toEqual(
|
expect(result.users).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
{
|
{
|
||||||
user: { id: user1, name: null, image: null },
|
user: { id: user1, name: null, image: null },
|
||||||
permission: "board-view",
|
permission: "view",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
user: { id: user2, name: null, image: null },
|
user: { id: user2, name: null, image: null },
|
||||||
permission: "board-change",
|
permission: "modify",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(result.inherited).toEqual([{ group: { id: groupId, name: "group1" }, permission: "admin" }]);
|
expect(result.inherited).toEqual([{ group: { id: groupId, name: "group1" }, permission: "admin" }]);
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveUserBoardPermissions should save user board permissions", () => {
|
describe("saveUserBoardPermissions should save user board permissions", () => {
|
||||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
|
||||||
"should save user board permissions",
|
"should save user board permissions",
|
||||||
async (permission) => {
|
async (permission) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -1163,10 +1163,10 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
await caller.saveUserBoardPermissions({
|
await caller.saveUserBoardPermissions({
|
||||||
id: boardId,
|
entityId: boardId,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
itemId: user1,
|
principalId: user1,
|
||||||
permission,
|
permission,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1177,13 +1177,13 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
|
|||||||
where: eq(boardUserPermissions.userId, user1),
|
where: eq(boardUserPermissions.userId, user1),
|
||||||
});
|
});
|
||||||
expect(dbUserPermission).toBeDefined();
|
expect(dbUserPermission).toBeDefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveGroupBoardPermissions should save group board permissions", () => {
|
describe("saveGroupBoardPermissions should save group board permissions", () => {
|
||||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
|
||||||
"should save group board permissions",
|
"should save group board permissions",
|
||||||
async (permission) => {
|
async (permission) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -1210,10 +1210,10 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
await caller.saveGroupBoardPermissions({
|
await caller.saveGroupBoardPermissions({
|
||||||
id: boardId,
|
entityId: boardId,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
itemId: groupId,
|
principalId: groupId,
|
||||||
permission,
|
permission,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1224,7 +1224,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
|||||||
where: eq(boardGroupPermissions.groupId, groupId),
|
where: eq(boardGroupPermissions.groupId, groupId),
|
||||||
});
|
});
|
||||||
expect(dbGroupPermission).toBeDefined();
|
expect(dbGroupPermission).toBeDefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,14 +18,11 @@ const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) =>
|
|||||||
await expect(act()).resolves.toBeUndefined();
|
await expect(act()).resolves.toBeUndefined();
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: most of this test can be used for constructBoardPermissions
|
|
||||||
// TODO: the tests for the board-access can be reduced to about 4 tests (as the unit has shrunk)
|
|
||||||
|
|
||||||
describe("throwIfActionForbiddenAsync should check access to board and return boolean", () => {
|
describe("throwIfActionForbiddenAsync should check access to board and return boolean", () => {
|
||||||
test.each([
|
test.each([
|
||||||
["full-access" as const, true],
|
["full" as const, true],
|
||||||
["board-change" as const, true],
|
["modify" as const, true],
|
||||||
["board-view" as const, true],
|
["view" as const, true],
|
||||||
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
|
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -52,9 +49,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["full-access" as const, false],
|
["full" as const, false],
|
||||||
["board-change" as const, true],
|
["modify" as const, true],
|
||||||
["board-view" as const, true],
|
["view" as const, true],
|
||||||
])("with permission %s should return %s when hasChangeAccess is true", async (permission, expectedResult) => {
|
])("with permission %s should return %s when hasChangeAccess is true", async (permission, expectedResult) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -81,9 +78,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["full-access" as const, false],
|
["full" as const, false],
|
||||||
["board-change" as const, false],
|
["modify" as const, false],
|
||||||
["board-view" as const, true],
|
["view" as const, true],
|
||||||
])("with permission %s should return %s when hasViewAccess is true", async (permission, expectedResult) => {
|
])("with permission %s should return %s when hasViewAccess is true", async (permission, expectedResult) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -110,9 +107,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["full-access" as const, false],
|
["full" as const, false],
|
||||||
["board-change" as const, false],
|
["modify" as const, false],
|
||||||
["board-view" as const, false],
|
["view" as const, false],
|
||||||
])("with permission %s should return %s when hasViewAccess is false", async (permission, expectedResult) => {
|
])("with permission %s should return %s when hasViewAccess is false", async (permission, expectedResult) => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -143,7 +140,7 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
|
|||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, createId()), "full-access");
|
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, createId()), "full");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await expect(act()).rejects.toThrow("Board not found");
|
await expect(act()).rejects.toThrow("Board not found");
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ describe("savePermissions should save permissions for group", () => {
|
|||||||
// Act
|
// Act
|
||||||
await caller.savePermissions({
|
await caller.savePermissions({
|
||||||
groupId,
|
groupId,
|
||||||
permissions: ["integration-use-all", "board-full-access"],
|
permissions: ["integration-use-all", "board-full-all"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -373,7 +373,7 @@ describe("savePermissions should save permissions for group", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(permissions.length).toBe(2);
|
expect(permissions.length).toBe(2);
|
||||||
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-access"]);
|
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with non existing group it should throw not found error", async () => {
|
test("with non existing group it should throw not found error", async () => {
|
||||||
@@ -390,7 +390,7 @@ describe("savePermissions should save permissions for group", () => {
|
|||||||
const actAsync = async () =>
|
const actAsync = async () =>
|
||||||
await caller.savePermissions({
|
await caller.savePermissions({
|
||||||
groupId: createId(),
|
groupId: createId(),
|
||||||
permissions: ["integration-create", "board-full-access"],
|
permissions: ["integration-create", "board-full-all"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as authShared from "@homarr/auth/shared";
|
||||||
|
import { createId, eq } from "@homarr/db";
|
||||||
|
import { integrations, users } from "@homarr/db/schema/sqlite";
|
||||||
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
|
import { throwIfActionForbiddenAsync } from "../../integration/integration-access";
|
||||||
|
|
||||||
|
const defaultCreatorId = createId();
|
||||||
|
|
||||||
|
const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) => {
|
||||||
|
if (!success) {
|
||||||
|
await expect(act()).rejects.toThrow("Integration not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(act()).resolves.toBeUndefined();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("throwIfActionForbiddenAsync should check access to integration and return boolean", () => {
|
||||||
|
test.each([
|
||||||
|
["full" as const, true],
|
||||||
|
["interact" as const, true],
|
||||||
|
["use" as const, true],
|
||||||
|
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
|
||||||
|
spy.mockReturnValue({
|
||||||
|
hasFullAccess: true,
|
||||||
|
hasInteractAccess: false,
|
||||||
|
hasUseAccess: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const integrationId = createId();
|
||||||
|
await db.insert(integrations).values({
|
||||||
|
id: integrationId,
|
||||||
|
name: "test",
|
||||||
|
kind: "adGuardHome",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const act = () =>
|
||||||
|
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expectActToBeAsync(act, expectedResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["full" as const, false],
|
||||||
|
["interact" as const, true],
|
||||||
|
["use" as const, true],
|
||||||
|
])("with permission %s should return %s when hasInteractAccess is true", async (permission, expectedResult) => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
|
||||||
|
spy.mockReturnValue({
|
||||||
|
hasFullAccess: false,
|
||||||
|
hasInteractAccess: true,
|
||||||
|
hasUseAccess: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(users).values({ id: defaultCreatorId });
|
||||||
|
const integrationId = createId();
|
||||||
|
await db.insert(integrations).values({
|
||||||
|
id: integrationId,
|
||||||
|
name: "test",
|
||||||
|
kind: "adGuardHome",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const act = () =>
|
||||||
|
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expectActToBeAsync(act, expectedResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["full" as const, false],
|
||||||
|
["interact" as const, false],
|
||||||
|
["use" as const, true],
|
||||||
|
])("with permission %s should return %s when hasUseAccess is true", async (permission, expectedResult) => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
|
||||||
|
spy.mockReturnValue({
|
||||||
|
hasFullAccess: false,
|
||||||
|
hasInteractAccess: false,
|
||||||
|
hasUseAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(users).values({ id: defaultCreatorId });
|
||||||
|
const integrationId = createId();
|
||||||
|
await db.insert(integrations).values({
|
||||||
|
id: integrationId,
|
||||||
|
name: "test",
|
||||||
|
kind: "adGuardHome",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const act = () =>
|
||||||
|
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expectActToBeAsync(act, expectedResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["full" as const, false],
|
||||||
|
["interact" as const, false],
|
||||||
|
["use" as const, false],
|
||||||
|
])("with permission %s should return %s when hasUseAccess is false", async (permission, expectedResult) => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
|
||||||
|
spy.mockReturnValue({
|
||||||
|
hasFullAccess: false,
|
||||||
|
hasInteractAccess: false,
|
||||||
|
hasUseAccess: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(users).values({ id: defaultCreatorId });
|
||||||
|
const integrationId = createId();
|
||||||
|
await db.insert(integrations).values({
|
||||||
|
id: integrationId,
|
||||||
|
name: "test",
|
||||||
|
kind: "adGuardHome",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const act = () =>
|
||||||
|
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expectActToBeAsync(act, expectedResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw when integration is not found", async () => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, createId()), "full");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(act()).rejects.toThrow("Integration not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { encryptSecret } from "@homarr/common";
|
import { encryptSecret } from "@homarr/common";
|
||||||
import { createId } from "@homarr/db";
|
import { createId } from "@homarr/db";
|
||||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
|
|
||||||
import { integrationRouter } from "../../integration/integration-router";
|
import { integrationRouter } from "../../integration/integration-router";
|
||||||
import { expectToBeDefined } from "../helper";
|
import { expectToBeDefined } from "../helper";
|
||||||
|
|
||||||
|
const defaultUserId = createId();
|
||||||
|
const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) =>
|
||||||
|
({
|
||||||
|
user: {
|
||||||
|
id: defaultUserId,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
}) satisfies Session;
|
||||||
|
|
||||||
// Mock the auth module to return an empty session
|
// Mock the auth module to return an empty session
|
||||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||||
vi.mock("../../integration/integration-test-connection", () => ({
|
vi.mock("../../integration/integration-test-connection", () => ({
|
||||||
@@ -17,11 +28,11 @@ vi.mock("../../integration/integration-test-connection", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("all should return all integrations", () => {
|
describe("all should return all integrations", () => {
|
||||||
it("should return all integrations", async () => {
|
test("with any session should return all integrations", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: defaultSessionWithPermissions(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(integrations).values([
|
await db.insert(integrations).values([
|
||||||
@@ -47,11 +58,11 @@ describe("all should return all integrations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("byId should return an integration by id", () => {
|
describe("byId should return an integration by id", () => {
|
||||||
it("should return an integration by id", async () => {
|
test("with full access should return an integration by id", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(integrations).values([
|
await db.insert(integrations).values([
|
||||||
@@ -73,22 +84,22 @@ describe("byId should return an integration by id", () => {
|
|||||||
expect(result.kind).toBe("plex");
|
expect(result.kind).toBe("plex");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw an error if the integration does not exist", async () => {
|
test("with full access should throw an error if the integration does not exist", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const actAsync = async () => await caller.byId({ id: "2" });
|
const actAsync = async () => await caller.byId({ id: "2" });
|
||||||
await expect(actAsync()).rejects.toThrow("Integration not found");
|
await expect(actAsync()).rejects.toThrow("Integration not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should only return the public secret values", async () => {
|
test("with full access should only return the public secret values", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(integrations).values([
|
await db.insert(integrations).values([
|
||||||
@@ -129,14 +140,38 @@ describe("byId should return an integration by id", () => {
|
|||||||
const apiKey = expectToBeDefined(result.secrets.find((secret) => secret.kind === "apiKey"));
|
const apiKey = expectToBeDefined(result.secrets.find((secret) => secret.kind === "apiKey"));
|
||||||
expect(apiKey.value).toBeNull();
|
expect(apiKey.value).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("create should create a new integration", () => {
|
test("without full access should throw integration not found error", async () => {
|
||||||
it("should create a new integration", async () => {
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: defaultSessionWithPermissions(["integration-interact-all"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(integrations).values([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Home assistant",
|
||||||
|
kind: "homeAssistant",
|
||||||
|
url: "http://homeassist.local",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await caller.byId({ id: "1" });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).rejects.toThrow("Integration not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create should create a new integration", () => {
|
||||||
|
test("with create integration access should create a new integration", async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const caller = integrationRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: defaultSessionWithPermissions(["integration-create"]),
|
||||||
});
|
});
|
||||||
const input = {
|
const input = {
|
||||||
name: "Jellyfin",
|
name: "Jellyfin",
|
||||||
@@ -164,14 +199,35 @@ describe("create should create a new integration", () => {
|
|||||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("update should update an integration", () => {
|
test("without create integration access should throw permission error", async () => {
|
||||||
it("should update an integration", async () => {
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: defaultSessionWithPermissions(["integration-interact-all"]),
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
name: "Jellyfin",
|
||||||
|
kind: "jellyfin" as const,
|
||||||
|
url: "http://jellyfin.local",
|
||||||
|
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await caller.create(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update should update an integration", () => {
|
||||||
|
test("with full access should update an integration", async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const caller = integrationRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastWeek = new Date("2023-06-24T00:00:00Z");
|
const lastWeek = new Date("2023-06-24T00:00:00Z");
|
||||||
@@ -241,11 +297,11 @@ describe("update should update an integration", () => {
|
|||||||
expect(apiKey.value).not.toEqual(input.secrets[2]!.value);
|
expect(apiKey.value).not.toEqual(input.secrets[2]!.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw an error if the integration does not exist", async () => {
|
test("with full access should throw an error if the integration does not exist", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const actAsync = async () =>
|
const actAsync = async () =>
|
||||||
@@ -257,14 +313,35 @@ describe("update should update an integration", () => {
|
|||||||
});
|
});
|
||||||
await expect(actAsync()).rejects.toThrow("Integration not found");
|
await expect(actAsync()).rejects.toThrow("Integration not found");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("delete should delete an integration", () => {
|
test("without full access should throw permission error", async () => {
|
||||||
it("should delete an integration", async () => {
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = integrationRouter.createCaller({
|
const caller = integrationRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: defaultSessionWithPermissions(["integration-interact-all"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () =>
|
||||||
|
await caller.update({
|
||||||
|
id: createId(),
|
||||||
|
name: "Pi Hole",
|
||||||
|
url: "http://hole.local",
|
||||||
|
secrets: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).rejects.toThrow("Integration not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delete should delete an integration", () => {
|
||||||
|
test("with full access should delete an integration", async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const caller = integrationRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const integrationId = createId();
|
const integrationId = createId();
|
||||||
@@ -291,4 +368,19 @@ describe("delete should delete an integration", () => {
|
|||||||
const dbSecrets = await db.query.integrationSecrets.findMany();
|
const dbSecrets = await db.query.integrationSecrets.findMany();
|
||||||
expect(dbSecrets.length).toBe(0);
|
expect(dbSecrets.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("without full access should throw permission error", async () => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const caller = integrationRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: defaultSessionWithPermissions(["integration-interact-all"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await caller.delete({ id: createId() });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).rejects.toThrow("Integration not found");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
|
|
||||||
|
import type { BoardPermission } from "@homarr/definitions";
|
||||||
|
|
||||||
export type BoardPermissionsProps = (
|
export type BoardPermissionsProps = (
|
||||||
| {
|
| {
|
||||||
creator: {
|
creator: {
|
||||||
@@ -11,10 +13,10 @@ export type BoardPermissionsProps = (
|
|||||||
}
|
}
|
||||||
) & {
|
) & {
|
||||||
userPermissions: {
|
userPermissions: {
|
||||||
permission: string;
|
permission: BoardPermission;
|
||||||
}[];
|
}[];
|
||||||
groupPermissions: {
|
groupPermissions: {
|
||||||
permission: string;
|
permission: BoardPermission;
|
||||||
}[];
|
}[];
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
};
|
};
|
||||||
@@ -23,11 +25,11 @@ export const constructBoardPermissions = (board: BoardPermissionsProps, session:
|
|||||||
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
|
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasFullAccess: session?.user.id === creatorId || session?.user.permissions.includes("board-full-access"),
|
hasFullAccess: session?.user.id === creatorId || session?.user.permissions.includes("board-full-all"),
|
||||||
hasChangeAccess:
|
hasChangeAccess:
|
||||||
session?.user.id === creatorId ||
|
session?.user.id === creatorId ||
|
||||||
board.userPermissions.some(({ permission }) => permission === "board-change") ||
|
board.userPermissions.some(({ permission }) => permission === "modify") ||
|
||||||
board.groupPermissions.some(({ permission }) => permission === "board-change") ||
|
board.groupPermissions.some(({ permission }) => permission === "modify") ||
|
||||||
session?.user.permissions.includes("board-modify-all"),
|
session?.user.permissions.includes("board-modify-all"),
|
||||||
hasViewAccess:
|
hasViewAccess:
|
||||||
session?.user.id === creatorId ||
|
session?.user.id === creatorId ||
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./board-permissions";
|
export * from "./board-permissions";
|
||||||
|
export * from "./integration-permissions";
|
||||||
|
|||||||
26
packages/auth/permissions/integration-permissions.ts
Normal file
26
packages/auth/permissions/integration-permissions.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Session } from "next-auth";
|
||||||
|
|
||||||
|
import type { IntegrationPermission } from "@homarr/definitions";
|
||||||
|
|
||||||
|
export interface IntegrationPermissionsProps {
|
||||||
|
userPermissions: {
|
||||||
|
permission: IntegrationPermission;
|
||||||
|
}[];
|
||||||
|
groupPermissions: {
|
||||||
|
permission: IntegrationPermission;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const constructIntegrationPermissions = (integration: IntegrationPermissionsProps, session: Session | null) => {
|
||||||
|
return {
|
||||||
|
hasFullAccess: session?.user.permissions.includes("integration-full-all"),
|
||||||
|
hasInteractAccess:
|
||||||
|
integration.userPermissions.some(({ permission }) => permission === "interact") ||
|
||||||
|
integration.groupPermissions.some(({ permission }) => permission === "interact") ||
|
||||||
|
session?.user.permissions.includes("integration-interact-all"),
|
||||||
|
hasUseAccess:
|
||||||
|
integration.userPermissions.length >= 1 ||
|
||||||
|
integration.groupPermissions.length >= 1 ||
|
||||||
|
session?.user.permissions.includes("integration-use-all"),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -33,7 +33,7 @@ describe("constructBoardPermissions", () => {
|
|||||||
expect(result.hasViewAccess).toBe(true);
|
expect(result.hasViewAccess).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return hasFullAccess as true when session permissions include board-full-access", () => {
|
test("should return hasFullAccess as true when session permissions include board-full-all", () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const board = {
|
const board = {
|
||||||
creator: {
|
creator: {
|
||||||
@@ -46,7 +46,7 @@ describe("constructBoardPermissions", () => {
|
|||||||
const session = {
|
const session = {
|
||||||
user: {
|
user: {
|
||||||
id: "2",
|
id: "2",
|
||||||
permissions: getPermissionsWithChildren(["board-full-access"]),
|
permissions: getPermissionsWithChildren(["board-full-all"]),
|
||||||
},
|
},
|
||||||
expires: new Date().toISOString(),
|
expires: new Date().toISOString(),
|
||||||
} satisfies Session;
|
} satisfies Session;
|
||||||
@@ -87,14 +87,14 @@ describe("constructBoardPermissions", () => {
|
|||||||
expect(result.hasViewAccess).toBe(true);
|
expect(result.hasViewAccess).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return hasChangeAccess as true when board user permissions include "board-change"', () => {
|
test('should return hasChangeAccess as true when board user permissions include "modify"', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const board = {
|
const board = {
|
||||||
creator: {
|
creator: {
|
||||||
id: "1",
|
id: "1",
|
||||||
},
|
},
|
||||||
|
|
||||||
userPermissions: [{ permission: "board-change" }],
|
userPermissions: [{ permission: "modify" as const }],
|
||||||
groupPermissions: [],
|
groupPermissions: [],
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
};
|
};
|
||||||
@@ -115,14 +115,14 @@ describe("constructBoardPermissions", () => {
|
|||||||
expect(result.hasViewAccess).toBe(true);
|
expect(result.hasViewAccess).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return hasChangeAccess as true when board group permissions include board-change", () => {
|
test("should return hasChangeAccess as true when board group permissions include modify", () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const board = {
|
const board = {
|
||||||
creator: {
|
creator: {
|
||||||
id: "1",
|
id: "1",
|
||||||
},
|
},
|
||||||
userPermissions: [],
|
userPermissions: [],
|
||||||
groupPermissions: [{ permission: "board-change" }],
|
groupPermissions: [{ permission: "modify" as const }],
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
};
|
};
|
||||||
const session = {
|
const session = {
|
||||||
@@ -175,7 +175,7 @@ describe("constructBoardPermissions", () => {
|
|||||||
creator: {
|
creator: {
|
||||||
id: "1",
|
id: "1",
|
||||||
},
|
},
|
||||||
userPermissions: [{ permission: "board-view" }],
|
userPermissions: [{ permission: "view" as const }],
|
||||||
groupPermissions: [],
|
groupPermissions: [],
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
};
|
};
|
||||||
@@ -203,7 +203,7 @@ describe("constructBoardPermissions", () => {
|
|||||||
id: "1",
|
id: "1",
|
||||||
},
|
},
|
||||||
userPermissions: [],
|
userPermissions: [],
|
||||||
groupPermissions: [{ permission: "board-view" }],
|
groupPermissions: [{ permission: "view" as const }],
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
};
|
};
|
||||||
const session = {
|
const session = {
|
||||||
|
|||||||
229
packages/auth/permissions/test/integration-permissions.spec.ts
Normal file
229
packages/auth/permissions/test/integration-permissions.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { constructIntegrationPermissions } from "../integration-permissions";
|
||||||
|
|
||||||
|
describe("constructIntegrationPermissions", () => {
|
||||||
|
test("should return hasFullAccess as true when session permissions include integration-full-all", () => {
|
||||||
|
// Arrange
|
||||||
|
const integration = {
|
||||||
|
userPermissions: [],
|
||||||
|
groupPermissions: [],
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "2",
|
||||||
|
permissions: getPermissionsWithChildren(["integration-full-all"]),
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.hasFullAccess).toBe(true);
|
||||||
|
expect(result.hasInteractAccess).toBe(true);
|
||||||
|
expect(result.hasUseAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return hasInteractAccess as true when session permissions include integration-interact-all", () => {
|
||||||
|
// Arrange
|
||||||
|
const integration = {
|
||||||
|
userPermissions: [],
|
||||||
|
groupPermissions: [],
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "2",
|
||||||
|
permissions: getPermissionsWithChildren(["integration-interact-all"]),
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.hasFullAccess).toBe(false);
|
||||||
|
expect(result.hasInteractAccess).toBe(true);
|
||||||
|
expect(result.hasUseAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return hasInteractAccess as true when integration user permissions include "interact"', () => {
|
||||||
|
// Arrange
|
||||||
|
const integration = {
|
||||||
|
userPermissions: [{ permission: "interact" as const }],
|
||||||
|
groupPermissions: [],
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "2",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.hasFullAccess).toBe(false);
|
||||||
|
expect(result.hasInteractAccess).toBe(true);
|
||||||
|
expect(result.hasUseAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return hasInteractAccess as true when integration group permissions include interact", () => {
|
||||||
|
// Arrange
|
||||||
|
const integration = {
|
||||||
|
userPermissions: [],
|
||||||
|
groupPermissions: [{ permission: "interact" as const }],
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "2",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.hasFullAccess).toBe(false);
|
||||||
|
expect(result.hasInteractAccess).toBe(true);
|
||||||
|
expect(result.hasUseAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return hasUseAccess as true when session permissions include integration-use-all", () => {
|
||||||
|
// Arrange
|
||||||
|
const integration = {
|
||||||
|
userPermissions: [],
|
||||||
|
groupPermissions: [],
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "2",
|
||||||
|
permissions: getPermissionsWithChildren(["integration-use-all"]),
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.hasFullAccess).toBe(false);
|
||||||
|
expect(result.hasInteractAccess).toBe(false);
|
||||||
|
expect(result.hasUseAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return hasUseAccess as true when integration user permissions length is greater than or equal to 1", () => {
|
||||||
|
// Arrange
|
||||||
|
const integration = {
|
||||||
|
userPermissions: [{ permission: "use" as const }],
|
||||||
|
groupPermissions: [],
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "2",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.hasFullAccess).toBe(false);
|
||||||
|
expect(result.hasInteractAccess).toBe(false);
|
||||||
|
expect(result.hasUseAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return hasUseAccess as true when integration group permissions length is greater than or equal to 1", () => {
|
||||||
|
// Arrange
|
||||||
|
const integration = {
|
||||||
|
userPermissions: [],
|
||||||
|
groupPermissions: [{ permission: "use" as const }],
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "2",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.hasFullAccess).toBe(false);
|
||||||
|
expect(result.hasInteractAccess).toBe(false);
|
||||||
|
expect(result.hasUseAccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return all false when integration no permissions", () => {
|
||||||
|
// Arrange
|
||||||
|
const integration = {
|
||||||
|
userPermissions: [],
|
||||||
|
groupPermissions: [],
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
user: {
|
||||||
|
id: "2",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
} satisfies Session;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = constructIntegrationPermissions(integration, session);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.hasFullAccess).toBe(false);
|
||||||
|
expect(result.hasInteractAccess).toBe(false);
|
||||||
|
expect(result.hasUseAccess).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test("should return hasViewAccess as true when board is public", () => {
|
||||||
|
// Arrange
|
||||||
|
const board = {
|
||||||
|
creator: {
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
userPermissions: [],
|
||||||
|
groupPermissions: [],
|
||||||
|
isPublic: true,
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
*/
|
||||||
18
packages/db/migrations/mysql/0004_noisy_giant_girl.sql
Normal file
18
packages/db/migrations/mysql/0004_noisy_giant_girl.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE `integrationGroupPermissions` (
|
||||||
|
`integration_id` varchar(64) NOT NULL,
|
||||||
|
`group_id` varchar(64) NOT NULL,
|
||||||
|
`permission` text NOT NULL,
|
||||||
|
CONSTRAINT `integrationGroupPermissions_integration_id_group_id_permission_pk` PRIMARY KEY(`integration_id`,`group_id`,`permission`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `integrationUserPermission` (
|
||||||
|
`integration_id` varchar(64) NOT NULL,
|
||||||
|
`user_id` varchar(64) NOT NULL,
|
||||||
|
`permission` text NOT NULL,
|
||||||
|
CONSTRAINT `integrationUserPermission_integration_id_user_id_permission_pk` PRIMARY KEY(`integration_id`,`user_id`,`permission`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `integrationGroupPermissions` ADD CONSTRAINT `integrationGroupPermissions_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `integrationGroupPermissions` ADD CONSTRAINT `integrationGroupPermissions_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `integrationUserPermission` ADD CONSTRAINT `integrationUserPermission_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `integrationUserPermission` ADD CONSTRAINT `integrationUserPermission_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": "5",
|
"version": "5",
|
||||||
"dialect": "mysql",
|
"dialect": "mysql",
|
||||||
"id": "4e382d0d-a432-4953-bd5e-04f3f33e26a4",
|
"id": "4e382d0d-a432-4953-bd5e-04f3f33e26a4",
|
||||||
"prevId": "fdeaf6eb-cd62-4fa5-9b38-d7f80a60db9f",
|
"prevId": "ba2dd885-4e7f-4a45-99a0-7b45cbd0a5c2",
|
||||||
"tables": {
|
"tables": {
|
||||||
"account": {
|
"account": {
|
||||||
"name": "account",
|
"name": "account",
|
||||||
|
|||||||
1320
packages/db/migrations/mysql/meta/0004_snapshot.json
Normal file
1320
packages/db/migrations/mysql/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1716148439439,
|
"when": 1716148439439,
|
||||||
"tag": "0003_freezing_black_panther",
|
"tag": "0003_freezing_black_panther",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1720113913876,
|
||||||
|
"tag": "0004_noisy_giant_girl",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
17
packages/db/migrations/sqlite/0004_peaceful_red_ghost.sql
Normal file
17
packages/db/migrations/sqlite/0004_peaceful_red_ghost.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE `integrationGroupPermissions` (
|
||||||
|
`integration_id` text NOT NULL,
|
||||||
|
`group_id` text NOT NULL,
|
||||||
|
`permission` text NOT NULL,
|
||||||
|
PRIMARY KEY(`group_id`, `integration_id`, `permission`),
|
||||||
|
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`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 `integrationUserPermission` (
|
||||||
|
`integration_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`permission` text NOT NULL,
|
||||||
|
PRIMARY KEY(`integration_id`, `permission`, `user_id`),
|
||||||
|
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "5ad60251-8450-437d-9081-a456884120d2",
|
"id": "5ad60251-8450-437d-9081-a456884120d2",
|
||||||
"prevId": "0575873a-9e10-4480-8d7d-c47198622c22",
|
"prevId": "2ed0ffc3-8612-42e7-bd8e-f5f8f3338a39",
|
||||||
"tables": {
|
"tables": {
|
||||||
"account": {
|
"account": {
|
||||||
"name": "account",
|
"name": "account",
|
||||||
|
|||||||
1263
packages/db/migrations/sqlite/meta/0004_snapshot.json
Normal file
1263
packages/db/migrations/sqlite/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1716148434186,
|
"when": 1716148434186,
|
||||||
"tag": "0003_adorable_raider",
|
"tag": "0003_adorable_raider",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1720036615408,
|
||||||
|
"tag": "0004_peaceful_red_ghost",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
BoardPermission,
|
BoardPermission,
|
||||||
GroupPermissionKey,
|
GroupPermissionKey,
|
||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
|
IntegrationPermission,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
SectionKind,
|
SectionKind,
|
||||||
WidgetKind,
|
WidgetKind,
|
||||||
@@ -157,6 +158,42 @@ export const integrationSecrets = mysqlTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const integrationUserPermissions = mysqlTable(
|
||||||
|
"integrationUserPermission",
|
||||||
|
{
|
||||||
|
integrationId: varchar("integration_id", { length: 64 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
|
userId: varchar("user_id", { length: 64 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
compoundKey: primaryKey({
|
||||||
|
columns: [table.integrationId, table.userId, table.permission],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const integrationGroupPermissions = mysqlTable(
|
||||||
|
"integrationGroupPermissions",
|
||||||
|
{
|
||||||
|
integrationId: varchar("integration_id", { length: 64 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
|
groupId: varchar("group_id", { length: 64 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => groups.id, { onDelete: "cascade" }),
|
||||||
|
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
compoundKey: primaryKey({
|
||||||
|
columns: [table.integrationId, table.groupId, table.permission],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const boards = mysqlTable("board", {
|
export const boards = mysqlTable("board", {
|
||||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||||
name: varchar("name", { length: 256 }).unique().notNull(),
|
name: varchar("name", { length: 256 }).unique().notNull(),
|
||||||
@@ -387,6 +424,30 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({
|
|||||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||||
secrets: many(integrationSecrets),
|
secrets: many(integrationSecrets),
|
||||||
items: many(integrationItems),
|
items: many(integrationItems),
|
||||||
|
userPermissions: many(integrationUserPermissions),
|
||||||
|
groupPermissions: many(integrationGroupPermissions),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [integrationUserPermissions.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
integration: one(integrations, {
|
||||||
|
fields: [integrationUserPermissions.integrationId],
|
||||||
|
references: [integrations.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const integrationGroupPermissionRelations = relations(integrationGroupPermissions, ({ one }) => ({
|
||||||
|
group: one(groups, {
|
||||||
|
fields: [integrationGroupPermissions.groupId],
|
||||||
|
references: [groups.id],
|
||||||
|
}),
|
||||||
|
integration: one(integrations, {
|
||||||
|
fields: [integrationGroupPermissions.integrationId],
|
||||||
|
references: [integrations.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
|
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
BoardPermission,
|
BoardPermission,
|
||||||
GroupPermissionKey,
|
GroupPermissionKey,
|
||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
|
IntegrationPermission,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
SectionKind,
|
SectionKind,
|
||||||
WidgetKind,
|
WidgetKind,
|
||||||
@@ -160,6 +161,42 @@ export const integrationSecrets = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const integrationUserPermissions = sqliteTable(
|
||||||
|
"integrationUserPermission",
|
||||||
|
{
|
||||||
|
integrationId: text("integration_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
compoundKey: primaryKey({
|
||||||
|
columns: [table.integrationId, table.userId, table.permission],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const integrationGroupPermissions = sqliteTable(
|
||||||
|
"integrationGroupPermissions",
|
||||||
|
{
|
||||||
|
integrationId: text("integration_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
|
groupId: text("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => groups.id, { onDelete: "cascade" }),
|
||||||
|
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
compoundKey: primaryKey({
|
||||||
|
columns: [table.integrationId, table.groupId, table.permission],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const boards = sqliteTable("board", {
|
export const boards = sqliteTable("board", {
|
||||||
id: text("id").notNull().primaryKey(),
|
id: text("id").notNull().primaryKey(),
|
||||||
name: text("name").unique().notNull(),
|
name: text("name").unique().notNull(),
|
||||||
@@ -390,6 +427,30 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({
|
|||||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||||
secrets: many(integrationSecrets),
|
secrets: many(integrationSecrets),
|
||||||
items: many(integrationItems),
|
items: many(integrationItems),
|
||||||
|
userPermissions: many(integrationUserPermissions),
|
||||||
|
groupPermissions: many(integrationGroupPermissions),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [integrationUserPermissions.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
integration: one(integrations, {
|
||||||
|
fields: [integrationUserPermissions.integrationId],
|
||||||
|
references: [integrations.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const integrationGroupPermissionRelations = relations(integrationGroupPermissions, ({ one }) => ({
|
||||||
|
group: one(groups, {
|
||||||
|
fields: [integrationGroupPermissions.groupId],
|
||||||
|
references: [groups.id],
|
||||||
|
}),
|
||||||
|
integration: one(integrations, {
|
||||||
|
fields: [integrationGroupPermissions.integrationId],
|
||||||
|
references: [integrations.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
|
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
|
||||||
|
|||||||
@@ -1,23 +1,57 @@
|
|||||||
import { objectEntries, objectKeys } from "@homarr/common";
|
import { objectEntries, objectKeys } from "@homarr/common";
|
||||||
|
|
||||||
export const boardPermissions = ["board-view", "board-change"] as const;
|
/**
|
||||||
|
* Permissions for boards.
|
||||||
|
* view: Can view the board and its content. (e.g. see all items on the board, but not modify them)
|
||||||
|
* modify: Can modify the board, its content and visual settings. (e.g. move items, change the background)
|
||||||
|
* full: Can modify the board, its content, visual settings, access settings, delete, change the visibility and rename. (e.g. change the board name, delete the board, give access to other users)
|
||||||
|
*/
|
||||||
|
export const boardPermissions = ["view", "modify", "full"] as const;
|
||||||
|
export const boardPermissionsMap = {
|
||||||
|
view: "board-view-all",
|
||||||
|
modify: "board-modify-all",
|
||||||
|
full: "board-full-all",
|
||||||
|
} satisfies Record<BoardPermission, GroupPermissionKey>;
|
||||||
|
|
||||||
|
export type BoardPermission = (typeof boardPermissions)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions for integrations.
|
||||||
|
* use: Can select the integration for an item on the board. (e.g. select pi-hole for a widget)
|
||||||
|
* interact: Can interact with the integration. (e.g. enable / disable pi-hole)
|
||||||
|
* full: Can modify the integration. (e.g. change the pi-hole url, secrets and access settings)
|
||||||
|
*/
|
||||||
|
export const integrationPermissions = ["use", "interact", "full"] as const;
|
||||||
|
export const integrationPermissionsMap = {
|
||||||
|
use: "integration-use-all",
|
||||||
|
interact: "integration-interact-all",
|
||||||
|
full: "integration-full-all",
|
||||||
|
} satisfies Record<IntegrationPermission, GroupPermissionKey>;
|
||||||
|
|
||||||
|
export type IntegrationPermission = (typeof integrationPermissions)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global permissions that can be assigned to groups.
|
||||||
|
* The keys are generated through combining the key and all array items.
|
||||||
|
* For example "board-create" is a generated key
|
||||||
|
*/
|
||||||
export const groupPermissions = {
|
export const groupPermissions = {
|
||||||
board: ["create", "view-all", "modify-all", "full-access"],
|
board: ["create", "view-all", "modify-all", "full-all"],
|
||||||
integration: ["create", "use-all", "interact-all", "full-access"],
|
integration: ["create", "use-all", "interact-all", "full-all"],
|
||||||
admin: true,
|
admin: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In the following object is described how the permissions are related to each other.
|
* In the following object is described how the permissions are related to each other.
|
||||||
* For example everybody with the permission "board-modify-all" also has the permission "board-view-all".
|
* For example everybody with the permission "board-modify-all" also has the permission "board-view-all".
|
||||||
* Or admin has all permissions (board-full-access and integration-full-access which will resolve in an array of every permission).
|
* Or admin has all permissions (board-full-all and integration-full-all which will resolve in an array of every permission).
|
||||||
*/
|
*/
|
||||||
const groupPermissionParents = {
|
const groupPermissionParents = {
|
||||||
"board-modify-all": ["board-view-all"],
|
"board-modify-all": ["board-view-all"],
|
||||||
"board-full-access": ["board-modify-all", "board-create"],
|
"board-full-all": ["board-modify-all", "board-create"],
|
||||||
"integration-interact-all": ["integration-use-all"],
|
"integration-interact-all": ["integration-use-all"],
|
||||||
"integration-full-access": ["integration-interact-all", "integration-create"],
|
"integration-full-all": ["integration-interact-all", "integration-create"],
|
||||||
admin: ["board-full-access", "integration-full-access"],
|
admin: ["board-full-all", "integration-full-all"],
|
||||||
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
|
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
|
||||||
|
|
||||||
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {
|
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {
|
||||||
@@ -66,5 +100,3 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key
|
|||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, [] as GroupPermissionKey[]);
|
}, [] as GroupPermissionKey[]);
|
||||||
|
|
||||||
export type BoardPermission = (typeof boardPermissions)[number];
|
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { getPermissionsWithChildren, getPermissionsWithParents } from "../permis
|
|||||||
|
|
||||||
describe("getPermissionsWithParents should return the correct permissions", () => {
|
describe("getPermissionsWithParents should return the correct permissions", () => {
|
||||||
test.each([
|
test.each([
|
||||||
[["board-view-all"], ["board-view-all", "board-modify-all", "board-full-access", "admin"]],
|
[["board-view-all"], ["board-view-all", "board-modify-all", "board-full-all", "admin"]],
|
||||||
[["board-modify-all"], ["board-modify-all", "board-full-access", "admin"]],
|
[["board-modify-all"], ["board-modify-all", "board-full-all", "admin"]],
|
||||||
[["board-create"], ["board-create", "board-full-access", "admin"]],
|
[["board-create"], ["board-create", "board-full-all", "admin"]],
|
||||||
[["board-full-access"], ["board-full-access", "admin"]],
|
[["board-full-all"], ["board-full-all", "admin"]],
|
||||||
[["integration-use-all"], ["integration-use-all", "integration-interact-all", "integration-full-access", "admin"]],
|
[["integration-use-all"], ["integration-use-all", "integration-interact-all", "integration-full-all", "admin"]],
|
||||||
[["integration-create"], ["integration-create", "integration-full-access", "admin"]],
|
[["integration-create"], ["integration-create", "integration-full-all", "admin"]],
|
||||||
[["integration-interact-all"], ["integration-interact-all", "integration-full-access", "admin"]],
|
[["integration-interact-all"], ["integration-interact-all", "integration-full-all", "admin"]],
|
||||||
[["integration-full-access"], ["integration-full-access", "admin"]],
|
[["integration-full-all"], ["integration-full-all", "admin"]],
|
||||||
[["admin"], ["admin"]],
|
[["admin"], ["admin"]],
|
||||||
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])("expect %s to return %s", (input, expectedOutput) => {
|
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])("expect %s to return %s", (input, expectedOutput) => {
|
||||||
expect(getPermissionsWithParents(input)).toEqual(expect.arrayContaining(expectedOutput));
|
expect(getPermissionsWithParents(input)).toEqual(expect.arrayContaining(expectedOutput));
|
||||||
@@ -24,19 +24,19 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
|
|||||||
[["board-view-all"], ["board-view-all"]],
|
[["board-view-all"], ["board-view-all"]],
|
||||||
[["board-modify-all"], ["board-view-all", "board-modify-all"]],
|
[["board-modify-all"], ["board-view-all", "board-modify-all"]],
|
||||||
[["board-create"], ["board-create"]],
|
[["board-create"], ["board-create"]],
|
||||||
[["board-full-access"], ["board-full-access", "board-modify-all", "board-view-all"]],
|
[["board-full-all"], ["board-full-all", "board-modify-all", "board-view-all"]],
|
||||||
[["integration-use-all"], ["integration-use-all"]],
|
[["integration-use-all"], ["integration-use-all"]],
|
||||||
[["integration-create"], ["integration-create"]],
|
[["integration-create"], ["integration-create"]],
|
||||||
[["integration-interact-all"], ["integration-interact-all", "integration-use-all"]],
|
[["integration-interact-all"], ["integration-interact-all", "integration-use-all"]],
|
||||||
[["integration-full-access"], ["integration-full-access", "integration-interact-all", "integration-use-all"]],
|
[["integration-full-all"], ["integration-full-all", "integration-interact-all", "integration-use-all"]],
|
||||||
[
|
[
|
||||||
["admin"],
|
["admin"],
|
||||||
[
|
[
|
||||||
"admin",
|
"admin",
|
||||||
"board-full-access",
|
"board-full-all",
|
||||||
"board-modify-all",
|
"board-modify-all",
|
||||||
"board-view-all",
|
"board-view-all",
|
||||||
"integration-full-access",
|
"integration-full-all",
|
||||||
"integration-interact-all",
|
"integration-interact-all",
|
||||||
"integration-use-all",
|
"integration-use-all",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export default {
|
|||||||
label: "Modify all boards",
|
label: "Modify all boards",
|
||||||
description: "Allow members to modify all boards (Does not include access control and danger zone)",
|
description: "Allow members to modify all boards (Does not include access control and danger zone)",
|
||||||
},
|
},
|
||||||
"full-access": {
|
"full-all": {
|
||||||
label: "Full board access",
|
label: "Full board access",
|
||||||
description:
|
description:
|
||||||
"Allow members to view, modify, and delete all boards (Including access control and danger zone)",
|
"Allow members to view, modify, and delete all boards (Including access control and danger zone)",
|
||||||
@@ -187,7 +187,7 @@ export default {
|
|||||||
label: "Interact with any integration",
|
label: "Interact with any integration",
|
||||||
description: "Allow members to interact with any integration",
|
description: "Allow members to interact with any integration",
|
||||||
},
|
},
|
||||||
"full-access": {
|
"full-all": {
|
||||||
label: "Full integration access",
|
label: "Full integration access",
|
||||||
description: "Allow members to manage, use and interact with any integration",
|
description: "Allow members to manage, use and interact with any integration",
|
||||||
},
|
},
|
||||||
@@ -484,6 +484,11 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
permission: {
|
||||||
|
use: "Select integrations in items",
|
||||||
|
interact: "Interact with integrations",
|
||||||
|
full: "Full integration access",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
rtl: "{value}{symbol}",
|
rtl: "{value}{symbol}",
|
||||||
@@ -1156,36 +1161,14 @@ export default {
|
|||||||
access: {
|
access: {
|
||||||
title: "Access control",
|
title: "Access control",
|
||||||
permission: {
|
permission: {
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
item: {
|
item: {
|
||||||
"board-view": {
|
view: {
|
||||||
label: "View board",
|
label: "View board",
|
||||||
},
|
},
|
||||||
"board-change": {
|
modify: {
|
||||||
label: "Change board",
|
label: "Modify board",
|
||||||
},
|
},
|
||||||
"board-full": {
|
full: {
|
||||||
label: "Full access",
|
label: "Full access",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1605,6 +1588,35 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
permission: {
|
||||||
|
title: "Permissions",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
saveUser: "Save user permission",
|
||||||
|
saveGroup: "Save group permission",
|
||||||
|
},
|
||||||
|
},
|
||||||
navigationStructure: {
|
navigationStructure: {
|
||||||
manage: {
|
manage: {
|
||||||
label: "Manage",
|
label: "Manage",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
import { zodEnumFromArray } from "./enums";
|
import { zodEnumFromArray } from "./enums";
|
||||||
|
import { createSavePermissionsSchema } from "./permissions";
|
||||||
import { commonItemSchema, createSectionSchema } from "./shared";
|
import { commonItemSchema, createSectionSchema } from "./shared";
|
||||||
|
|
||||||
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
||||||
@@ -66,11 +67,13 @@ const permissionsSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const savePermissionsSchema = z.object({
|
const savePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions));
|
||||||
id: z.string(),
|
|
||||||
|
z.object({
|
||||||
|
entityId: z.string(),
|
||||||
permissions: z.array(
|
permissions: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
itemId: z.string(),
|
principalId: z.string(),
|
||||||
permission: zodEnumFromArray(boardPermissions),
|
permission: zodEnumFromArray(boardPermissions),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { integrationKinds, integrationSecretKinds } from "@homarr/definitions";
|
import { integrationKinds, integrationPermissions, integrationSecretKinds } from "@homarr/definitions";
|
||||||
|
|
||||||
import { zodEnumFromArray } from "./enums";
|
import { zodEnumFromArray } from "./enums";
|
||||||
|
import { createSavePermissionsSchema } from "./permissions";
|
||||||
|
|
||||||
const integrationCreateSchema = z.object({
|
const integrationCreateSchema = z.object({
|
||||||
name: z.string().nonempty().max(127),
|
name: z.string().nonempty().max(127),
|
||||||
@@ -44,10 +45,13 @@ const testConnectionSchema = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const savePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(integrationPermissions));
|
||||||
|
|
||||||
export const integrationSchemas = {
|
export const integrationSchemas = {
|
||||||
create: integrationCreateSchema,
|
create: integrationCreateSchema,
|
||||||
update: integrationUpdateSchema,
|
update: integrationUpdateSchema,
|
||||||
delete: idSchema,
|
delete: idSchema,
|
||||||
byId: idSchema,
|
byId: idSchema,
|
||||||
testConnection: testConnectionSchema,
|
testConnection: testConnectionSchema,
|
||||||
|
savePermissions: savePermissionsSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
15
packages/validation/src/permissions.ts
Normal file
15
packages/validation/src/permissions.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createSavePermissionsSchema = <const TPermissionSchema extends z.ZodEnum<[string, ...string[]]>>(
|
||||||
|
permissionSchema: TPermissionSchema,
|
||||||
|
) => {
|
||||||
|
return z.object({
|
||||||
|
entityId: z.string(),
|
||||||
|
permissions: z.array(
|
||||||
|
z.object({
|
||||||
|
principalId: z.string(),
|
||||||
|
permission: permissionSchema,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user