feat: implement board access control (#349)

* feat: implement board access control

* fix: deepsource issues

* wip: address pull request feedback

* chore: address pull request feedback

* fix: format issue

* test: improve tests

* fix: type and lint issue

* chore: address pull request feedback

* refactor: rename board procedures
This commit is contained in:
Meier Lukas
2024-04-30 21:32:55 +02:00
committed by GitHub
parent 56388eb8ef
commit 7ab9dc2501
50 changed files with 1020 additions and 324 deletions

View File

@@ -0,0 +1,9 @@
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string }>({
async getInitialBoard() {
return await api.board.getDefaultBoard();
},
});

View File

@@ -0,0 +1,9 @@
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoard({ name }) {
return await api.board.getBoardByName({ name });
},
});

View File

@@ -0,0 +1,5 @@
import definition from "./_definition";
const { layout } = definition;
export default layout;

View File

@@ -19,8 +19,8 @@ export const updateBoardName = (name: string | null) => {
}; };
type UpdateCallback = ( type UpdateCallback = (
prev: RouterOutputs["board"]["default"], prev: RouterOutputs["board"]["getDefaultBoard"],
) => RouterOutputs["board"]["default"]; ) => RouterOutputs["board"]["getDefaultBoard"];
export const useUpdateBoard = () => { export const useUpdateBoard = () => {
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -30,7 +30,7 @@ export const useUpdateBoard = () => {
if (!boardName) { if (!boardName) {
throw new Error("Board name is not set"); throw new Error("Board name is not set");
} }
utils.board.byName.setData({ name: boardName }, (previous) => utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
previous ? updaterWithoutUndefined(previous) : previous, previous ? updaterWithoutUndefined(previous) : previous,
); );
}, },

View File

