diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx deleted file mode 100644 index 5739e98b6..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - - setCounts(({ user, ...others }) => ({ - user: callback(user), - ...others, - })) - } - /> - - - - - setCounts(({ group, ...others }) => ({ - group: callback(group), - ...others, - })) - } - /> - - - - - - - - ); -}; - -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 ( - }> - - {t(`tab.${value}`)} - - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts deleted file mode 100644 index 08520b9d5..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts +++ /dev/null @@ -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(); - -export type OnCountChange = (callback: (prev: number) => number) => void; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx deleted file mode 100644 index d887ee696..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx +++ /dev/null @@ -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>; - -export const InheritTable = ({ initialPermissions }: InheritTableProps) => { - const tPermissions = useScopedI18n("board.setting.section.access.permission"); - return ( - - - - - {tPermissions("field.user.label")} - {tPermissions("field.permission.label")} - - - - {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 ( - } - permission={boardPermission} - /> - ); - })} - -
-
- ); -}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx deleted file mode 100644 index 775925252..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx +++ /dev/null @@ -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; - 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>( - 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 ( -
- - - - - - {tPermissions("field.user.label")} - {tPermissions("field.permission.label")} - - - - {board.creator && ( - } permission="board-full" /> - )} - {form.values.items.map((row, index) => ( - } - permission={row.permission} - index={index} - onCountChange={onCountChange} - /> - ))} - -
- - - - - -
-
-
- ); -}; - -const UserItemContent = ({ user }: { user: User }) => { - return ( - - - - - - {user.name} - - - ); -}; - -interface User { - id: string; - name: string | null; - image: string | null; -} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_board-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_board-access.tsx new file mode 100644 index 000000000..c5ea88cb2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_board-access.tsx @@ -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 ( + 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, + }, + }} + /> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index 1748ee36b..c110651d3 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -20,8 +20,8 @@ import type { TablerIcon } from "@homarr/ui"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; -import { AccessSettingsContent } from "./_access"; import { BackgroundSettingsContent } from "./_background"; +import { BoardAccessSettings } from "./_board-access"; import { ColorSettingsContent } from "./_colors"; import { CustomCssSettingsContent } from "./_customCss"; import { DangerZoneSettingsContent } from "./_danger"; @@ -44,8 +44,8 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => { const permissions = hasFullAccess ? await api.board.getBoardPermissions({ id: board.id }) : { - userPermissions: [], - groupPermissions: [], + users: [], + groups: [], inherited: [], }; @@ -89,7 +89,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props) {hasFullAccess && ( <> - + diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/integration-access-settings.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/integration-access-settings.tsx new file mode 100644 index 000000000..c6df1abad --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/integration-access-settings.tsx @@ -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 ( + t(`integration.permission.${key}`)} + query={{ + data, + invalidate: () => utils.integration.getIntegrationPermissions.invalidate(), + }} + groupsMutation={groupsMutation} + usersMutation={usersMutation} + /> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx index 3fe30771e..a2b557522 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx @@ -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 { 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 { IntegrationAccessSettings } from "../../_components/integration-access-settings"; import { IntegrationAvatar } from "../../_integration-avatar"; import { EditIntegrationForm } from "./_integration-edit-form"; @@ -13,8 +14,10 @@ interface 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 integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id }); return ( <> @@ -23,9 +26,14 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag - {t("title", { name: getIntegrationName(integration.kind) })} + {editT("title", { name: getIntegrationName(integration.kind) })} + + {t("permission.title")} +
+ +
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index c817f1c0e..7d4b436d2 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -24,7 +24,7 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati notFound(); } - const t = await getScopedI18n("integration.page.create"); + const tCreate = await getScopedI18n("integration.page.create"); const currentKind = result.data; @@ -35,7 +35,7 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati - {t("title", { name: getIntegrationName(currentKind) })} + {tCreate("title", { name: getIntegrationName(currentKind) })} diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx index a350b3236..28563fbc9 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx @@ -8,7 +8,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal"; +import { UserSelectModal } from "~/components/access/user-select-modal"; interface TransferGroupOwnershipProps { group: { diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx index 5bac56612..adc364f99 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx @@ -6,8 +6,8 @@ import { clientApi } from "@homarr/api/client"; import { useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; +import { UserSelectModal } from "~/components/access/user-select-modal"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; interface AddGroupMemberProps { diff --git a/apps/nextjs/src/components/access/access-settings.tsx b/apps/nextjs/src/components/access/access-settings.tsx new file mode 100644 index 000000000..c90b8a0d8 --- /dev/null +++ b/apps/nextjs/src/components/access/access-settings.tsx @@ -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 { + permission: TPermission; + group: { + id: string; + name: string; + }; +} + +interface UserAccessPermission { + permission: TPermission; + user: { + name: string | null; + image: string | null; + id: string; + }; +} + +interface SimpleMutation { + mutate: ( + props: { entityId: string; permissions: { principalId: string; permission: TPermission }[] }, + options: { onSuccess: () => void }, + ) => void; + isPending: boolean; +} + +export interface AccessQueryData { + inherited: GroupAccessPermission[]; + groups: GroupAccessPermission[]; + users: UserAccessPermission[]; +} + +interface Props { + permission: { + items: readonly TPermission[]; + default: TPermission; + icons: Record; + groupPermissionMapping: Record; + fullAccessGroupPermission: GroupPermissionKey; + }; + + query: { + data: AccessQueryData; + invalidate: () => Promise; + }; + groupsMutation: SimpleMutation; + usersMutation: SimpleMutation; + entity: { + id: string; + ownerId: string | null; + owner: { + id: string; + name: string | null; + image: string | null; + } | null; + }; + translate: (key: TPermission) => string; +} + +export const AccessSettings = ({ + permission, + query, + groupsMutation, + usersMutation, + entity, + translate, +}: Props) => { + const [counts, setCounts] = useState({ + user: query.data.users.length + (entity.owner ? 1 : 0), + group: query.data.groups.length, + }); + + const handleGroupSubmit = (values: AccessFormType) => { + groupsMutation.mutate( + { + entityId: entity.id, + permissions: values.items, + }, + { + onSuccess() { + void query.invalidate(); + }, + }, + ); + }; + + const handleUserSubmit = (values: AccessFormType) => { + usersMutation.mutate( + { + entityId: entity.id, + permissions: values.items, + }, + { + onSuccess() { + void query.invalidate(); + }, + }, + ); + }; + + return ( + + defaultPermission={permission.default} + icons={permission.icons} + permissions={permission.items} + translate={translate} + > + + + + + + + + + + + entity={entity} + accessQueryData={query.data} + handleCountChange={(callback) => + setCounts(({ user, ...others }) => ({ + user: callback(user), + ...others, + })) + } + handleSubmit={handleUserSubmit} + isPending={usersMutation.isPending} + /> + + + + + accessQueryData={query.data} + handleCountChange={(callback) => + setCounts(({ group, ...others }) => ({ + group: callback(group), + ...others, + })) + } + handleSubmit={handleGroupSubmit} + isPending={groupsMutation.isPending} + /> + + + + + accessQueryData={query.data} + fullAccessGroupPermission={permission.fullAccessGroupPermission} + mapPermissions={permission.groupPermissionMapping} + /> + + + + + ); +}; + +interface TabItemProps { + value: "user" | "group" | "inherited"; + count: number; + icon: TablerIcon; +} + +const TabItem = ({ value, icon: Icon, count }: TabItemProps) => { + const t = useScopedI18n("permission"); + + return ( + }> + + {t(`tab.${value}`)} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/board-access-table-rows.tsx b/apps/nextjs/src/components/access/access-table-rows.tsx similarity index 56% rename from apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/board-access-table-rows.tsx rename to apps/nextjs/src/components/access/access-table-rows.tsx index 4a370051a..199e1343c 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/board-access-table-rows.tsx +++ b/apps/nextjs/src/components/access/access-table-rows.tsx @@ -1,43 +1,36 @@ -import { useCallback } from "react"; import type { ReactNode } from "react"; +import { useCallback } from "react"; import type { SelectProps } from "@mantine/core"; import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core"; -import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react"; +import { Icon123, IconCheck } from "@tabler/icons-react"; -import type { BoardPermission } from "@homarr/definitions"; -import { boardPermissions } from "@homarr/definitions"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import type { TablerIcon } from "@homarr/ui"; +import { useI18n } from "@homarr/translation/client"; -import type { OnCountChange } from "./form"; +import { useAccessContext } from "./context"; +import type { HandleCountChange } from "./form"; import { useFormContext } from "./form"; -const icons = { - "board-change": IconPencil, - "board-view": IconEye, - "board-full": IconSettings, -} satisfies Record; - -interface BoardAccessSelectRowProps { +interface AccessSelectRowProps { itemContent: ReactNode; - permission: BoardPermission; + permission: string; 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 tPermissions = useScopedI18n("board.setting.section.access.permission"); + const { icons, getSelectData } = useAccessContext(); const form = useFormContext(); - const Icon = icons[permission]; const handleRemove = useCallback(() => { form.setFieldValue( "items", form.values.items.filter((_, i) => i !== index), ); - onCountChange((prev) => prev - 1); - }, [form, index, onCountChange]); + handleCountChange((prev) => prev - 1); + }, [form, index, handleCountChange]); + + const Icon = icons[permission] ?? Icon123; return ( @@ -50,10 +43,7 @@ export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountCh leftSection={} renderOption={RenderOption} variant="unstyled" - data={boardPermissions.map((permission) => ({ - value: permission, - label: tPermissions(`item.${permission}.label`), - }))} + data={getSelectData()} {...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 ( - - {itemContent} - - - - - - {tPermissions(`item.${permission}.label`)} - - - - ); -}; - const iconProps = { stroke: 1.5, color: "currentColor", @@ -98,7 +64,9 @@ const iconProps = { }; const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => { - const Icon = icons[option.value as BoardPermission]; + const { icons } = useAccessContext(); + + const Icon = icons[option.value] ?? Icon123; return ( @@ -107,3 +75,27 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => { ); }; + +interface AccessDisplayRowProps { + itemContent: ReactNode; + permission: string; +} + +export const AccessDisplayRow = ({ itemContent, permission }: AccessDisplayRowProps) => { + const { icons, translate } = useAccessContext(); + const Icon = icons[permission] ?? Icon123; + + return ( + + {itemContent} + + + + + + {translate(permission)} + + + + ); +}; diff --git a/apps/nextjs/src/components/access/context.tsx b/apps/nextjs/src/components/access/context.tsx new file mode 100644 index 000000000..259c59a42 --- /dev/null +++ b/apps/nextjs/src/components/access/context.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext } from "react"; +import type { TablerIcon } from "@tabler/icons-react"; + +const AccessContext = createContext<{ + permissions: readonly string[]; + icons: Record; + translate: (key: string) => string; + defaultPermission: string; +} | null>(null); + +export const useAccessContext = () => { + const context = useContext(AccessContext); + + if (!context) { + throw new Error("useAccessContext must be used within a AccessProvider"); + } + + return { + icons: context.icons as Record, + 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 = ({ + defaultPermission, + permissions, + icons, + translate, + children, +}: { + defaultPermission: TPermission; + permissions: readonly TPermission[]; + icons: Record; + translate: (key: TPermission) => string; + children: React.ReactNode; +}) => { + return ( + string, + }} + > + {children} + + ); +}; diff --git a/apps/nextjs/src/components/access/form.ts b/apps/nextjs/src/components/access/form.ts new file mode 100644 index 000000000..39f5a3070 --- /dev/null +++ b/apps/nextjs/src/components/access/form.ts @@ -0,0 +1,12 @@ +import { createFormContext } from "@homarr/form"; + +export interface AccessFormType { + items: { + principalId: string; + permission: TPermission; + }[]; +} + +export const [FormProvider, useFormContext, useForm] = createFormContext>(); + +export type HandleCountChange = (callback: (prev: number) => number) => void; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx b/apps/nextjs/src/components/access/group-access-form.tsx similarity index 52% rename from apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx rename to apps/nextjs/src/components/access/group-access-form.tsx index 7a6c4dce3..4f62f6335 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx +++ b/apps/nextjs/src/components/access/group-access-form.tsx @@ -1,73 +1,60 @@ -import { useCallback, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react"; -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; import { useModalAction } from "@homarr/modals"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { BoardAccessSelectRow } from "./board-access-table-rows"; -import type { BoardAccessFormType } from "./form"; +import type { AccessQueryData } from "./access-settings"; +import { AccessSelectRow } from "./access-table-rows"; +import { useAccessContext } from "./context"; +import type { AccessFormType } from "./form"; import { FormProvider, useForm } from "./form"; 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) => { - const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation(); - const utils = clientApi.useUtils(); - const [groups, setGroups] = useState>( - new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])), +export const GroupAccessForm = ({ + accessQueryData, + handleCountChange, + handleSubmit, + isPending, +}: Omit, "entity">) => { + const { defaultPermission } = useAccessContext(); + const [groups, setGroups] = useState["groups"][number]["group"]>>( + new Map(accessQueryData.groups.map(({ group }) => [group.id, group])), ); const { openModal } = useModalAction(GroupSelectModal); const t = useI18n(); - const tPermissions = useScopedI18n("board.setting.section.access.permission"); + const tPermissions = useScopedI18n("permission"); const form = useForm({ initialValues: { - items: initialPermissions.groupPermissions.map(({ group, permission }) => ({ - itemId: group.id, + items: accessQueryData.groups.map(({ group, permission }) => ({ + principalId: group.id, permission, })), }, }); - const handleSubmit = useCallback( - (values: BoardAccessFormType) => { - mutate( - { - id: board.id, - permissions: values.items, - }, - { - onSuccess: () => { - void utils.board.getBoardPermissions.invalidate(); - }, - }, - ); - }, - [board.id, mutate, utils.board.getBoardPermissions], - ); - - const handleAddUser = useCallback(() => { + const handleAddUser = () => { openModal({ - presentGroupIds: form.values.items.map(({ itemId: id }) => id), + presentGroupIds: form.values.items.map(({ principalId: id }) => id), onSelect: (group) => { setGroups((prev) => new Map(prev).set(group.id, group)); form.setFieldValue("items", [ { - itemId: group.id, - permission: "board-view", + principalId: group.id, + permission: defaultPermission, }, ...form.values.items, ]); - onCountChange((prev) => prev + 1); + handleCountChange((prev) => prev + 1); }, }); - }, [form, openModal, onCountChange]); + }; return ( -
+ handleSubmit(values as AccessFormType))}> @@ -79,13 +66,13 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro {form.values.items.map((row, index) => ( - } + itemContent={} permission={row.permission} index={index} - onCountChange={onCountChange} + handleCountChange={handleCountChange} /> ))} @@ -96,7 +83,7 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro {t("common.action.add")} @@ -105,12 +92,10 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro ); }; -export const GroupItemContent = ({ group }: { group: Group }) => { +export const GroupItemContent = ({ group }: { group: AccessQueryData["groups"][number]["group"] }) => { return ( {group.name} ); }; - -type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"]; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx b/apps/nextjs/src/components/access/group-select-modal.tsx similarity index 96% rename from apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx rename to apps/nextjs/src/components/access/group-select-modal.tsx index ef7ff2d5e..62b37ab0f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx +++ b/apps/nextjs/src/components/access/group-select-modal.tsx @@ -63,5 +63,5 @@ export const GroupSelectModal = createModal(({ actions, innerProps } ); }).withOptions({ - defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"), + defaultTitle: (t) => t("permission.groupSelect.title"), }); diff --git a/apps/nextjs/src/components/access/inherit-access-table.tsx b/apps/nextjs/src/components/access/inherit-access-table.tsx new file mode 100644 index 000000000..5b439ce8c --- /dev/null +++ b/apps/nextjs/src/components/access/inherit-access-table.tsx @@ -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 { + accessQueryData: AccessQueryData; + mapPermissions: Partial>; + fullAccessGroupPermission: GroupPermissionKey; +} + +export const InheritAccessTable = ({ + accessQueryData, + mapPermissions, + fullAccessGroupPermission, +}: InheritTableProps) => { + const tPermissions = useScopedI18n("permission"); + return ( + +
+ + + {tPermissions("field.user.label")} + {tPermissions("field.permission.label")} + + + + {accessQueryData.inherited.map(({ group, permission }) => { + const entityPermission = + permission in mapPermissions + ? mapPermissions[permission] + : getPermissionsWithChildren([permission]).includes(fullAccessGroupPermission) + ? "full" + : null; + + if (!entityPermission) { + return null; + } + + return ( + } + permission={entityPermission} + /> + ); + })} + +
+
+ ); +}; diff --git a/apps/nextjs/src/components/access/user-access-form.tsx b/apps/nextjs/src/components/access/user-access-form.tsx new file mode 100644 index 000000000..bf30db619 --- /dev/null +++ b/apps/nextjs/src/components/access/user-access-form.tsx @@ -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 { + entity: { + id: string; + ownerId: string | null; + owner: { + id: string; + name: string | null; + image: string | null; + } | null; + }; + accessQueryData: AccessQueryData; + handleCountChange: HandleCountChange; + handleSubmit: (values: AccessFormType) => void; + isPending: boolean; +} + +export const UsersAccessForm = ({ + entity, + accessQueryData, + handleCountChange, + handleSubmit, + isPending, +}: FormProps) => { + const { defaultPermission } = useAccessContext(); + const [users, setUsers] = useState>( + 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 ( +
handleSubmit(values as AccessFormType))}> + + + + + + {tPermissions("field.user.label")} + {tPermissions("field.permission.label")} + + + + {entity.owner && ( + } permission="full" /> + )} + {form.values.items.map((row, index) => ( + } + permission={row.permission} + index={index} + handleCountChange={handleCountChange} + /> + ))} + +
+ + + + + +
+
+ + ); +}; + +interface UserItemContentProps { + user: { + id: string; + name: string | null; + image: string | null; + }; +} + +const UserItemContent = ({ user }: UserItemContentProps) => { + return ( + + + + + + {user.name} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-select-modal.tsx b/apps/nextjs/src/components/access/user-select-modal.tsx similarity index 97% rename from apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-select-modal.tsx rename to apps/nextjs/src/components/access/user-select-modal.tsx index d4c4946ff..b586e47d5 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-select-modal.tsx +++ b/apps/nextjs/src/components/access/user-select-modal.tsx @@ -72,7 +72,7 @@ export const UserSelectModal = createModal(({ actions, innerProps }) ); }).withOptions({ - defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"), + defaultTitle: (t) => t("permission.userSelect.title"), }); const iconProps = { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 0776fcb41..f39a6ac1f 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -113,7 +113,7 @@ export const boardRouter = createTRPCRouter({ }); }), 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]); @@ -122,7 +122,7 @@ export const boardRouter = createTRPCRouter({ changeBoardVisibility: protectedProcedure .input(validation.board.changeVisibility) .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 .update(boards) @@ -130,12 +130,12 @@ export const boardRouter = createTRPCRouter({ .where(eq(boards.id, input.id)); }), 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)); }), 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)); }), @@ -148,20 +148,20 @@ export const boardRouter = createTRPCRouter({ : null; 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); }), getBoardByName: publicProcedure.input(validation.board.byName).query(async ({ input, ctx }) => { 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); }), savePartialBoardSettings: protectedProcedure .input(validation.board.savePartialSettings.and(z.object({ id: z.string() }))) .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 .update(boards) @@ -192,7 +192,7 @@ export const boardRouter = createTRPCRouter({ .where(eq(boards.id, input.id)); }), 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) => { 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 }) => { - 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({ where: inArray( groupPermissions.permission, - getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-access"]), + getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-all"]), ), columns: { groupId: false, @@ -381,7 +381,7 @@ export const boardRouter = createTRPCRouter({ inherited: dbGroupPermissions.sort((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), - userPermissions: userPermissions + users: userPermissions .map(({ user, permission }) => ({ user, permission, @@ -389,7 +389,7 @@ export const boardRouter = createTRPCRouter({ .sort((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), - groupPermissions: dbGroupBoardPermission + groups: dbGroupBoardPermission .map(({ group, permission }) => ({ group: { id: group.id, @@ -405,18 +405,18 @@ export const boardRouter = createTRPCRouter({ saveUserBoardPermissions: protectedProcedure .input(validation.board.savePermissions) .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 transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.id)); + await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId)); if (input.permissions.length === 0) { return; } await transaction.insert(boardUserPermissions).values( input.permissions.map((permission) => ({ - userId: permission.itemId, + userId: permission.principalId, permission: permission.permission, - boardId: input.id, + boardId: input.entityId, })), ); }); @@ -424,18 +424,18 @@ export const boardRouter = createTRPCRouter({ saveGroupBoardPermissions: protectedProcedure .input(validation.board.savePermissions) .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 transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.id)); + await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId)); if (input.permissions.length === 0) { return; } await transaction.insert(boardGroupPermissions).values( input.permissions.map((permission) => ({ - groupId: permission.itemId, + groupId: permission.principalId, permission: permission.permission, - boardId: input.id, + boardId: input.entityId, })), ); }); diff --git a/packages/api/src/router/board/board-access.ts b/packages/api/src/router/board/board-access.ts index d27d1cbe5..0bce8817a 100644 --- a/packages/api/src/router/board/board-access.ts +++ b/packages/api/src/router/board/board-access.ts @@ -16,7 +16,7 @@ import type { BoardPermission } from "@homarr/definitions"; export const throwIfActionForbiddenAsync = async ( ctx: { db: Database; session: Session | null }, boardWhere: SQL, - permission: "full-access" | BoardPermission, + permission: BoardPermission, ) => { const { db, session } = ctx; 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 } - 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 } - if (permission === "board-view" && hasViewAccess) { + if (permission === "view" && hasViewAccess) { return; // As view access is required and user has view access, allow } diff --git a/packages/api/src/router/integration/integration-access.ts b/packages/api/src/router/integration/integration-access.ts new file mode 100644 index 000000000..f8e83e90b --- /dev/null +++ b/packages/api/src/router/integration/integration-access.ts @@ -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, + 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", + }); +} diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 611c288d1..60040f457 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -2,17 +2,24 @@ import { TRPCError } from "@trpc/server"; import { decryptSecret, encryptSecret } from "@homarr/common"; import type { Database } from "@homarr/db"; -import { and, createId, eq } from "@homarr/db"; -import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite"; +import { and, createId, eq, inArray } from "@homarr/db"; +import { + groupPermissions, + integrationGroupPermissions, + integrations, + integrationSecrets, + integrationUserPermissions, +} from "@homarr/db/schema/sqlite"; 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 { createTRPCRouter, publicProcedure } from "../../trpc"; +import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc"; +import { throwIfActionForbiddenAsync } from "./integration-access"; import { testConnectionAsync } from "./integration-test-connection"; export const integrationRouter = createTRPCRouter({ - all: publicProcedure.query(async ({ ctx }) => { + all: protectedProcedure.query(async ({ ctx }) => { const integrations = await ctx.db.query.integrations.findMany(); return integrations .map((integration) => ({ @@ -26,7 +33,8 @@ export const integrationRouter = createTRPCRouter({ 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({ where: eq(integrations.id, input.id), with: { @@ -60,34 +68,39 @@ export const integrationRouter = createTRPCRouter({ })), }; }), - create: publicProcedure.input(validation.integration.create).mutation(async ({ ctx, input }) => { - await testConnectionAsync({ - id: "new", - name: input.name, - url: input.url, - kind: input.kind, - secrets: input.secrets, - }); + create: permissionRequiredProcedure + .requiresPermission("integration-create") + .input(validation.integration.create) + .mutation(async ({ ctx, input }) => { + await testConnectionAsync({ + id: "new", + name: input.name, + url: input.url, + kind: input.kind, + secrets: input.secrets, + }); - const integrationId = createId(); - await ctx.db.insert(integrations).values({ - id: integrationId, - name: input.name, - url: input.url, - kind: input.kind, - }); + const integrationId = createId(); + await ctx.db.insert(integrations).values({ + id: integrationId, + name: input.name, + url: input.url, + 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({ where: eq(integrations.id, input.id), 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({ 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)); }), + 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 { diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index e5afca76e..5c121f91d 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -158,7 +158,7 @@ describe("getAllBoards should return all boards accessable to the current user", 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", async (permission) => { // 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", async (permission) => { // Arrange @@ -347,7 +347,7 @@ describe("rename board should rename board", () => { }); expect(dbBoard).toBeDefined(); 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 () => { @@ -422,7 +422,7 @@ describe("changeBoardVisibility should change board visibility", () => { }); expect(dbBoard).toBeDefined(); 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), }); 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 () => { @@ -485,7 +485,7 @@ describe("getHomeBoard should return home board", () => { name: "home", ...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, ...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 () => { @@ -583,7 +583,7 @@ describe("savePartialBoardSettings should save general settings", () => { expect(dbBoard?.primaryColor).toBe(newPrimaryColor); 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 () => { @@ -638,7 +638,7 @@ describe("saveBoard should save full board", () => { expect(definedBoard.sections.length).toBe(1); expect(definedBoard.sections[0]?.id).not.toBe(sectionId); 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 () => { 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[0]?.id).not.toBe(itemId); 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 () => { 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[0]?.integrationId).not.toBe(integrationId); 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" }]])( "should add section when present in input", @@ -811,7 +811,7 @@ describe("saveBoard should save full board", () => { expect(addedSection.name).toBe(partialSection.name); } 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 () => { @@ -875,7 +875,7 @@ describe("saveBoard should save full board", () => { expect(addedItem.xOffset).toBe(3); expect(addedItem.yOffset).toBe(2); 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 () => { 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[0]?.integrationId).toBe(integration.id); 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 () => { const db = createDb(); @@ -1052,7 +1052,7 @@ describe("saveBoard should save full board", () => { expect(firstItem.width).toBe(2); expect(firstItem.xOffset).toBe(7); 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 () => { const db = createDb(); @@ -1091,12 +1091,12 @@ describe("getBoardPermissions should return board permissions", () => { await db.insert(boardUserPermissions).values([ { userId: user1, - permission: "board-view", + permission: "view", boardId, }, { userId: user2, - permission: "board-change", + permission: "modify", boardId, }, ]); @@ -1109,7 +1109,7 @@ describe("getBoardPermissions should return board permissions", () => { await db.insert(boardGroupPermissions).values({ groupId, - permission: "board-view", + permission: "view", boardId, }); @@ -1122,26 +1122,26 @@ describe("getBoardPermissions should return board permissions", () => { const result = await caller.getBoardPermissions({ id: boardId }); // Assert - expect(result.groupPermissions).toEqual([{ group: { id: groupId, name: "group1" }, permission: "board-view" }]); - expect(result.userPermissions).toEqual( + expect(result.groups).toEqual([{ group: { id: groupId, name: "group1" }, permission: "view" }]); + expect(result.users).toEqual( expect.arrayContaining([ { user: { id: user1, name: null, image: null }, - permission: "board-view", + permission: "view", }, { user: { id: user2, name: null, image: null }, - permission: "board-change", + permission: "modify", }, ]), ); 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", () => { - test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])( + test.each([["view"], ["modify"]] satisfies [BoardPermission][])( "should save user board permissions", async (permission) => { // Arrange @@ -1163,10 +1163,10 @@ describe("saveUserBoardPermissions should save user board permissions", () => { // Act await caller.saveUserBoardPermissions({ - id: boardId, + entityId: boardId, permissions: [ { - itemId: user1, + principalId: user1, permission, }, ], @@ -1177,13 +1177,13 @@ describe("saveUserBoardPermissions should save user board permissions", () => { where: eq(boardUserPermissions.userId, user1), }); 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", () => { - test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])( + test.each([["view"], ["modify"]] satisfies [BoardPermission][])( "should save group board permissions", async (permission) => { // Arrange @@ -1210,10 +1210,10 @@ describe("saveGroupBoardPermissions should save group board permissions", () => // Act await caller.saveGroupBoardPermissions({ - id: boardId, + entityId: boardId, permissions: [ { - itemId: groupId, + principalId: groupId, permission, }, ], @@ -1224,7 +1224,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () => where: eq(boardGroupPermissions.groupId, groupId), }); expect(dbGroupPermission).toBeDefined(); - expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access"); + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full"); }, ); }); diff --git a/packages/api/src/router/test/board/board-access.spec.ts b/packages/api/src/router/test/board/board-access.spec.ts index a9072f7e6..599d08489 100644 --- a/packages/api/src/router/test/board/board-access.spec.ts +++ b/packages/api/src/router/test/board/board-access.spec.ts @@ -18,14 +18,11 @@ const expectActToBeAsync = async (act: () => Promise, success: boolean) => 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", () => { test.each([ - ["full-access" as const, true], - ["board-change" as const, true], - ["board-view" as const, true], + ["full" as const, true], + ["modify" as const, true], + ["view" as const, true], ])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => { // Arrange const db = createDb(); @@ -52,9 +49,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo }); test.each([ - ["full-access" as const, false], - ["board-change" as const, true], - ["board-view" as const, true], + ["full" as const, false], + ["modify" as const, true], + ["view" as const, true], ])("with permission %s should return %s when hasChangeAccess is true", async (permission, expectedResult) => { // Arrange const db = createDb(); @@ -81,9 +78,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo }); test.each([ - ["full-access" as const, false], - ["board-change" as const, false], - ["board-view" as const, true], + ["full" as const, false], + ["modify" as const, false], + ["view" as const, true], ])("with permission %s should return %s when hasViewAccess is true", async (permission, expectedResult) => { // Arrange const db = createDb(); @@ -110,9 +107,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo }); test.each([ - ["full-access" as const, false], - ["board-change" as const, false], - ["board-view" as const, false], + ["full" as const, false], + ["modify" as const, false], + ["view" as const, false], ])("with permission %s should return %s when hasViewAccess is false", async (permission, expectedResult) => { // Arrange const db = createDb(); @@ -143,7 +140,7 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo const db = createDb(); // 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 await expect(act()).rejects.toThrow("Board not found"); diff --git a/packages/api/src/router/test/group.spec.ts b/packages/api/src/router/test/group.spec.ts index c91483e72..29921ea68 100644 --- a/packages/api/src/router/test/group.spec.ts +++ b/packages/api/src/router/test/group.spec.ts @@ -364,7 +364,7 @@ describe("savePermissions should save permissions for group", () => { // Act await caller.savePermissions({ groupId, - permissions: ["integration-use-all", "board-full-access"], + permissions: ["integration-use-all", "board-full-all"], }); // Assert @@ -373,7 +373,7 @@ describe("savePermissions should save permissions for group", () => { }); 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 () => { @@ -390,7 +390,7 @@ describe("savePermissions should save permissions for group", () => { const actAsync = async () => await caller.savePermissions({ groupId: createId(), - permissions: ["integration-create", "board-full-access"], + permissions: ["integration-create", "board-full-all"], }); // Assert diff --git a/packages/api/src/router/test/integration/integration-access.spec.ts b/packages/api/src/router/test/integration/integration-access.spec.ts new file mode 100644 index 000000000..5013c015f --- /dev/null +++ b/packages/api/src/router/test/integration/integration-access.spec.ts @@ -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, 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"); + }); +}); diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index 711cfcb20..2f21e666b 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -1,15 +1,26 @@ /* 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 { encryptSecret } from "@homarr/common"; import { createId } from "@homarr/db"; import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; +import type { GroupPermissionKey } from "@homarr/definitions"; import { integrationRouter } from "../../integration/integration-router"; 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 vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("../../integration/integration-test-connection", () => ({ @@ -17,11 +28,11 @@ vi.mock("../../integration/integration-test-connection", () => ({ })); 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 caller = integrationRouter.createCaller({ db, - session: null, + session: defaultSessionWithPermissions(), }); await db.insert(integrations).values([ @@ -47,11 +58,11 @@ describe("all should return all integrations", () => { }); 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 caller = integrationRouter.createCaller({ db, - session: null, + session: defaultSessionWithPermissions(["integration-full-all"]), }); await db.insert(integrations).values([ @@ -73,22 +84,22 @@ describe("byId should return an integration by id", () => { 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 caller = integrationRouter.createCaller({ db, - session: null, + session: defaultSessionWithPermissions(["integration-full-all"]), }); const actAsync = async () => await caller.byId({ id: "2" }); 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 caller = integrationRouter.createCaller({ db, - session: null, + session: defaultSessionWithPermissions(["integration-full-all"]), }); 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")); expect(apiKey.value).toBeNull(); }); -}); -describe("create should create a new integration", () => { - it("should create a new integration", async () => { + test("without full access should throw integration not found error", async () => { + // Arrange const db = createDb(); const caller = integrationRouter.createCaller({ 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 = { 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!.updatedAt).toEqual(fakeNow); }); -}); -describe("update should update an integration", () => { - it("should update an integration", async () => { + test("without create integration access should throw permission error", async () => { + // Arrange const db = createDb(); const caller = integrationRouter.createCaller({ 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"); @@ -241,11 +297,11 @@ describe("update should update an integration", () => { 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 caller = integrationRouter.createCaller({ db, - session: null, + session: defaultSessionWithPermissions(["integration-full-all"]), }); const actAsync = async () => @@ -257,14 +313,35 @@ describe("update should update an integration", () => { }); await expect(actAsync()).rejects.toThrow("Integration not found"); }); -}); -describe("delete should delete an integration", () => { - it("should delete an integration", async () => { + test("without full access should throw permission error", async () => { + // Arrange const db = createDb(); const caller = integrationRouter.createCaller({ 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(); @@ -291,4 +368,19 @@ describe("delete should delete an integration", () => { const dbSecrets = await db.query.integrationSecrets.findMany(); 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"); + }); }); diff --git a/packages/auth/permissions/board-permissions.ts b/packages/auth/permissions/board-permissions.ts index af3b57816..817cced6f 100644 --- a/packages/auth/permissions/board-permissions.ts +++ b/packages/auth/permissions/board-permissions.ts @@ -1,5 +1,7 @@ import type { Session } from "next-auth"; +import type { BoardPermission } from "@homarr/definitions"; + export type BoardPermissionsProps = ( | { creator: { @@ -11,10 +13,10 @@ export type BoardPermissionsProps = ( } ) & { userPermissions: { - permission: string; + permission: BoardPermission; }[]; groupPermissions: { - permission: string; + permission: BoardPermission; }[]; isPublic: boolean; }; @@ -23,11 +25,11 @@ export const constructBoardPermissions = (board: BoardPermissionsProps, session: const creatorId = "creator" in board ? board.creator?.id : board.creatorId; 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: session?.user.id === creatorId || - board.userPermissions.some(({ permission }) => permission === "board-change") || - board.groupPermissions.some(({ permission }) => permission === "board-change") || + board.userPermissions.some(({ permission }) => permission === "modify") || + board.groupPermissions.some(({ permission }) => permission === "modify") || session?.user.permissions.includes("board-modify-all"), hasViewAccess: session?.user.id === creatorId || diff --git a/packages/auth/permissions/index.ts b/packages/auth/permissions/index.ts index 8090541aa..dd5b9e462 100644 --- a/packages/auth/permissions/index.ts +++ b/packages/auth/permissions/index.ts @@ -1 +1,2 @@ export * from "./board-permissions"; +export * from "./integration-permissions"; diff --git a/packages/auth/permissions/integration-permissions.ts b/packages/auth/permissions/integration-permissions.ts new file mode 100644 index 000000000..d8c4d2127 --- /dev/null +++ b/packages/auth/permissions/integration-permissions.ts @@ -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"), + }; +}; diff --git a/packages/auth/permissions/test/board-permissions.spec.ts b/packages/auth/permissions/test/board-permissions.spec.ts index 05b98a039..3e02e6be6 100644 --- a/packages/auth/permissions/test/board-permissions.spec.ts +++ b/packages/auth/permissions/test/board-permissions.spec.ts @@ -33,7 +33,7 @@ describe("constructBoardPermissions", () => { 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 const board = { creator: { @@ -46,7 +46,7 @@ describe("constructBoardPermissions", () => { const session = { user: { id: "2", - permissions: getPermissionsWithChildren(["board-full-access"]), + permissions: getPermissionsWithChildren(["board-full-all"]), }, expires: new Date().toISOString(), } satisfies Session; @@ -87,14 +87,14 @@ describe("constructBoardPermissions", () => { 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 const board = { creator: { id: "1", }, - userPermissions: [{ permission: "board-change" }], + userPermissions: [{ permission: "modify" as const }], groupPermissions: [], isPublic: false, }; @@ -115,14 +115,14 @@ describe("constructBoardPermissions", () => { 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 const board = { creator: { id: "1", }, userPermissions: [], - groupPermissions: [{ permission: "board-change" }], + groupPermissions: [{ permission: "modify" as const }], isPublic: false, }; const session = { @@ -175,7 +175,7 @@ describe("constructBoardPermissions", () => { creator: { id: "1", }, - userPermissions: [{ permission: "board-view" }], + userPermissions: [{ permission: "view" as const }], groupPermissions: [], isPublic: false, }; @@ -203,7 +203,7 @@ describe("constructBoardPermissions", () => { id: "1", }, userPermissions: [], - groupPermissions: [{ permission: "board-view" }], + groupPermissions: [{ permission: "view" as const }], isPublic: false, }; const session = { diff --git a/packages/auth/permissions/test/integration-permissions.spec.ts b/packages/auth/permissions/test/integration-permissions.spec.ts new file mode 100644 index 000000000..01c9ba818 --- /dev/null +++ b/packages/auth/permissions/test/integration-permissions.spec.ts @@ -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); + }); +}); +*/ diff --git a/packages/db/migrations/mysql/0004_noisy_giant_girl.sql b/packages/db/migrations/mysql/0004_noisy_giant_girl.sql new file mode 100644 index 000000000..dd1732222 --- /dev/null +++ b/packages/db/migrations/mysql/0004_noisy_giant_girl.sql @@ -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; \ No newline at end of file diff --git a/packages/db/migrations/mysql/meta/0002_snapshot.json b/packages/db/migrations/mysql/meta/0002_snapshot.json index f098f20c9..0616a5962 100644 --- a/packages/db/migrations/mysql/meta/0002_snapshot.json +++ b/packages/db/migrations/mysql/meta/0002_snapshot.json @@ -2,7 +2,7 @@ "version": "5", "dialect": "mysql", "id": "4e382d0d-a432-4953-bd5e-04f3f33e26a4", - "prevId": "fdeaf6eb-cd62-4fa5-9b38-d7f80a60db9f", + "prevId": "ba2dd885-4e7f-4a45-99a0-7b45cbd0a5c2", "tables": { "account": { "name": "account", diff --git a/packages/db/migrations/mysql/meta/0004_snapshot.json b/packages/db/migrations/mysql/meta/0004_snapshot.json new file mode 100644 index 000000000..5f8bfaecf --- /dev/null +++ b/packages/db/migrations/mysql/meta/0004_snapshot.json @@ -0,0 +1,1320 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "4af9bcc1-5573-4e00-8629-3c8de4c3a826", + "prevId": "4e382d0d-a432-4953-bd5e-04f3f33e26a4", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": ["provider", "providerAccountId"] + } + }, + "uniqueConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_id": { + "name": "app_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('fixed')" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('no-repeat')" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('cover')" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fa5252')" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fd7e14')" + }, + "opacity": { + "name": "opacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "board_id": { + "name": "board_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"] + } + } + }, + "groupMember": { + "name": "groupMember", + "columns": { + "groupId": { + "name": "groupId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_groupId_group_id_fk": { + "name": "groupMember_groupId_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_userId_user_id_fk": { + "name": "groupMember_userId_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_groupId_userId_pk": { + "name": "groupMember_groupId_userId_pk", + "columns": ["groupId", "userId"] + } + }, + "uniqueConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "groupId": { + "name": "groupId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_groupId_group_id_fk": { + "name": "groupPermission_groupId_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_id": { + "name": "group_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "iconRepository_id": { + "name": "iconRepository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconRepository_slug": { + "name": "iconRepository_slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "iconRepository_iconRepository_id": { + "name": "iconRepository_iconRepository_id", + "columns": ["iconRepository_id"] + } + }, + "uniqueConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "icon_id": { + "name": "icon_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_name": { + "name": "icon_name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_checksum": { + "name": "icon_checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconRepository_id": { + "name": "iconRepository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_iconRepository_id_iconRepository_iconRepository_id_fk": { + "name": "icon_iconRepository_id_iconRepository_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["iconRepository_id"], + "columnsTo": ["iconRepository_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "icon_icon_id": { + "name": "icon_icon_id", + "columns": ["icon_id"] + } + }, + "uniqueConstraints": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationGroupPermissions_integration_id_group_id_permission_pk": { + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_id": { + "name": "integration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "invite_id": { + "name": "invite_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"] + } + } + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "item_section_id_section_id_fk": { + "name": "item_section_id_section_id_fk", + "tableFrom": "item", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_id": { + "name": "item_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_id": { + "name": "section_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serverSetting_key": { + "name": "serverSetting_key", + "columns": ["key"] + } + }, + "uniqueConstraints": { + "serverSetting_key_unique": { + "name": "serverSetting_key_unique", + "columns": ["key"] + } + } + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_sessionToken": { + "name": "session_sessionToken", + "columns": ["sessionToken"] + } + }, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homeBoardId": { + "name": "homeBoardId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_homeBoardId_board_id_fk": { + "name": "user_homeBoardId_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["homeBoardId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {} + } + }, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index bb349fd94..dac0c8477 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1716148439439, "tag": "0003_freezing_black_panther", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1720113913876, + "tag": "0004_noisy_giant_girl", + "breakpoints": true } ] } diff --git a/packages/db/migrations/sqlite/0004_peaceful_red_ghost.sql b/packages/db/migrations/sqlite/0004_peaceful_red_ghost.sql new file mode 100644 index 000000000..2a83c6ee3 --- /dev/null +++ b/packages/db/migrations/sqlite/0004_peaceful_red_ghost.sql @@ -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 +); diff --git a/packages/db/migrations/sqlite/meta/0002_snapshot.json b/packages/db/migrations/sqlite/meta/0002_snapshot.json index 885a5df37..2ff06e406 100644 --- a/packages/db/migrations/sqlite/meta/0002_snapshot.json +++ b/packages/db/migrations/sqlite/meta/0002_snapshot.json @@ -2,7 +2,7 @@ "version": "6", "dialect": "sqlite", "id": "5ad60251-8450-437d-9081-a456884120d2", - "prevId": "0575873a-9e10-4480-8d7d-c47198622c22", + "prevId": "2ed0ffc3-8612-42e7-bd8e-f5f8f3338a39", "tables": { "account": { "name": "account", diff --git a/packages/db/migrations/sqlite/meta/0004_snapshot.json b/packages/db/migrations/sqlite/meta/0004_snapshot.json new file mode 100644 index 000000000..2d0f12df4 --- /dev/null +++ b/packages/db/migrations/sqlite/meta/0004_snapshot.json @@ -0,0 +1,1263 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "17473af6-8220-4f3b-970f-1cfe9e9f2ebe", + "prevId": "b72fe407-31bc-4dd0-8c36-dbb8e42ef708", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": ["provider", "providerAccountId"], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "columns": ["board_id", "group_id", "permission"], + "name": "boardGroupPermission_board_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "columns": ["board_id", "permission", "user_id"], + "name": "boardUserPermission_board_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + } + }, + "indexes": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "groupId": { + "name": "groupId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_groupId_group_id_fk": { + "name": "groupMember_groupId_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_userId_user_id_fk": { + "name": "groupMember_userId_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_groupId_userId_pk": { + "columns": ["groupId", "userId"], + "name": "groupMember_groupId_userId_pk" + } + }, + "uniqueConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "groupId": { + "name": "groupId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_groupId_group_id_fk": { + "name": "groupPermission_groupId_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "iconRepository_id": { + "name": "iconRepository_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "iconRepository_slug": { + "name": "iconRepository_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "icon_id": { + "name": "icon_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "icon_name": { + "name": "icon_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_checksum": { + "name": "icon_checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconRepository_id": { + "name": "iconRepository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_iconRepository_id_iconRepository_iconRepository_id_fk": { + "name": "icon_iconRepository_id_iconRepository_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["iconRepository_id"], + "columnsTo": ["iconRepository_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationGroupPermissions_integration_id_group_id_permission_pk": { + "columns": ["group_id", "integration_id", "permission"], + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "columns": ["integration_id", "item_id"], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "columns": ["integration_id", "kind"], + "name": "integrationSecret_integration_id_kind_pk" + } + }, + "uniqueConstraints": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "columns": ["integration_id", "permission", "user_id"], + "name": "integrationUserPermission_integration_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_section_id_section_id_fk": { + "name": "item_section_id_section_id_fk", + "tableFrom": "item", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": { + "serverSetting_key_unique": { + "name": "serverSetting_key_unique", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homeBoardId": { + "name": "homeBoardId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_homeBoardId_board_id_fk": { + "name": "user_homeBoardId_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["homeBoardId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": ["identifier", "token"], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index 5d9bc2d12..4e36deafc 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1716148434186, "tag": "0003_adorable_raider", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1720036615408, + "tag": "0004_peaceful_red_ghost", + "breakpoints": true } ] } diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 22692da00..824e269b5 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -10,6 +10,7 @@ import type { BoardPermission, GroupPermissionKey, IntegrationKind, + IntegrationPermission, IntegrationSecretKind, SectionKind, 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().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().notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.integrationId, table.groupId, table.permission], + }), + }), +); + export const boards = mysqlTable("board", { id: varchar("id", { length: 64 }).notNull().primaryKey(), name: varchar("name", { length: 256 }).unique().notNull(), @@ -387,6 +424,30 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({ export const integrationRelations = relations(integrations, ({ many }) => ({ secrets: many(integrationSecrets), 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 }) => ({ diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index ff913f264..dd72e42d5 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -12,6 +12,7 @@ import type { BoardPermission, GroupPermissionKey, IntegrationKind, + IntegrationPermission, IntegrationSecretKind, SectionKind, 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().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().notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.integrationId, table.groupId, table.permission], + }), + }), +); + export const boards = sqliteTable("board", { id: text("id").notNull().primaryKey(), name: text("name").unique().notNull(), @@ -390,6 +427,30 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({ export const integrationRelations = relations(integrations, ({ many }) => ({ secrets: many(integrationSecrets), 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 }) => ({ diff --git a/packages/definitions/src/permissions.ts b/packages/definitions/src/permissions.ts index 8c3eae83c..14ff21c46 100644 --- a/packages/definitions/src/permissions.ts +++ b/packages/definitions/src/permissions.ts @@ -1,23 +1,57 @@ 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; + +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; + +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 = { - board: ["create", "view-all", "modify-all", "full-access"], - integration: ["create", "use-all", "interact-all", "full-access"], + board: ["create", "view-all", "modify-all", "full-all"], + integration: ["create", "use-all", "interact-all", "full-all"], admin: true, } as const; /** * 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". - * 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 = { "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-full-access": ["integration-interact-all", "integration-create"], - admin: ["board-full-access", "integration-full-access"], + "integration-full-all": ["integration-interact-all", "integration-create"], + admin: ["board-full-all", "integration-full-all"], } satisfies Partial>; export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => { @@ -66,5 +100,3 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key } return acc; }, [] as GroupPermissionKey[]); - -export type BoardPermission = (typeof boardPermissions)[number]; diff --git a/packages/definitions/src/test/permissions.spec.ts b/packages/definitions/src/test/permissions.spec.ts index 1a3572c45..48a4ad665 100644 --- a/packages/definitions/src/test/permissions.spec.ts +++ b/packages/definitions/src/test/permissions.spec.ts @@ -5,14 +5,14 @@ import { getPermissionsWithChildren, getPermissionsWithParents } from "../permis describe("getPermissionsWithParents should return the correct permissions", () => { test.each([ - [["board-view-all"], ["board-view-all", "board-modify-all", "board-full-access", "admin"]], - [["board-modify-all"], ["board-modify-all", "board-full-access", "admin"]], - [["board-create"], ["board-create", "board-full-access", "admin"]], - [["board-full-access"], ["board-full-access", "admin"]], - [["integration-use-all"], ["integration-use-all", "integration-interact-all", "integration-full-access", "admin"]], - [["integration-create"], ["integration-create", "integration-full-access", "admin"]], - [["integration-interact-all"], ["integration-interact-all", "integration-full-access", "admin"]], - [["integration-full-access"], ["integration-full-access", "admin"]], + [["board-view-all"], ["board-view-all", "board-modify-all", "board-full-all", "admin"]], + [["board-modify-all"], ["board-modify-all", "board-full-all", "admin"]], + [["board-create"], ["board-create", "board-full-all", "admin"]], + [["board-full-all"], ["board-full-all", "admin"]], + [["integration-use-all"], ["integration-use-all", "integration-interact-all", "integration-full-all", "admin"]], + [["integration-create"], ["integration-create", "integration-full-all", "admin"]], + [["integration-interact-all"], ["integration-interact-all", "integration-full-all", "admin"]], + [["integration-full-all"], ["integration-full-all", "admin"]], [["admin"], ["admin"]], ] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])("expect %s to return %s", (input, 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-modify-all"], ["board-view-all", "board-modify-all"]], [["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-create"], ["integration-create"]], [["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", - "board-full-access", + "board-full-all", "board-modify-all", "board-view-all", - "integration-full-access", + "integration-full-all", "integration-interact-all", "integration-use-all", ], diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index c5e03a1bd..98e9e843e 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -165,7 +165,7 @@ export default { label: "Modify all boards", description: "Allow members to modify all boards (Does not include access control and danger zone)", }, - "full-access": { + "full-all": { label: "Full board access", description: "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", description: "Allow members to interact with any integration", }, - "full-access": { + "full-all": { label: "Full integration access", 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: { rtl: "{value}{symbol}", @@ -1156,36 +1161,14 @@ export default { access: { title: "Access control", 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: { - "board-view": { + view: { label: "View board", }, - "board-change": { - label: "Change board", + modify: { + label: "Modify board", }, - "board-full": { + full: { 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: { manage: { label: "Manage", diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 42577f1f3..d8567cebb 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -8,6 +8,7 @@ import { } from "@homarr/definitions"; import { zodEnumFromArray } from "./enums"; +import { createSavePermissionsSchema } from "./permissions"; import { commonItemSchema, createSectionSchema } from "./shared"; const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/); @@ -66,11 +67,13 @@ const permissionsSchema = z.object({ id: z.string(), }); -const savePermissionsSchema = z.object({ - id: z.string(), +const savePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions)); + +z.object({ + entityId: z.string(), permissions: z.array( z.object({ - itemId: z.string(), + principalId: z.string(), permission: zodEnumFromArray(boardPermissions), }), ), diff --git a/packages/validation/src/integration.ts b/packages/validation/src/integration.ts index 332d4c541..1a7529087 100644 --- a/packages/validation/src/integration.ts +++ b/packages/validation/src/integration.ts @@ -1,8 +1,9 @@ import { z } from "zod"; -import { integrationKinds, integrationSecretKinds } from "@homarr/definitions"; +import { integrationKinds, integrationPermissions, integrationSecretKinds } from "@homarr/definitions"; import { zodEnumFromArray } from "./enums"; +import { createSavePermissionsSchema } from "./permissions"; const integrationCreateSchema = z.object({ name: z.string().nonempty().max(127), @@ -44,10 +45,13 @@ const testConnectionSchema = z.object({ ), }); +const savePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(integrationPermissions)); + export const integrationSchemas = { create: integrationCreateSchema, update: integrationUpdateSchema, delete: idSchema, byId: idSchema, testConnection: testConnectionSchema, + savePermissions: savePermissionsSchema, }; diff --git a/packages/validation/src/permissions.ts b/packages/validation/src/permissions.ts new file mode 100644 index 000000000..35ebe9c86 --- /dev/null +++ b/packages/validation/src/permissions.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const createSavePermissionsSchema = >( + permissionSchema: TPermissionSchema, +) => { + return z.object({ + entityId: z.string(), + permissions: z.array( + z.object({ + principalId: z.string(), + permission: permissionSchema, + }), + ), + }); +};