@@ -16,7 +16,7 @@ import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client"; import { updateBoardName } from "./_client";
const BoardContext = createContext<{ const BoardContext = createContext<{
board: RouterOutputs["board"]["default"]; board: RouterOutputs["board"]["getDefaultBoard"];
isReady: boolean; isReady: boolean;
markAsReady: (id: string) => void; markAsReady: (id: string) => void;
} | null>(null); } | null>(null);
@@ -24,11 +24,13 @@ const BoardContext = createContext<{
export const BoardProvider = ({ export const BoardProvider = ({
children, children,
initialBoard, initialBoard,
}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["byName"] }>) => { }: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
}>) => {
const pathname = usePathname(); const pathname = usePathname();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]); const [readySections, setReadySections] = useState<string[]>([]);
const { data } = clientApi.board.byName.useQuery( const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name }, { name: initialBoard.name },
{ {
initialData: initialBoard, initialData: initialBoard,
@@ -45,7 +47,7 @@ export const BoardProvider = ({
useEffect(() => { useEffect(() => {
return () => { return () => {
setReadySections([]); setReadySections([]);
void utils.board.byName.invalidate({ name: initialBoard.name }); void utils.board.getBoardByName.invalidate({ name: initialBoard.name });
}; };
}, [pathname, utils, initialBoard.name]); }, [pathname, utils, initialBoard.name]);

View File

@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { TRPCError } from "@trpc/server";
import { capitalize } from "@homarr/common";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import { createBoardLayout } from "../_layout-creator";
import type { Board } from "../_types";
import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions";
export type Params = Record<string, unknown>;
interface Props<TParams extends Params> {
getInitialBoard: (params: TParams) => Promise<Board>;
}
export const createBoardContentPage = <
TParams extends Record<string, unknown>,
>({
getInitialBoard,
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoard,
}),
page: () => {
return <ClientBoard />;
},
generateMetadata: async ({
params,
}: {
params: TParams;
}): Promise<Metadata> => {
try {
const board = await getInitialBoard(params);
return {
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return {};
}
throw error;
}
},
};
};

View File

@@ -25,14 +25,20 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction"; import { revalidatePathAction } from "~/app/revalidatePathAction";
import { editModeAtom } from "~/components/board/editMode"; import { editModeAtom } from "~/components/board/editMode";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "../../_context"; import { useRequiredBoard } from "./_context";
export default function BoardViewHeaderActions() { export const BoardContentHeaderActions = () => {
const isEditMode = useAtomValue(editModeAtom); const isEditMode = useAtomValue(editModeAtom);
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
return null; // Hide actions for user without access
}
return ( return (
<> <>
@@ -45,7 +51,7 @@ export default function BoardViewHeaderActions() {
</HeaderButton> </HeaderButton>
</> </>
); );
} };
const AddMenu = () => { const AddMenu = () => {
const { openModal: openCategoryEditModal } = const { openModal: openCategoryEditModal } =
@@ -117,28 +123,29 @@ const EditModeMenu = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit"); const t = useScopedI18n("board.action.edit");
const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({ const { mutate: saveBoard, isPending } =
onSuccess() { clientApi.board.saveBoard.useMutation({
showSuccessNotification({ onSuccess() {
title: t("notification.success.title"), showSuccessNotification({
message: t("notification.success.message"), title: t("notification.success.title"),
}); message: t("notification.success.message"),
void utils.board.byName.invalidate({ name: board.name }); });
void revalidatePathAction(`/boards/${board.name}`); void utils.board.getBoardByName.invalidate({ name: board.name });
setEditMode(false); void revalidatePathAction(`/boards/${board.name}`);
}, setEditMode(false);
onError() { },
showErrorNotification({ onError() {
title: t("notification.error.title"), showErrorNotification({
message: t("notification.error.message"), title: t("notification.error.title"),
}); message: t("notification.error.message"),
}, });
}); },
});
const toggle = () => { const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board); if (isEditMode) return saveBoard(board);
setEditMode(true); setEditMode(true);
}; }, [board, isEditMode, saveBoard, setEditMode]);
return ( return (
<HeaderButton onClick={toggle} loading={isPending}> <HeaderButton onClick={toggle} loading={isPending}>

View File

@@ -1,3 +0,0 @@
import headerActions from "../../[name]/@headeractions/page";
export default headerActions;

View File

@@ -1,9 +0,0 @@
import { api } from "@homarr/api/server";
import { createBoardPage } from "../_creator";
export default createBoardPage<{ locale: string }>({
async getInitialBoard() {
return await api.board.default();
},
});

View File

@@ -1,9 +0,0 @@
import { api } from "@homarr/api/server";
import { createBoardPage } from "../_creator";
export default createBoardPage<{ locale: string; name: string }>({
async getInitialBoard({ name }) {
return await api.board.byName({ name });
},
});

View File

@@ -1,5 +1,11 @@
import definition from "./_definition"; import { api } from "@homarr/api/server";
const { layout } = definition; import { BoardOtherHeaderActions } from "../_header-actions";
import { createBoardLayout } from "../_layout-creator";
export default layout; export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />,
async getInitialBoard({ name }) {
return await api.board.getBoardByName({ name });
},
});

View File

@@ -38,11 +38,11 @@ import type { Board } from "../../_types";
interface Props { interface Props {
board: Board; board: Board;
initialPermissions: RouterOutputs["board"]["permissions"]; initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
} }
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => { export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
const { data: permissions } = clientApi.board.permissions.useQuery( const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{ {
id: board.id, id: board.id,
}, },
@@ -64,7 +64,8 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
}), }),
}, },
}); });
const { mutate, isPending } = clientApi.board.savePermissions.useMutation(); const { mutate, isPending } =
clientApi.board.saveBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { openModal } = useModalAction(UserSelectModal); const { openModal } = useModalAction(UserSelectModal);
@@ -77,12 +78,12 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
void utils.board.permissions.invalidate(); void utils.board.getBoardPermissions.invalidate();
}, },
}, },
); );
}, },
[board.id, mutate, utils.board.permissions], [board.id, mutate, utils.board.getBoardPermissions],
); );
const handleAddUser = useCallback(() => { const handleAddUser = useCallback(() => {
@@ -237,7 +238,7 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
}; };
interface FormType { interface FormType {
permissions: RouterOutputs["board"]["permissions"]; permissions: RouterOutputs["board"]["getBoardPermissions"];
} }
interface InnerProps { interface InnerProps {

View File

@@ -20,8 +20,8 @@ import { useDisclosure } from "@mantine/hooks";
import { useForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { generateColors } from "../../_theme";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { generateColors } from "../../(content)/_theme";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {

View File

@@ -9,7 +9,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { useRequiredBoard } from "../../_context"; import { useRequiredBoard } from "../../(content)/_context";
import classes from "./danger.module.css"; import classes from "./danger.module.css";
export const DangerZoneSettingsContent = () => { export const DangerZoneSettingsContent = () => {
@@ -19,9 +19,9 @@ export const DangerZoneSettingsContent = () => {
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal); const { openModal } = useModalAction(BoardRenameModal);
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeVisibility.useMutation(); clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = const { mutate: deleteBoard, isPending: isDeletePending } =
clientApi.board.delete.useMutation(); clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private"; const visibility = board.isPublic ? "public" : "private";
@@ -51,8 +51,8 @@ export const DangerZoneSettingsContent = () => {
}, },
{ {
onSettled() { onSettled() {
void utils.board.byName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.default.invalidate(); void utils.board.getDefaultBoard.invalidate();
}, },
}, },
); );
@@ -63,8 +63,8 @@ export const DangerZoneSettingsContent = () => {
board.name, board.name,
changeVisibility, changeVisibility,
t, t,
utils.board.byName, utils.board.getBoardByName,
utils.board.default, utils.board.getDefaultBoard,
visibility, visibility,
openConfirmModal, openConfirmModal,
]); ]);

View File

@@ -20,8 +20,8 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { useForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useUpdateBoard } from "../../_client";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useUpdateBoard } from "../../(content)/_client";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {

View File

@@ -4,10 +4,10 @@ import type { Board } from "../../_types";
export const useSavePartialSettingsMutation = (board: Board) => { export const useSavePartialSettingsMutation = (board: Board) => {
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
return clientApi.board.savePartialSettings.useMutation({ return clientApi.board.savePartialBoardSettings.useMutation({
onSettled() { onSettled() {
void utils.board.byName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.default.invalidate(); void utils.board.getDefaultBoard.invalidate();
}, },
}); });
}; };

View File

@@ -1,4 +1,5 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { import {
AccordionControl, AccordionControl,
AccordionItem, AccordionItem,
@@ -17,6 +18,7 @@ import {
IconSettings, IconSettings,
IconUser, IconUser,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { TRPCError } from "@trpc/server";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common"; import { capitalize } from "@homarr/common";
@@ -24,6 +26,7 @@ import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissions } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { AccessSettingsContent } from "./_access"; import { AccessSettingsContent } from "./_access";
import { BackgroundSettingsContent } from "./_background"; import { BackgroundSettingsContent } from "./_background";
@@ -42,12 +45,29 @@ interface Props {
}; };
} }
const getBoardAndPermissions = async (params: Props["params"]) => {
try {
const board = await api.board.getBoardByName({ name: params.name });
const permissions = await api.board.getBoardPermissions({ id: board.id });
return { board, permissions };
} catch (error) {
// Ignore not found errors and redirect to 404
// error is already logged in _layout-creator.tsx
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
notFound();
}
throw error;
}
};
export default async function BoardSettingsPage({ export default async function BoardSettingsPage({
params, params,
searchParams, searchParams,
}: Props) { }: Props) {
const board = await api.board.byName({ name: params.name }); const { board, permissions } = await getBoardAndPermissions(params);
const permissions = await api.board.permissions({ id: board.id }); const { hasFullAccess } = await getBoardPermissions(board);
const t = await getScopedI18n("board.setting"); const t = await getScopedI18n("board.setting");
return ( return (
@@ -73,20 +93,24 @@ export default async function BoardSettingsPage({
<AccordionItemFor value="customCss" icon={IconFileTypeCss}> <AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent /> <CustomCssSettingsContent />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="access" icon={IconUser}> {hasFullAccess && (
<AccessSettingsContent <>
board={board} <AccordionItemFor value="access" icon={IconUser}>
initialPermissions={permissions} <AccessSettingsContent
/> board={board}
</AccordionItemFor> initialPermissions={permissions}
<AccordionItemFor />
value="dangerZone" </AccordionItemFor>
icon={IconAlertTriangle} <AccordionItemFor
danger value="dangerZone"
noPadding icon={IconAlertTriangle}
> danger
<DangerZoneSettingsContent /> noPadding
</AccordionItemFor> >
<DangerZoneSettingsContent />
</AccordionItemFor>
</>
)}
</ActiveTabAccordion> </ActiveTabAccordion>
</Stack> </Stack>
</Container> </Container>

View File

@@ -1,113 +0,0 @@
import type { PropsWithChildren, ReactNode } from "react";
import type { Metadata } from "next";
import { AppShellMain } from "@mantine/core";
import { capitalize } from "@homarr/common";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { ClientBoard } from "./_client";
import { BoardProvider } from "./_context";
import type { Board } from "./_types";
// This is placed here because it's used in the layout and the page and because it's here it's not needed to load it everywhere
import "../../../styles/gridstack.scss";
import { notFound } from "next/navigation";
import { auth } from "@homarr/auth";
import { and, db, eq, schema } from "@homarr/db";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { BoardMantineProvider } from "./_theme";
type Params = Record<string, unknown>;
interface Props<TParams extends Params> {
getInitialBoard: (params: TParams) => Promise<Board>;
}
export const createBoardPage = <TParams extends Record<string, unknown>>({
getInitialBoard,
}: Props<TParams>) => {
return {
layout: async ({
params,
children,
headeractions,
}: PropsWithChildren<{ params: TParams; headeractions: ReactNode }>) => {
const initialBoard = await getInitialBoard(params);
return (
<GlobalItemServerDataRunner board={initialBoard}>
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headeractions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
);
},
page: async ({ params }: { params: TParams }) => {
const board = await getInitialBoard(params);
if (await canAccessBoardAsync(board)) {
return <ClientBoard />;
}
return notFound();
},
generateMetadata: async ({
params,
}: {
params: TParams;
}): Promise<Metadata> => {
const board = await getInitialBoard(params);
if (!(await canAccessBoardAsync(board))) {
return {};
}
return {
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
};
},
};
};
const canAccessBoardAsync = async (board: Board) => {
const session = await auth();
if (board.isPublic) {
return true; // Public boards can be accessed by anyone
}
if (!session) {
return false; // Not logged in users can't access private boards
}
if (board.creatorId === session?.user.id) {
return true; // Creators can access their own private boards
}
const permissions = await db.query.boardPermissions.findMany({
where: and(
eq(schema.boardPermissions.userId, session.user.id),
eq(schema.boardPermissions.boardId, board.id),
),
});
return ["board-view", "board-change"].some((key) =>
permissions.some(({ permission }) => key === permission),
); // Allow access for all with any board permission
};

View File

@@ -3,9 +3,9 @@
import { IconLayoutBoard } from "@tabler/icons-react"; import { IconLayoutBoard } from "@tabler/icons-react";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "../../../_context"; import { useRequiredBoard } from "./(content)/_context";
export default function BoardViewLayout() { export const BoardOtherHeaderActions = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
return ( return (
@@ -13,4 +13,4 @@ export default function BoardViewLayout() {
<IconLayoutBoard stroke={1.5} /> <IconLayoutBoard stroke={1.5} />
</HeaderButton> </HeaderButton>
); );
} };

View File

@@ -0,0 +1,60 @@
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator";
import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoard: (params: TParams) => Promise<Board>;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
getInitialBoard,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
children,
}: PropsWithChildren<{
params: TParams;
}>) => {
const initialBoard = await getInitialBoard(params).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
logger.warn(error);
notFound();
}
throw error;
});
return (
<GlobalItemServerDataRunner board={initialBoard}>
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
);
};
return Layout;
};

View File

@@ -1,7 +1,7 @@
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
export type Board = RouterOutputs["board"]["default"]; export type Board = RouterOutputs["board"]["getDefaultBoard"];
export type Section = Board["sections"][number]; export type Section = Board["sections"][number];
export type Item = Section["items"][number]; export type Item = Section["items"][number];

View File

@@ -11,6 +11,7 @@ import { useConfirmModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction"; import { revalidatePathAction } from "~/app/revalidatePathAction";
import { useBoardPermissions } from "~/components/board/permissions/client";
const iconProps = { const iconProps = {
size: 16, size: 16,
@@ -18,7 +19,10 @@ const iconProps = {
}; };
interface BoardCardMenuDropdownProps { interface BoardCardMenuDropdownProps {
board: Pick<RouterOutputs["board"]["getAll"][number], "id" | "name">; board: Pick<
RouterOutputs["board"]["getAllBoards"][number],
"id" | "name" | "creator" | "permissions" | "isPublic"
>;
} }
export const BoardCardMenuDropdown = ({ export const BoardCardMenuDropdown = ({
@@ -27,9 +31,11 @@ export const BoardCardMenuDropdown = ({
const t = useScopedI18n("management.page.board.action"); const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { mutateAsync, isPending } = clientApi.board.delete.useMutation({ const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
onSettled: async () => { onSettled: async () => {
await revalidatePathAction("/manage/boards"); await revalidatePathAction("/manage/boards");
}, },
@@ -51,26 +57,31 @@ export const BoardCardMenuDropdown = ({
return ( return (
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item {hasChangeAccess && (
component={Link} <Menu.Item
href={`/boards/${board.name}/settings`} component={Link}
leftSection={<IconSettings {...iconProps} />} href={`/boards/${board.name}/settings`}
> leftSection={<IconSettings {...iconProps} />}
{t("settings.label")} >
</Menu.Item> {t("settings.label")}
</Menu.Item>
<Menu.Divider /> )}
<Menu.Label c="red.7"> {hasFullAccess && (
{tCommon("menu.section.dangerZone.title")} <>
</Menu.Label> <Menu.Divider />
<Menu.Item <Menu.Label c="red.7">
c="red.7" {tCommon("menu.section.dangerZone.title")}
leftSection={<IconTrash {...iconProps} />} </Menu.Label>
onClick={handleDeletion} <Menu.Item
disabled={isPending} c="red.7"
> leftSection={<IconTrash {...iconProps} />}
{t("delete.label")} onClick={handleDeletion}
</Menu.Item> disabled={isPending}
>
{t("delete.label")}
</Menu.Item>
</>
)}
</Menu.Dropdown> </Menu.Dropdown>
); );
}; };

View File

@@ -19,7 +19,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
const t = useI18n(); const t = useI18n();
const { openModal } = useModalAction(AddBoardModal); const { openModal } = useModalAction(AddBoardModal);
const { mutateAsync, isPending } = clientApi.board.create.useMutation({ const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
onSettled: async () => { onSettled: async () => {
await revalidatePathAction("/manage/boards"); await revalidatePathAction("/manage/boards");
}, },

View File

@@ -19,13 +19,14 @@ import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { getBoardPermissions } from "~/components/board/permissions/server";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button"; import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() { export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board"); const t = await getScopedI18n("management.page.board");
const boards = await api.board.getAll(); const boards = await api.board.getAllBoards();
return ( return (
<> <>
@@ -46,11 +47,12 @@ export default async function ManageBoardsPage() {
} }
interface BoardCardProps { interface BoardCardProps {
board: RouterOutputs["board"]["getAll"][number]; board: RouterOutputs["board"]["getAllBoards"][number];
} }
const BoardCard = async ({ board }: BoardCardProps) => { const BoardCard = async ({ board }: BoardCardProps) => {
const t = await getScopedI18n("management.page.board"); const t = await getScopedI18n("management.page.board");
const { hasChangeAccess: isMenuVisible } = await getBoardPermissions(board);
const visibility = board.isPublic ? "public" : "private"; const visibility = board.isPublic ? "public" : "private";
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
@@ -79,14 +81,16 @@ const BoardCard = async ({ board }: BoardCardProps) => {
> >
{t("action.open.label")} {t("action.open.label")}
</Button> </Button>
<Menu position="bottom-end"> {isMenuVisible && (
<MenuTarget> <Menu position="bottom-end">
<ActionIcon variant="default" size="lg"> <MenuTarget>
<IconDotsVertical size={16} stroke={1.5} /> <ActionIcon variant="default" size="lg">
</ActionIcon> <IconDotsVertical size={16} stroke={1.5} />
</MenuTarget> </ActionIcon>
<BoardCardMenuDropdown board={board} /> </MenuTarget>
</Menu> <BoardCardMenuDropdown board={board} />
</Menu>
)}
</Group> </Group>
</CardSection> </CardSection>
</Card> </Card>

View File

@@ -3,8 +3,8 @@ import { useCallback } from "react";
import { createId } from "@homarr/db/client"; import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { useUpdateBoard } from "~/app/[locale]/boards/_client";
import type { EmptySection, Item } from "~/app/[locale]/boards/_types"; import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
interface MoveAndResizeItem { interface MoveAndResizeItem {
itemId: string; itemId: string;

View File

@@ -18,10 +18,12 @@ export const BoardRenameModal = createModal<InnerProps>(
({ actions, innerProps }) => { ({ actions, innerProps }) => {
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useI18n(); const t = useI18n();
const { mutate, isPending } = clientApi.board.rename.useMutation({ const { mutate, isPending } = clientApi.board.renameBoard.useMutation({
onSettled() { onSettled() {
void utils.board.byName.invalidate({ name: innerProps.previousName }); void utils.board.getBoardByName.invalidate({
void utils.board.default.invalidate(); name: innerProps.previousName,
});
void utils.board.getDefaultBoard.invalidate();
}, },
}); });
const form = useForm<FormType>({ const form = useForm<FormType>({

View File

@@ -0,0 +1,8 @@
import { useSession } from "@homarr/auth/client";
import type { BoardPermissionsProps } from "@homarr/auth/shared";
import { constructBoardPermissions } from "@homarr/auth/shared";
export const useBoardPermissions = (board: BoardPermissionsProps) => {
const { data: session } = useSession();
return constructBoardPermissions(board, session);
};

View File

@@ -0,0 +1,8 @@
import { auth } from "@homarr/auth";
import type { BoardPermissionsProps } from "@homarr/auth/shared";
import { constructBoardPermissions } from "@homarr/auth/shared";
export const getBoardPermissions = async (board: BoardPermissionsProps) => {
const session = await auth();
return constructBoardPermissions(board, session);
};

View File

@@ -2,12 +2,12 @@ import { useCallback } from "react";
import { createId } from "@homarr/db/client"; import { createId } from "@homarr/db/client";
import { useUpdateBoard } from "~/app/[locale]/boards/_client";
import type { import type {
CategorySection, CategorySection,
EmptySection, EmptySection,
Section, Section,
} from "~/app/[locale]/boards/_types"; } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
interface AddCategory { interface AddCategory {
name: string; name: string;

View File

@@ -22,8 +22,8 @@ import {
WidgetEditModal, WidgetEditModal,
} from "@homarr/widgets"; } from "@homarr/widgets";
import { useRequiredBoard } from "~/app/[locale]/boards/_context";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { editModeAtom } from "../editMode"; import { editModeAtom } from "../editMode";
import { useItemActions } from "../items/item-actions"; import { useItemActions } from "../items/item-actions";
import type { UseGridstackRefs } from "./gridstack/use-gridstack"; import type { UseGridstackRefs } from "./gridstack/use-gridstack";

View File

@@ -8,11 +8,11 @@ import type {
GridStackNode, GridStackNode,
} from "@homarr/gridstack"; } from "@homarr/gridstack";
import type { Section } from "~/app/[locale]/boards/_types";
import { import {
useMarkSectionAsReady, useMarkSectionAsReady,
useRequiredBoard, useRequiredBoard,
} from "~/app/[locale]/boards/_context"; } from "~/app/[locale]/boards/(content)/_context";
import type { Section } from "~/app/[locale]/boards/_types";
import { editModeAtom } from "../../editMode"; import { editModeAtom } from "../../editMode";
import { useItemActions } from "../../items/item-actions"; import { useItemActions } from "../../items/item-actions";
import { initializeGridstack } from "./init-gridstack"; import { initializeGridstack } from "./init-gridstack";

View File

@@ -1,7 +1,7 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { AppShellProps } from "@mantine/core"; import type { AppShellProps } from "@mantine/core";
import { useOptionalBoard } from "~/app/[locale]/boards/_context"; import { useOptionalBoard } from "~/app/[locale]/boards/(content)/_context";
const supportedVideoFormats = ["mp4", "webm", "ogg"]; const supportedVideoFormats = ["mp4", "webm", "ogg"];
const isVideo = (url: string) => const isVideo = (url: string) =>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRequiredBoard } from "~/app/[locale]/boards/_context"; import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo"; import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
import type { LogoWithTitleProps } from "./logo"; import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo"; import { Logo, LogoWithTitle } from "./logo";

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import type { Database, SQL } from "@homarr/db"; import type { Database, SQL } from "@homarr/db";
import { and, createId, eq, inArray } from "@homarr/db"; import { and, createId, eq, inArray, or } from "@homarr/db";
import { import {
boardPermissions, boardPermissions,
boards, boards,
@@ -20,7 +20,8 @@ import {
} from "@homarr/validation"; } from "@homarr/validation";
import { zodUnionFromArray } from "../../../validation/src/enums"; import { zodUnionFromArray } from "../../../validation/src/enums";
import { createTRPCRouter, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const filterAddedItems = <TInput extends { id: string }>( const filterAddedItems = <TInput extends { id: string }>(
inputArray: TInput[], inputArray: TInput[],
@@ -47,23 +48,41 @@ const filterUpdatedItems = <TInput extends { id: string }>(
); );
export const boardRouter = createTRPCRouter({ export const boardRouter = createTRPCRouter({
getAll: publicProcedure.query(async ({ ctx }) => { getAllBoards: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.boards.findMany({ const permissionsOfCurrentUserWhenPresent =
await ctx.db.query.boardPermissions.findMany({
where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""),
});
const boardIds = permissionsOfCurrentUserWhenPresent.map(
(permission) => permission.boardId,
);
const dbBoards = await ctx.db.query.boards.findMany({
columns: { columns: {
id: true, id: true,
name: true, name: true,
isPublic: true, isPublic: true,
}, },
with: { with: {
sections: { creator: {
with: { columns: {
items: true, id: true,
name: true,
image: true,
}, },
}, },
permissions: {
where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""),
},
}, },
where: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
}); });
return dbBoards;
}), }),
create: publicProcedure createBoard: protectedProcedure
.input(validation.board.create) .input(validation.board.create)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const boardId = createId(); const boardId = createId();
@@ -71,6 +90,7 @@ export const boardRouter = createTRPCRouter({
await transaction.insert(boards).values({ await transaction.insert(boards).values({
id: boardId, id: boardId,
name: input.name, name: input.name,
creatorId: ctx.session.user.id,
}); });
await transaction.insert(sections).values({ await transaction.insert(sections).values({
id: createId(), id: createId(),
@@ -80,9 +100,15 @@ export const boardRouter = createTRPCRouter({
}); });
}); });
}), }),
rename: publicProcedure renameBoard: protectedProcedure
.input(validation.board.rename) .input(validation.board.rename)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await noBoardWithSimilarName(ctx.db, input.name, [input.id]); await noBoardWithSimilarName(ctx.db, input.name, [input.id]);
await ctx.db await ctx.db
@@ -90,40 +116,61 @@ export const boardRouter = createTRPCRouter({
.set({ name: input.name }) .set({ name: input.name })
.where(eq(boards.id, input.id)); .where(eq(boards.id, input.id));
}), }),
changeVisibility: publicProcedure changeBoardVisibility: protectedProcedure
.input(validation.board.changeVisibility) .input(validation.board.changeVisibility)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await ctx.db await ctx.db
.update(boards) .update(boards)
.set({ isPublic: input.visibility === "public" }) .set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id)); .where(eq(boards.id, input.id));
}), }),
delete: publicProcedure deleteBoard: protectedProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await ctx.db.delete(boards).where(eq(boards.id, input.id)); await ctx.db.delete(boards).where(eq(boards.id, input.id));
}), }),
default: publicProcedure.query(async ({ ctx }) => { getDefaultBoard: publicProcedure.query(async ({ ctx }) => {
return await getFullBoardWithWhere(ctx.db, eq(boards.name, "default")); const boardWhere = eq(boards.name, "default");
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
return await getFullBoardWithWhere(
ctx.db,
boardWhere,
ctx.session?.user.id ?? null,
);
}), }),
byName: publicProcedure getBoardByName: publicProcedure
.input(validation.board.byName) .input(validation.board.byName)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name)); const boardWhere = eq(boards.name, input.name);
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
return await getFullBoardWithWhere(
ctx.db,
boardWhere,
ctx.session?.user.id ?? null,
);
}), }),
savePartialSettings: publicProcedure savePartialBoardSettings: protectedProcedure
.input(validation.board.savePartialSettings) .input(validation.board.savePartialSettings)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const board = await ctx.db.query.boards.findFirst({ await throwIfActionForbiddenAsync(
where: eq(boards.id, input.id), ctx,
}); eq(boards.id, input.id),
"board-change",
if (!board) { );
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}
await ctx.db await ctx.db
.update(boards) .update(boards)
@@ -153,13 +200,20 @@ export const boardRouter = createTRPCRouter({
}) })
.where(eq(boards.id, input.id)); .where(eq(boards.id, input.id));
}), }),
save: publicProcedure saveBoard: protectedProcedure
.input(validation.board.save) .input(validation.board.save)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"board-change",
);
await ctx.db.transaction(async (transaction) => { await ctx.db.transaction(async (transaction) => {
const dbBoard = await getFullBoardWithWhere( const dbBoard = await getFullBoardWithWhere(
transaction, transaction,
eq(boards.id, input.id), eq(boards.id, input.id),
ctx.session.user.id,
); );
const addedSections = filterAddedItems( const addedSections = filterAddedItems(
@@ -314,9 +368,15 @@ export const boardRouter = createTRPCRouter({
}); });
}), }),
permissions: publicProcedure getBoardPermissions: protectedProcedure
.input(validation.board.permissions) .input(validation.board.permissions)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
const permissions = await ctx.db.query.boardPermissions.findMany({ const permissions = await ctx.db.query.boardPermissions.findMany({
where: eq(boardPermissions.boardId, input.id), where: eq(boardPermissions.boardId, input.id),
with: { with: {
@@ -340,9 +400,15 @@ export const boardRouter = createTRPCRouter({
return permissionA.user.name.localeCompare(permissionB.user.name); return permissionA.user.name.localeCompare(permissionB.user.name);
}); });
}), }),
savePermissions: publicProcedure saveBoardPermissions: protectedProcedure
.input(validation.board.savePermissions) .input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await ctx.db.transaction(async (transaction) => { await ctx.db.transaction(async (transaction) => {
await transaction await transaction
.delete(boardPermissions) .delete(boardPermissions)
@@ -387,7 +453,11 @@ const noBoardWithSimilarName = async (
} }
}; };
const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => { const getFullBoardWithWhere = async (
db: Database,
where: SQL<unknown>,
userId: string | null,
) => {
const board = await db.query.boards.findFirst({ const board = await db.query.boards.findFirst({
where, where,
with: { with: {
@@ -410,6 +480,12 @@ const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
}, },
}, },
}, },
permissions: {
where: eq(boardPermissions.userId, userId ?? ""),
columns: {
permission: true,
},
},
}, },
}); });
@@ -437,8 +513,6 @@ const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
}; };
}; };
// The following is a bit of a mess, it's providing us typesafe options matching the widget kind.
// But I might be able to do this in a better way in the future.
const forKind = <T extends WidgetKind>(kind: T) => const forKind = <T extends WidgetKind>(kind: T) =>
z.object({ z.object({
kind: z.literal(kind), kind: z.literal(kind),

View File

@@ -0,0 +1,67 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq } from "@homarr/db";
import { boardPermissions } from "@homarr/db/schema/sqlite";
import type { BoardPermission } from "@homarr/definitions";
/**
* Throws NOT_FOUND if user is not allowed to perform action on board
* @param ctx trpc router context
* @param boardWhere where clause for the board
* @param permission permission required to perform action on board
*/
export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
boardWhere: SQL<unknown>,
permission: "full-access" | BoardPermission,
) => {
const { db, session } = ctx;
const board = await db.query.boards.findFirst({
where: boardWhere,
columns: {
id: true,
creatorId: true,
isPublic: true,
},
with: {
permissions: {
where: eq(boardPermissions.userId, session?.user.id ?? ""),
},
},
});
if (!board) {
notAllowed();
}
const { hasViewAccess, hasChangeAccess, hasFullAccess } =
constructBoardPermissions(board, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
}
if (["board-change", "board-view"].includes(permission) && hasChangeAccess) {
return; // As change access is required and user has change access, allow
}
if (permission === "board-view" && hasViewAccess) {
return; // As view access is required and user has view 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: "Board not found",
});
}

View File

@@ -10,89 +10,168 @@ import {
integrations, integrations,
items, items,
sections, sections,
users,
} from "@homarr/db/schema/sqlite"; } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import type { RouterOutputs } from "../.."; import type { RouterOutputs } from "../..";
import { boardRouter } from "../board"; import { boardRouter } from "../board";
import * as boardAccess from "../board/board-access";
import { expectToBeDefined } from "./helper";
const defaultCreatorId = createId();
const defaultSession = {
user: {
id: defaultCreatorId,
},
expires: new Date().toISOString(),
} satisfies Session;
// Mock the auth module to return an empty session // Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
export const expectToBeDefined = <T>(value: T) => { describe("getDefaultBoard should return default board", () => {
if (value === undefined) {
expect(value).toBeDefined();
}
if (value === null) {
expect(value).not.toBeNull();
}
return value as Exclude<T, undefined | null>;
};
describe("default should return default board", () => {
it("should return default board", async () => { it("should return default board", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "default"); const fullBoardProps = await createFullBoardAsync(db, "default");
const result = await caller.default(); // Act
const result = await caller.getDefaultBoard();
// Assert
expectInputToBeFullBoardWithName(result, { expectInputToBeFullBoardWithName(result, {
name: "default", name: "default",
...fullBoardProps, ...fullBoardProps,
}); });
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-view",
);
}); });
}); });
describe("byName should return board by name", () => { describe("getBoardByName should return board by name", () => {
it.each([["default"], ["something"]])( it.each([["default"], ["something"]])(
"should return board by name %s when present", "should return board by name %s when present",
async (name) => { async (name) => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, name); const fullBoardProps = await createFullBoardAsync(db, name);
const result = await caller.byName({ name }); // Act
const result = await caller.getBoardByName({ name });
// Assert
expectInputToBeFullBoardWithName(result, { expectInputToBeFullBoardWithName(result, {
name, name,
...fullBoardProps, ...fullBoardProps,
}); });
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-view",
);
}, },
); );
it("should throw error when not present"); it("should throw error when not present", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
await createFullBoardAsync(db, "default");
// Act
const act = async () =>
await caller.getBoardByName({ name: "nonExistentBoard" });
// Assert
await expect(act()).rejects.toThrowError("Board not found");
});
}); });
describe("savePartialSettings should save general settings", () => { describe("savePartialBoardSettings should save general settings", () => {
it("should save general settings", async () => { it("should save general settings", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const newPageTitle = "newPageTitle"; const newPageTitle = "newPageTitle";
const newMetaTitle = "newMetaTitle"; const newMetaTitle = "newMetaTitle";
const newLogoImageUrl = "http://logo.image/url.png"; const newLogoImageUrl = "http://logo.image/url.png";
const newFaviconImageUrl = "http://favicon.image/url.png"; const newFaviconImageUrl = "http://favicon.image/url.png";
const newBackgroundImageAttachment = "scroll";
const newBackgroundImageSize = "cover";
const newBackgroundImageRepeat = "repeat";
const newBackgroundImageUrl = "http://background.image/url.png";
const newColumnCount = 2;
const newCustomCss = "body { background-color: blue; }";
const newOpacity = 0.8;
const newPrimaryColor = "#0000ff";
const newSecondaryColor = "#ff00ff";
const { boardId } = await createFullBoardAsync(db, "default"); const { boardId } = await createFullBoardAsync(db, "default");
await caller.savePartialSettings({ // Act
await caller.savePartialBoardSettings({
pageTitle: newPageTitle, pageTitle: newPageTitle,
metaTitle: newMetaTitle, metaTitle: newMetaTitle,
logoImageUrl: newLogoImageUrl, logoImageUrl: newLogoImageUrl,
faviconImageUrl: newFaviconImageUrl, faviconImageUrl: newFaviconImageUrl,
backgroundImageAttachment: newBackgroundImageAttachment,
backgroundImageRepeat: newBackgroundImageRepeat,
backgroundImageSize: newBackgroundImageSize,
backgroundImageUrl: newBackgroundImageUrl,
columnCount: newColumnCount,
customCss: newCustomCss,
opacity: newOpacity,
primaryColor: newPrimaryColor,
secondaryColor: newSecondaryColor,
id: boardId, id: boardId,
}); });
// Assert
const dbBoard = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.pageTitle).toBe(newPageTitle);
expect(dbBoard?.metaTitle).toBe(newMetaTitle);
expect(dbBoard?.logoImageUrl).toBe(newLogoImageUrl);
expect(dbBoard?.faviconImageUrl).toBe(newFaviconImageUrl);
expect(dbBoard?.backgroundImageAttachment).toBe(
newBackgroundImageAttachment,
);
expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
expect(dbBoard?.columnCount).toBe(newColumnCount);
expect(dbBoard?.customCss).toBe(newCustomCss);
expect(dbBoard?.opacity).toBe(newOpacity);
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
expect(dbBoard?.secondaryColor).toBe(newSecondaryColor);
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
}); });
it("should throw error when board not found", async () => { it("should throw error when board not found", async () => {
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const act = async () => const act = async () =>
await caller.savePartialSettings({ await caller.savePartialBoardSettings({
pageTitle: "newPageTitle", pageTitle: "newPageTitle",
metaTitle: "newMetaTitle", metaTitle: "newMetaTitle",
logoImageUrl: "http://logo.image/url.png", logoImageUrl: "http://logo.image/url.png",
@@ -104,14 +183,15 @@ describe("savePartialSettings should save general settings", () => {
}); });
}); });
describe("save should save full board", () => { describe("saveBoard should save full board", () => {
it("should remove section when not present in input", async () => { it("should remove section when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default"); const { boardId, sectionId } = await createFullBoardAsync(db, "default");
await caller.save({ await caller.saveBoard({
id: boardId, id: boardId,
sections: [ sections: [
{ {
@@ -138,17 +218,23 @@ describe("save should save full board", () => {
expect(definedBoard.sections.length).toBe(1); expect(definedBoard.sections.length).toBe(1);
expect(definedBoard.sections[0]?.id).not.toBe(sectionId); expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
expect(section).toBeUndefined(); expect(section).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
}); });
it("should remove item when not present in input", async () => { it("should remove item when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync( const { boardId, itemId, sectionId } = await createFullBoardAsync(
db, db,
"default", "default",
); );
await caller.save({ await caller.saveBoard({
id: boardId, id: boardId,
sections: [ sections: [
{ {
@@ -192,10 +278,16 @@ describe("save should save full board", () => {
expect(firstSection.items.length).toBe(1); expect(firstSection.items.length).toBe(1);
expect(firstSection.items[0]?.id).not.toBe(itemId); expect(firstSection.items[0]?.id).not.toBe(itemId);
expect(item).toBeUndefined(); expect(item).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
}); });
it("should remove integration reference when not present in input", async () => { it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const anotherIntegration = { const anotherIntegration = {
id: createId(), id: createId(),
kind: "adGuardHome", kind: "adGuardHome",
@@ -207,7 +299,7 @@ describe("save should save full board", () => {
await createFullBoardAsync(db, "default"); await createFullBoardAsync(db, "default");
await db.insert(integrations).values(anotherIntegration); await db.insert(integrations).values(anotherIntegration);
await caller.save({ await caller.saveBoard({
id: boardId, id: boardId,
sections: [ sections: [
{ {
@@ -257,18 +349,24 @@ describe("save should save full board", () => {
expect(firstItem.integrations.length).toBe(1); expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId); expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
expect(integration).toBeUndefined(); expect(integration).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
}); });
it.each([ it.each([
[{ kind: "empty" as const }], [{ kind: "empty" as const }],
[{ kind: "category" as const, name: "My first category" }], [{ kind: "category" as const, name: "My first category" }],
])("should add section when present in input", async (partialSection) => { ])("should add section when present in input", async (partialSection) => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default"); const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId(); const newSectionId = createId();
await caller.save({ await caller.saveBoard({
id: boardId, id: boardId,
sections: [ sections: [
{ {
@@ -310,15 +408,21 @@ describe("save should save full board", () => {
expect(addedSection.name).toBe(partialSection.name); expect(addedSection.name).toBe(partialSection.name);
} }
expect(section).toBeDefined(); expect(section).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
}); });
it("should add item when present in input", async () => { it("should add item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default"); const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newItemId = createId(); const newItemId = createId();
await caller.save({ await caller.saveBoard({
id: boardId, id: boardId,
sections: [ sections: [
{ {
@@ -374,10 +478,16 @@ describe("save should save full board", () => {
expect(addedItem.xOffset).toBe(3); expect(addedItem.xOffset).toBe(3);
expect(addedItem.yOffset).toBe(2); expect(addedItem.yOffset).toBe(2);
expect(item).toBeDefined(); expect(item).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
}); });
it("should add integration reference when present in input", async () => { it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const integration = { const integration = {
id: createId(), id: createId(),
kind: "plex", kind: "plex",
@@ -391,7 +501,7 @@ describe("save should save full board", () => {
); );
await db.insert(integrations).values(integration); await db.insert(integrations).values(integration);
await caller.save({ await caller.saveBoard({
id: boardId, id: boardId,
sections: [ sections: [
{ {
@@ -443,10 +553,15 @@ describe("save should save full board", () => {
expect(firstItem.integrations.length).toBe(1); expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id); expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
expect(integrationItem).toBeDefined(); expect(integrationItem).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
}); });
it("should update section when present in input", async () => { it("should update section when present in input", async () => {
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default"); const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId(); const newSectionId = createId();
@@ -458,7 +573,7 @@ describe("save should save full board", () => {
boardId, boardId,
}); });
await caller.save({ await caller.saveBoard({
id: boardId, id: boardId,
sections: [ sections: [
{ {
@@ -503,15 +618,16 @@ describe("save should save full board", () => {
expect(secondSection.name).toBe("After"); expect(secondSection.name).toBe("After");
}); });
it("should update item when present in input", async () => { it("should update item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync( const { boardId, itemId, sectionId } = await createFullBoardAsync(
db, db,
"default", "default",
); );
await caller.save({ await caller.saveBoard({
id: boardId, id: boardId,
sections: [ sections: [
{ {
@@ -562,13 +678,18 @@ describe("save should save full board", () => {
expect(firstItem.width).toBe(2); expect(firstItem.width).toBe(2);
expect(firstItem.xOffset).toBe(7); expect(firstItem.xOffset).toBe(7);
expect(firstItem.yOffset).toBe(5); expect(firstItem.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
}); });
it("should fail when board not found", async () => { it("should fail when board not found", async () => {
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: defaultSession });
const act = async () => const act = async () =>
await caller.save({ await caller.saveBoard({
id: "nonExistentBoardId", id: "nonExistentBoardId",
sections: [], sections: [],
}); });
@@ -578,7 +699,7 @@ describe("save should save full board", () => {
}); });
const expectInputToBeFullBoardWithName = ( const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["default"], input: RouterOutputs["board"]["getDefaultBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>, props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
) => { ) => {
expect(input.id).toBe(props.boardId); expect(input.id).toBe(props.boardId);
@@ -600,10 +721,15 @@ const expectInputToBeFullBoardWithName = (
}; };
const createFullBoardAsync = async (db: Database, name: string) => { const createFullBoardAsync = async (db: Database, name: string) => {
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId(); const boardId = createId();
await db.insert(boards).values({ await db.insert(boards).values({
id: boardId, id: boardId,
name, name,
creatorId: defaultCreatorId,
}); });
const sectionId = createId(); const sectionId = createId();

View File

@@ -0,0 +1,188 @@
import { describe, expect, test, vi } from "vitest";
import * as authShared from "@homarr/auth/shared";
import { createId, eq } from "@homarr/db";
import { boards, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
const defaultCreatorId = createId();
const expectActToBe = async (act: () => Promise<void>, success: boolean) => {
if (!success) {
await expect(act()).rejects.toThrow("Board not found");
return;
}
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],
])(
"with permission %s should return %s when hasFullAccess is true",
async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: true,
hasChangeAccess: false,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, boardId),
permission,
);
// Assert
await expectActToBe(act, expectedResult);
},
);
test.each([
["full-access" as const, false],
["board-change" as const, true],
["board-view" as const, true],
])(
"with permission %s should return %s when hasChangeAccess is true",
async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: true,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, boardId),
permission,
);
// Assert
await expectActToBe(act, expectedResult);
},
);
test.each([
["full-access" as const, false],
["board-change" as const, false],
["board-view" as const, true],
])(
"with permission %s should return %s when hasViewAccess is true",
async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: false,
hasViewAccess: true,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, boardId),
permission,
);
// Assert
await expectActToBe(act, expectedResult);
},
);
test.each([
["full-access" as const, false],
["board-change" as const, false],
["board-view" as const, false],
])(
"with permission %s should return %s when hasViewAccess is false",
async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: false,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, boardId),
permission,
);
// Assert
await expectActToBe(act, expectedResult);
},
);
test("should throw when board is not found", async () => {
// Arrange
const db = createDb();
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, createId()),
"full-access",
);
// Assert
await expect(act()).rejects.toThrow("Board not found");
});
});

View File

@@ -0,0 +1,11 @@
import { expect } from "vitest";
export const expectToBeDefined = <T>(value: T) => {
if (value === undefined) {
expect(value).toBeDefined();
}
if (value === null) {
expect(value).not.toBeNull();
}
return value as Exclude<T, undefined | null>;
};

View File

@@ -7,7 +7,7 @@ import { createDb } from "@homarr/db/test";
import type { RouterInputs } from "../.."; import type { RouterInputs } from "../..";
import { encryptSecret, integrationRouter } from "../integration"; import { encryptSecret, integrationRouter } from "../integration";
import { expectToBeDefined } from "./board.spec"; import { expectToBeDefined } from "./helper";
// Mock the auth module to return an empty session // Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));

View File

@@ -1,3 +1,4 @@
import { cache } from "react";
import type { DefaultSession } from "@auth/core/types"; import type { DefaultSession } from "@auth/core/types";
import { createConfiguration } from "./configuration"; import { createConfiguration } from "./configuration";
@@ -16,5 +17,11 @@ export * from "./security";
export const createHandlers = (isCredentialsRequest: boolean) => export const createHandlers = (isCredentialsRequest: boolean) =>
createConfiguration(isCredentialsRequest); createConfiguration(isCredentialsRequest);
export const { auth } = createConfiguration(false); const { auth: defaultAuth } = createConfiguration(false);
/**
* This is the main way to get session data for your RSCs.
* This will de-duplicate all calls to next-auth's default `auth()` function and only call it once per request
*/
export const auth = cache(defaultAuth);
export { getSessionFromToken, sessionTokenCookieName } from "./session"; export { getSessionFromToken, sessionTokenCookieName } from "./session";

View File

@@ -6,6 +6,7 @@
".": "./index.ts", ".": "./index.ts",
"./security": "./security.ts", "./security": "./security.ts",
"./client": "./client.ts", "./client": "./client.ts",
"./shared": "./shared.ts",
"./env.mjs": "./env.mjs" "./env.mjs": "./env.mjs"
}, },
"private": true, "private": true,

View File

@@ -0,0 +1,35 @@
import type { Session } from "@auth/core/types";
export type BoardPermissionsProps = (
| {
creator: {
id: string;
} | null;
}
| {
creatorId: string | null;
}
) & {
permissions: {
permission: string;
}[];
isPublic: boolean;
};
export const constructBoardPermissions = (
board: BoardPermissionsProps,
session: Session | null,
) => {
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
return {
hasFullAccess: session?.user?.id === creatorId,
hasChangeAccess:
session?.user?.id === creatorId ||
board.permissions.some(({ permission }) => permission === "board-change"),
hasViewAccess:
session?.user?.id === creatorId ||
board.permissions.length >= 1 ||
board.isPublic,
};
};

View File

@@ -0,0 +1 @@
export * from "./board-permissions";

View File

@@ -0,0 +1,106 @@
import type { Session } from "@auth/core/types";
import { describe, expect, test } from "vitest";
import { constructBoardPermissions } from "../board-permissions";
describe("constructBoardPermissions", () => {
test("should return all board permissions as true when session user id is equal to creator id", () => {
// Arrange
const board = {
creator: {
id: "1",
},
permissions: [],
isPublic: false,
};
const session = {
user: {
id: "1",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(true);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test('should return hasChangeAccess as true when board permissions include "board-change"', () => {
// Arrange
const board = {
creator: {
id: "1",
},
permissions: [{ permission: "board-change" }],
isPublic: false,
};
const session = {
user: {
id: "2",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when board permissions length is greater than or equal to 1", () => {
// Arrange
const board = {
creator: {
id: "1",
},
permissions: [{ permission: "board-view" }],
isPublic: false,
};
const session = {
user: {
id: "2",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when board is public", () => {
// Arrange
const board = {
creator: {
id: "1",
},
permissions: [],
isPublic: true,
};
const session = {
user: {
id: "2",
},
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);
});
});

1
packages/auth/shared.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./permissions";

View File

@@ -7,7 +7,7 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "..";
import { ClientServerDataInitalizer } from "./client"; import { ClientServerDataInitalizer } from "./client";
import { GlobalItemServerDataProvider } from "./provider"; import { GlobalItemServerDataProvider } from "./provider";
type Board = RouterOutputs["board"]["default"]; type Board = RouterOutputs["board"]["getDefaultBoard"];
type Props = PropsWithChildren<{ type Props = PropsWithChildren<{
board: Board; board: Board;