diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(default)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(default)/_definition.ts new file mode 100644 index 000000000..e4ce98302 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(default)/_definition.ts @@ -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(); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/(default)/layout.tsx similarity index 100% rename from apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx rename to apps/nextjs/src/app/[locale]/boards/(content)/(default)/layout.tsx diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/page.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/(default)/page.tsx similarity index 100% rename from apps/nextjs/src/app/[locale]/boards/(default)/page.tsx rename to apps/nextjs/src/app/[locale]/boards/(content)/(default)/page.tsx diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/_definition.tsx new file mode 100644 index 000000000..5235b8971 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/_definition.tsx @@ -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 }); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/layout.tsx new file mode 100644 index 000000000..7a2eb4b2c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/layout.tsx @@ -0,0 +1,5 @@ +import definition from "./_definition"; + +const { layout } = definition; + +export default layout; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/page.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/page.tsx similarity index 100% rename from apps/nextjs/src/app/[locale]/boards/[name]/page.tsx rename to apps/nextjs/src/app/[locale]/boards/(content)/[name]/page.tsx diff --git a/apps/nextjs/src/app/[locale]/boards/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx similarity index 92% rename from apps/nextjs/src/app/[locale]/boards/_client.tsx rename to apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 555d9c6a3..3faac9877 100644 --- a/apps/nextjs/src/app/[locale]/boards/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -19,8 +19,8 @@ export const updateBoardName = (name: string | null) => { }; type UpdateCallback = ( - prev: RouterOutputs["board"]["default"], -) => RouterOutputs["board"]["default"]; + prev: RouterOutputs["board"]["getDefaultBoard"], +) => RouterOutputs["board"]["getDefaultBoard"]; export const useUpdateBoard = () => { const utils = clientApi.useUtils(); @@ -30,7 +30,7 @@ export const useUpdateBoard = () => { if (!boardName) { 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, ); }, diff --git a/apps/nextjs/src/app/[locale]/boards/_context.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx similarity index 90% rename from apps/nextjs/src/app/[locale]/boards/_context.tsx rename to apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx index fdd6cd27d..5fbb6886d 100644 --- a/apps/nextjs/src/app/[locale]/boards/_context.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx @@ -16,7 +16,7 @@ import { clientApi } from "@homarr/api/client"; import { updateBoardName } from "./_client"; const BoardContext = createContext<{ - board: RouterOutputs["board"]["default"]; + board: RouterOutputs["board"]["getDefaultBoard"]; isReady: boolean; markAsReady: (id: string) => void; } | null>(null); @@ -24,11 +24,13 @@ const BoardContext = createContext<{ export const BoardProvider = ({ children, initialBoard, -}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["byName"] }>) => { +}: PropsWithChildren<{ + initialBoard: RouterOutputs["board"]["getBoardByName"]; +}>) => { const pathname = usePathname(); const utils = clientApi.useUtils(); const [readySections, setReadySections] = useState([]); - const { data } = clientApi.board.byName.useQuery( + const { data } = clientApi.board.getBoardByName.useQuery( { name: initialBoard.name }, { initialData: initialBoard, @@ -45,7 +47,7 @@ export const BoardProvider = ({ useEffect(() => { return () => { setReadySections([]); - void utils.board.byName.invalidate({ name: initialBoard.name }); + void utils.board.getBoardByName.invalidate({ name: initialBoard.name }); }; }, [pathname, utils, initialBoard.name]); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx new file mode 100644 index 000000000..1d543de05 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -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; + +interface Props { + getInitialBoard: (params: TParams) => Promise; +} + +export const createBoardContentPage = < + TParams extends Record, +>({ + getInitialBoard, +}: Props) => { + return { + layout: createBoardLayout({ + headerActions: , + getInitialBoard, + }), + page: () => { + return ; + }, + generateMetadata: async ({ + params, + }: { + params: TParams; + }): Promise => { + 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; + } + }, + }; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx similarity index 77% rename from apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx rename to apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 7b92c442b..d251d6e27 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -25,14 +25,20 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { revalidatePathAction } from "~/app/revalidatePathAction"; import { editModeAtom } from "~/components/board/editMode"; 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 { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; 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 board = useRequiredBoard(); + const { hasChangeAccess } = useBoardPermissions(board); + + if (!hasChangeAccess) { + return null; // Hide actions for user without access + } return ( <> @@ -45,7 +51,7 @@ export default function BoardViewHeaderActions() { ); -} +}; const AddMenu = () => { const { openModal: openCategoryEditModal } = @@ -117,28 +123,29 @@ const EditModeMenu = () => { const board = useRequiredBoard(); const utils = clientApi.useUtils(); const t = useScopedI18n("board.action.edit"); - const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({ - onSuccess() { - showSuccessNotification({ - title: t("notification.success.title"), - message: t("notification.success.message"), - }); - void utils.board.byName.invalidate({ name: board.name }); - void revalidatePathAction(`/boards/${board.name}`); - setEditMode(false); - }, - onError() { - showErrorNotification({ - title: t("notification.error.title"), - message: t("notification.error.message"), - }); - }, - }); + const { mutate: saveBoard, isPending } = + clientApi.board.saveBoard.useMutation({ + onSuccess() { + showSuccessNotification({ + title: t("notification.success.title"), + message: t("notification.success.message"), + }); + void utils.board.getBoardByName.invalidate({ name: board.name }); + void revalidatePathAction(`/boards/${board.name}`); + setEditMode(false); + }, + onError() { + showErrorNotification({ + title: t("notification.error.title"), + message: t("notification.error.message"), + }); + }, + }); - const toggle = () => { + const toggle = useCallback(() => { if (isEditMode) return saveBoard(board); setEditMode(true); - }; + }, [board, isEditMode, saveBoard, setEditMode]); return ( diff --git a/apps/nextjs/src/app/[locale]/boards/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx similarity index 100% rename from apps/nextjs/src/app/[locale]/boards/_theme.tsx rename to apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx b/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx deleted file mode 100644 index faec58434..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import headerActions from "../../[name]/@headeractions/page"; - -export default headerActions; diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts deleted file mode 100644 index 1512c171a..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts +++ /dev/null @@ -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(); - }, -}); diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx deleted file mode 100644 index db62646d0..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx +++ /dev/null @@ -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 }); - }, -}); diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx index 7a2eb4b2c..2fc15aebf 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx @@ -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: , + async getInitialBoard({ name }) { + return await api.board.getBoardByName({ name }); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx index 46a18d881..2cefb3dbf 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx @@ -38,11 +38,11 @@ import type { Board } from "../../_types"; interface Props { board: Board; - initialPermissions: RouterOutputs["board"]["permissions"]; + initialPermissions: RouterOutputs["board"]["getBoardPermissions"]; } export const AccessSettingsContent = ({ board, initialPermissions }: Props) => { - const { data: permissions } = clientApi.board.permissions.useQuery( + const { data: permissions } = clientApi.board.getBoardPermissions.useQuery( { 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 { openModal } = useModalAction(UserSelectModal); @@ -77,12 +78,12 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => { }, { 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(() => { @@ -237,7 +238,7 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => { }; interface FormType { - permissions: RouterOutputs["board"]["permissions"]; + permissions: RouterOutputs["board"]["getBoardPermissions"]; } interface InnerProps { diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx index 1666945f2..4b38a2d5f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx @@ -20,8 +20,8 @@ import { useDisclosure } from "@mantine/hooks"; import { useForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { generateColors } from "../../_theme"; import type { Board } from "../../_types"; +import { generateColors } from "../../(content)/_theme"; import { useSavePartialSettingsMutation } from "./_shared"; interface Props { diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx index b77d68f25..6635d0ba3 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -9,7 +9,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; -import { useRequiredBoard } from "../../_context"; +import { useRequiredBoard } from "../../(content)/_context"; import classes from "./danger.module.css"; export const DangerZoneSettingsContent = () => { @@ -19,9 +19,9 @@ export const DangerZoneSettingsContent = () => { const { openConfirmModal } = useConfirmModal(); const { openModal } = useModalAction(BoardRenameModal); const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = - clientApi.board.changeVisibility.useMutation(); + clientApi.board.changeBoardVisibility.useMutation(); const { mutate: deleteBoard, isPending: isDeletePending } = - clientApi.board.delete.useMutation(); + clientApi.board.deleteBoard.useMutation(); const utils = clientApi.useUtils(); const visibility = board.isPublic ? "public" : "private"; @@ -51,8 +51,8 @@ export const DangerZoneSettingsContent = () => { }, { onSettled() { - void utils.board.byName.invalidate({ name: board.name }); - void utils.board.default.invalidate(); + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getDefaultBoard.invalidate(); }, }, ); @@ -63,8 +63,8 @@ export const DangerZoneSettingsContent = () => { board.name, changeVisibility, t, - utils.board.byName, - utils.board.default, + utils.board.getBoardByName, + utils.board.getDefaultBoard, visibility, openConfirmModal, ]); diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx index 71b9c3cd6..d8e3eec82 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -20,8 +20,8 @@ import { IconAlertTriangle } from "@tabler/icons-react"; import { useForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { useUpdateBoard } from "../../_client"; import type { Board } from "../../_types"; +import { useUpdateBoard } from "../../(content)/_client"; import { useSavePartialSettingsMutation } from "./_shared"; interface Props { diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx index f69afcf9b..1097a8e13 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -4,10 +4,10 @@ import type { Board } from "../../_types"; export const useSavePartialSettingsMutation = (board: Board) => { const utils = clientApi.useUtils(); - return clientApi.board.savePartialSettings.useMutation({ + return clientApi.board.savePartialBoardSettings.useMutation({ onSettled() { - void utils.board.byName.invalidate({ name: board.name }); - void utils.board.default.invalidate(); + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getDefaultBoard.invalidate(); }, }); }; 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 5bb1f4be9..90f070106 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -1,4 +1,5 @@ import type { PropsWithChildren } from "react"; +import { notFound } from "next/navigation"; import { AccordionControl, AccordionItem, @@ -17,6 +18,7 @@ import { IconSettings, IconUser, } from "@tabler/icons-react"; +import { TRPCError } from "@trpc/server"; import { api } from "@homarr/api/server"; import { capitalize } from "@homarr/common"; @@ -24,6 +26,7 @@ import type { TranslationObject } from "@homarr/translation"; import { getScopedI18n } from "@homarr/translation/server"; import type { TablerIcon } from "@homarr/ui"; +import { getBoardPermissions } from "~/components/board/permissions/server"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; import { AccessSettingsContent } from "./_access"; 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({ params, searchParams, }: Props) { - const board = await api.board.byName({ name: params.name }); - const permissions = await api.board.permissions({ id: board.id }); + const { board, permissions } = await getBoardAndPermissions(params); + const { hasFullAccess } = await getBoardPermissions(board); const t = await getScopedI18n("board.setting"); return ( @@ -73,20 +93,24 @@ export default async function BoardSettingsPage({ - - - - - - + {hasFullAccess && ( + <> + + + + + + + + )} diff --git a/apps/nextjs/src/app/[locale]/boards/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/_creator.tsx deleted file mode 100644 index cf916a8ee..000000000 --- a/apps/nextjs/src/app/[locale]/boards/_creator.tsx +++ /dev/null @@ -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; - -interface Props { - getInitialBoard: (params: TParams) => Promise; -} - -export const createBoardPage = >({ - getInitialBoard, -}: Props) => { - return { - layout: async ({ - params, - children, - headeractions, - }: PropsWithChildren<{ params: TParams; headeractions: ReactNode }>) => { - const initialBoard = await getInitialBoard(params); - - return ( - - - - - } - actions={headeractions} - hasNavigation={false} - /> - {children} - - - - - ); - }, - page: async ({ params }: { params: TParams }) => { - const board = await getInitialBoard(params); - - if (await canAccessBoardAsync(board)) { - return ; - } - - return notFound(); - }, - generateMetadata: async ({ - params, - }: { - params: TParams; - }): Promise => { - 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 -}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx similarity index 73% rename from apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx rename to apps/nextjs/src/app/[locale]/boards/_header-actions.tsx index d373a1186..299bbb05f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx @@ -3,9 +3,9 @@ import { IconLayoutBoard } from "@tabler/icons-react"; 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(); return ( @@ -13,4 +13,4 @@ export default function BoardViewLayout() { ); -} +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx new file mode 100644 index 000000000..dbb946a68 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -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 { + headerActions: JSX.Element; + getInitialBoard: (params: TParams) => Promise; +} + +export const createBoardLayout = ({ + headerActions, + getInitialBoard, +}: CreateBoardLayoutProps) => { + 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 ( + + + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + + + + ); + }; + + return Layout; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_types.ts b/apps/nextjs/src/app/[locale]/boards/_types.ts index d1f2b92d8..dc5828171 100644 --- a/apps/nextjs/src/app/[locale]/boards/_types.ts +++ b/apps/nextjs/src/app/[locale]/boards/_types.ts @@ -1,7 +1,7 @@ import type { RouterOutputs } from "@homarr/api"; 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 Item = Section["items"][number]; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx index 78b0dda70..68eeaf3a0 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx @@ -11,6 +11,7 @@ import { useConfirmModal } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { revalidatePathAction } from "~/app/revalidatePathAction"; +import { useBoardPermissions } from "~/components/board/permissions/client"; const iconProps = { size: 16, @@ -18,7 +19,10 @@ const iconProps = { }; interface BoardCardMenuDropdownProps { - board: Pick; + board: Pick< + RouterOutputs["board"]["getAllBoards"][number], + "id" | "name" | "creator" | "permissions" | "isPublic" + >; } export const BoardCardMenuDropdown = ({ @@ -27,9 +31,11 @@ export const BoardCardMenuDropdown = ({ const t = useScopedI18n("management.page.board.action"); const tCommon = useScopedI18n("common"); + const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board); + const { openConfirmModal } = useConfirmModal(); - const { mutateAsync, isPending } = clientApi.board.delete.useMutation({ + const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({ onSettled: async () => { await revalidatePathAction("/manage/boards"); }, @@ -51,26 +57,31 @@ export const BoardCardMenuDropdown = ({ return ( - } - > - {t("settings.label")} - - - - - {tCommon("menu.section.dangerZone.title")} - - } - onClick={handleDeletion} - disabled={isPending} - > - {t("delete.label")} - + {hasChangeAccess && ( + } + > + {t("settings.label")} + + )} + {hasFullAccess && ( + <> + + + {tCommon("menu.section.dangerZone.title")} + + } + onClick={handleDeletion} + disabled={isPending} + > + {t("delete.label")} + + + )} ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index a3678be26..af16171b9 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -19,7 +19,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => { const t = useI18n(); const { openModal } = useModalAction(AddBoardModal); - const { mutateAsync, isPending } = clientApi.board.create.useMutation({ + const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({ onSettled: async () => { await revalidatePathAction("/manage/boards"); }, diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index 8a4b27253..cf0a52532 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -19,13 +19,14 @@ import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { getScopedI18n } from "@homarr/translation/server"; +import { getBoardPermissions } from "~/components/board/permissions/server"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { CreateBoardButton } from "./_components/create-board-button"; export default async function ManageBoardsPage() { const t = await getScopedI18n("management.page.board"); - const boards = await api.board.getAll(); + const boards = await api.board.getAllBoards(); return ( <> @@ -46,11 +47,12 @@ export default async function ManageBoardsPage() { } interface BoardCardProps { - board: RouterOutputs["board"]["getAll"][number]; + board: RouterOutputs["board"]["getAllBoards"][number]; } const BoardCard = async ({ board }: BoardCardProps) => { const t = await getScopedI18n("management.page.board"); + const { hasChangeAccess: isMenuVisible } = await getBoardPermissions(board); const visibility = board.isPublic ? "public" : "private"; const VisibilityIcon = board.isPublic ? IconWorld : IconLock; @@ -79,14 +81,16 @@ const BoardCard = async ({ board }: BoardCardProps) => { > {t("action.open.label")} - - - - - - - - + {isMenuVisible && ( + + + + + + + + + )} diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index c1a0a0553..badc39109 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -3,8 +3,8 @@ import { useCallback } from "react"; import { createId } from "@homarr/db/client"; import type { WidgetKind } from "@homarr/definitions"; -import { useUpdateBoard } from "~/app/[locale]/boards/_client"; import type { EmptySection, Item } from "~/app/[locale]/boards/_types"; +import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; interface MoveAndResizeItem { itemId: string; diff --git a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx index ef882be1e..0be5cede3 100644 --- a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx +++ b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx @@ -18,10 +18,12 @@ export const BoardRenameModal = createModal( ({ actions, innerProps }) => { const utils = clientApi.useUtils(); const t = useI18n(); - const { mutate, isPending } = clientApi.board.rename.useMutation({ + const { mutate, isPending } = clientApi.board.renameBoard.useMutation({ onSettled() { - void utils.board.byName.invalidate({ name: innerProps.previousName }); - void utils.board.default.invalidate(); + void utils.board.getBoardByName.invalidate({ + name: innerProps.previousName, + }); + void utils.board.getDefaultBoard.invalidate(); }, }); const form = useForm({ diff --git a/apps/nextjs/src/components/board/permissions/client.ts b/apps/nextjs/src/components/board/permissions/client.ts new file mode 100644 index 000000000..8c3406d5e --- /dev/null +++ b/apps/nextjs/src/components/board/permissions/client.ts @@ -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); +}; diff --git a/apps/nextjs/src/components/board/permissions/server.ts b/apps/nextjs/src/components/board/permissions/server.ts new file mode 100644 index 000000000..b36b7c693 --- /dev/null +++ b/apps/nextjs/src/components/board/permissions/server.ts @@ -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); +}; diff --git a/apps/nextjs/src/components/board/sections/category/category-actions.ts b/apps/nextjs/src/components/board/sections/category/category-actions.ts index 72935a077..1bd715439 100644 --- a/apps/nextjs/src/components/board/sections/category/category-actions.ts +++ b/apps/nextjs/src/components/board/sections/category/category-actions.ts @@ -2,12 +2,12 @@ import { useCallback } from "react"; import { createId } from "@homarr/db/client"; -import { useUpdateBoard } from "~/app/[locale]/boards/_client"; import type { CategorySection, EmptySection, Section, } from "~/app/[locale]/boards/_types"; +import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; interface AddCategory { name: string; diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index 0c31c015d..afa3de1db 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -22,8 +22,8 @@ import { WidgetEditModal, } from "@homarr/widgets"; -import { useRequiredBoard } from "~/app/[locale]/boards/_context"; import type { Item } from "~/app/[locale]/boards/_types"; +import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; import { editModeAtom } from "../editMode"; import { useItemActions } from "../items/item-actions"; import type { UseGridstackRefs } from "./gridstack/use-gridstack"; diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index 8949ff58d..0db33be04 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -8,11 +8,11 @@ import type { GridStackNode, } from "@homarr/gridstack"; +import type { Section } from "~/app/[locale]/boards/_types"; import { useMarkSectionAsReady, useRequiredBoard, -} from "~/app/[locale]/boards/_context"; -import type { Section } from "~/app/[locale]/boards/_types"; +} from "~/app/[locale]/boards/(content)/_context"; import { editModeAtom } from "../../editMode"; import { useItemActions } from "../../items/item-actions"; import { initializeGridstack } from "./init-gridstack"; diff --git a/apps/nextjs/src/components/layout/background.tsx b/apps/nextjs/src/components/layout/background.tsx index 262f977db..c6158dfca 100644 --- a/apps/nextjs/src/components/layout/background.tsx +++ b/apps/nextjs/src/components/layout/background.tsx @@ -1,7 +1,7 @@ import { usePathname } from "next/navigation"; 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 isVideo = (url: string) => diff --git a/apps/nextjs/src/components/layout/logo/board-logo.tsx b/apps/nextjs/src/components/layout/logo/board-logo.tsx index f174dd3ea..584c9a01a 100644 --- a/apps/nextjs/src/components/layout/logo/board-logo.tsx +++ b/apps/nextjs/src/components/layout/logo/board-logo.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRequiredBoard } from "~/app/[locale]/boards/_context"; +import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; import { homarrLogoPath, homarrPageTitle } from "./homarr-logo"; import type { LogoWithTitleProps } from "./logo"; import { Logo, LogoWithTitle } from "./logo"; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index c6c69e93d..fe84061de 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server"; import superjson from "superjson"; 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 { boardPermissions, boards, @@ -20,7 +20,8 @@ import { } from "@homarr/validation"; 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 = ( inputArray: TInput[], @@ -47,23 +48,41 @@ const filterUpdatedItems = ( ); export const boardRouter = createTRPCRouter({ - getAll: publicProcedure.query(async ({ ctx }) => { - return await ctx.db.query.boards.findMany({ + getAllBoards: publicProcedure.query(async ({ ctx }) => { + 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: { id: true, name: true, isPublic: true, }, with: { - sections: { - with: { - items: true, + creator: { + columns: { + 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) .mutation(async ({ ctx, input }) => { const boardId = createId(); @@ -71,6 +90,7 @@ export const boardRouter = createTRPCRouter({ await transaction.insert(boards).values({ id: boardId, name: input.name, + creatorId: ctx.session.user.id, }); await transaction.insert(sections).values({ id: createId(), @@ -80,9 +100,15 @@ export const boardRouter = createTRPCRouter({ }); }); }), - rename: publicProcedure + renameBoard: protectedProcedure .input(validation.board.rename) .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync( + ctx, + eq(boards.id, input.id), + "full-access", + ); + await noBoardWithSimilarName(ctx.db, input.name, [input.id]); await ctx.db @@ -90,40 +116,61 @@ export const boardRouter = createTRPCRouter({ .set({ name: input.name }) .where(eq(boards.id, input.id)); }), - changeVisibility: publicProcedure + changeBoardVisibility: protectedProcedure .input(validation.board.changeVisibility) .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync( + ctx, + eq(boards.id, input.id), + "full-access", + ); + await ctx.db .update(boards) .set({ isPublic: input.visibility === "public" }) .where(eq(boards.id, input.id)); }), - delete: publicProcedure + deleteBoard: protectedProcedure .input(z.object({ id: z.string() })) .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)); }), - default: publicProcedure.query(async ({ ctx }) => { - return await getFullBoardWithWhere(ctx.db, eq(boards.name, "default")); + getDefaultBoard: publicProcedure.query(async ({ ctx }) => { + 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) .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) .mutation(async ({ ctx, input }) => { - const board = await ctx.db.query.boards.findFirst({ - where: eq(boards.id, input.id), - }); - - if (!board) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Board not found", - }); - } + await throwIfActionForbiddenAsync( + ctx, + eq(boards.id, input.id), + "board-change", + ); await ctx.db .update(boards) @@ -153,13 +200,20 @@ export const boardRouter = createTRPCRouter({ }) .where(eq(boards.id, input.id)); }), - save: publicProcedure + saveBoard: protectedProcedure .input(validation.board.save) .mutation(async ({ input, ctx }) => { + await throwIfActionForbiddenAsync( + ctx, + eq(boards.id, input.id), + "board-change", + ); + await ctx.db.transaction(async (transaction) => { const dbBoard = await getFullBoardWithWhere( transaction, eq(boards.id, input.id), + ctx.session.user.id, ); const addedSections = filterAddedItems( @@ -314,9 +368,15 @@ export const boardRouter = createTRPCRouter({ }); }), - permissions: publicProcedure + getBoardPermissions: protectedProcedure .input(validation.board.permissions) .query(async ({ input, ctx }) => { + await throwIfActionForbiddenAsync( + ctx, + eq(boards.id, input.id), + "full-access", + ); + const permissions = await ctx.db.query.boardPermissions.findMany({ where: eq(boardPermissions.boardId, input.id), with: { @@ -340,9 +400,15 @@ export const boardRouter = createTRPCRouter({ return permissionA.user.name.localeCompare(permissionB.user.name); }); }), - savePermissions: publicProcedure + saveBoardPermissions: protectedProcedure .input(validation.board.savePermissions) .mutation(async ({ input, ctx }) => { + await throwIfActionForbiddenAsync( + ctx, + eq(boards.id, input.id), + "full-access", + ); + await ctx.db.transaction(async (transaction) => { await transaction .delete(boardPermissions) @@ -387,7 +453,11 @@ const noBoardWithSimilarName = async ( } }; -const getFullBoardWithWhere = async (db: Database, where: SQL) => { +const getFullBoardWithWhere = async ( + db: Database, + where: SQL, + userId: string | null, +) => { const board = await db.query.boards.findFirst({ where, with: { @@ -410,6 +480,12 @@ const getFullBoardWithWhere = async (db: Database, where: SQL) => { }, }, }, + permissions: { + where: eq(boardPermissions.userId, userId ?? ""), + columns: { + permission: true, + }, + }, }, }); @@ -437,8 +513,6 @@ const getFullBoardWithWhere = async (db: Database, where: SQL) => { }; }; -// 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 = (kind: T) => z.object({ kind: z.literal(kind), diff --git a/packages/api/src/router/board/board-access.ts b/packages/api/src/router/board/board-access.ts new file mode 100644 index 000000000..b3b7c4f1e --- /dev/null +++ b/packages/api/src/router/board/board-access.ts @@ -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, + 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", + }); +} diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index e8f0347f7..d2c084f76 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -10,89 +10,168 @@ import { integrations, items, sections, + users, } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; import type { RouterOutputs } from "../.."; 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 vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); -export const expectToBeDefined = (value: T) => { - if (value === undefined) { - expect(value).toBeDefined(); - } - if (value === null) { - expect(value).not.toBeNull(); - } - return value as Exclude; -}; - -describe("default should return default board", () => { +describe("getDefaultBoard should return default board", () => { it("should return default board", async () => { + // Arrange + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const fullBoardProps = await createFullBoardAsync(db, "default"); - const result = await caller.default(); + // Act + const result = await caller.getDefaultBoard(); + // Assert expectInputToBeFullBoardWithName(result, { name: "default", ...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"]])( "should return board by name %s when present", async (name) => { + // Arrange + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const fullBoardProps = await createFullBoardAsync(db, name); - const result = await caller.byName({ name }); + // Act + const result = await caller.getBoardByName({ name }); + // Assert expectInputToBeFullBoardWithName(result, { name, ...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 () => { + // Arrange + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const newPageTitle = "newPageTitle"; const newMetaTitle = "newMetaTitle"; const newLogoImageUrl = "http://logo.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"); - await caller.savePartialSettings({ + // Act + await caller.savePartialBoardSettings({ pageTitle: newPageTitle, metaTitle: newMetaTitle, logoImageUrl: newLogoImageUrl, faviconImageUrl: newFaviconImageUrl, + backgroundImageAttachment: newBackgroundImageAttachment, + backgroundImageRepeat: newBackgroundImageRepeat, + backgroundImageSize: newBackgroundImageSize, + backgroundImageUrl: newBackgroundImageUrl, + columnCount: newColumnCount, + customCss: newCustomCss, + opacity: newOpacity, + primaryColor: newPrimaryColor, + secondaryColor: newSecondaryColor, 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 () => { const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const act = async () => - await caller.savePartialSettings({ + await caller.savePartialBoardSettings({ pageTitle: "newPageTitle", metaTitle: "newMetaTitle", 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 () => { + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const { boardId, sectionId } = await createFullBoardAsync(db, "default"); - await caller.save({ + await caller.saveBoard({ id: boardId, sections: [ { @@ -138,17 +218,23 @@ describe("save 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", + ); }); it("should remove item when not present in input", async () => { + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const { boardId, itemId, sectionId } = await createFullBoardAsync( db, "default", ); - await caller.save({ + await caller.saveBoard({ id: boardId, sections: [ { @@ -192,10 +278,16 @@ describe("save 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", + ); }); it("should remove integration reference when not present in input", async () => { + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const anotherIntegration = { id: createId(), kind: "adGuardHome", @@ -207,7 +299,7 @@ describe("save should save full board", () => { await createFullBoardAsync(db, "default"); await db.insert(integrations).values(anotherIntegration); - await caller.save({ + await caller.saveBoard({ id: boardId, sections: [ { @@ -257,18 +349,24 @@ describe("save 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", + ); }); it.each([ [{ kind: "empty" as const }], [{ kind: "category" as const, name: "My first category" }], ])("should add section when present in input", async (partialSection) => { + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); 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 newSectionId = createId(); - await caller.save({ + await caller.saveBoard({ id: boardId, sections: [ { @@ -310,15 +408,21 @@ describe("save should save full board", () => { expect(addedSection.name).toBe(partialSection.name); } expect(section).toBeDefined(); + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "board-change", + ); }); it("should add item when present in input", async () => { + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); 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 newItemId = createId(); - await caller.save({ + await caller.saveBoard({ id: boardId, sections: [ { @@ -374,10 +478,16 @@ describe("save 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", + ); }); it("should add integration reference when present in input", async () => { + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const integration = { id: createId(), kind: "plex", @@ -391,7 +501,7 @@ describe("save should save full board", () => { ); await db.insert(integrations).values(integration); - await caller.save({ + await caller.saveBoard({ id: boardId, sections: [ { @@ -443,10 +553,15 @@ describe("save 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", + ); }); it("should update section when present in input", async () => { 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 newSectionId = createId(); @@ -458,7 +573,7 @@ describe("save should save full board", () => { boardId, }); - await caller.save({ + await caller.saveBoard({ id: boardId, sections: [ { @@ -503,15 +618,16 @@ describe("save should save full board", () => { expect(secondSection.name).toBe("After"); }); it("should update item when present in input", async () => { + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const { boardId, itemId, sectionId } = await createFullBoardAsync( db, "default", ); - await caller.save({ + await caller.saveBoard({ id: boardId, sections: [ { @@ -562,13 +678,18 @@ describe("save 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", + ); }); it("should fail when board not found", async () => { const db = createDb(); - const caller = boardRouter.createCaller({ db, session: null }); + const caller = boardRouter.createCaller({ db, session: defaultSession }); const act = async () => - await caller.save({ + await caller.saveBoard({ id: "nonExistentBoardId", sections: [], }); @@ -578,7 +699,7 @@ describe("save should save full board", () => { }); const expectInputToBeFullBoardWithName = ( - input: RouterOutputs["board"]["default"], + input: RouterOutputs["board"]["getDefaultBoard"], props: { name: string } & Awaited>, ) => { expect(input.id).toBe(props.boardId); @@ -600,10 +721,15 @@ const expectInputToBeFullBoardWithName = ( }; const createFullBoardAsync = async (db: Database, name: string) => { + await db.insert(users).values({ + id: defaultCreatorId, + }); + const boardId = createId(); await db.insert(boards).values({ id: boardId, name, + creatorId: defaultCreatorId, }); const sectionId = createId(); diff --git a/packages/api/src/router/test/board/board-access.spec.ts b/packages/api/src/router/test/board/board-access.spec.ts new file mode 100644 index 000000000..794a2a0ea --- /dev/null +++ b/packages/api/src/router/test/board/board-access.spec.ts @@ -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, 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"); + }); +}); diff --git a/packages/api/src/router/test/helper.ts b/packages/api/src/router/test/helper.ts new file mode 100644 index 000000000..f979bac5e --- /dev/null +++ b/packages/api/src/router/test/helper.ts @@ -0,0 +1,11 @@ +import { expect } from "vitest"; + +export const expectToBeDefined = (value: T) => { + if (value === undefined) { + expect(value).toBeDefined(); + } + if (value === null) { + expect(value).not.toBeNull(); + } + return value as Exclude; +}; diff --git a/packages/api/src/router/test/integration.spec.ts b/packages/api/src/router/test/integration.spec.ts index cff84d82c..a1ac61aad 100644 --- a/packages/api/src/router/test/integration.spec.ts +++ b/packages/api/src/router/test/integration.spec.ts @@ -7,7 +7,7 @@ import { createDb } from "@homarr/db/test"; import type { RouterInputs } from "../.."; import { encryptSecret, integrationRouter } from "../integration"; -import { expectToBeDefined } from "./board.spec"; +import { expectToBeDefined } from "./helper"; // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); diff --git a/packages/auth/index.ts b/packages/auth/index.ts index acefe9ee8..3495d19ed 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -1,3 +1,4 @@ +import { cache } from "react"; import type { DefaultSession } from "@auth/core/types"; import { createConfiguration } from "./configuration"; @@ -16,5 +17,11 @@ export * from "./security"; export const createHandlers = (isCredentialsRequest: boolean) => 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"; diff --git a/packages/auth/package.json b/packages/auth/package.json index 41dc4c3d8..59c1bae35 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -6,6 +6,7 @@ ".": "./index.ts", "./security": "./security.ts", "./client": "./client.ts", + "./shared": "./shared.ts", "./env.mjs": "./env.mjs" }, "private": true, diff --git a/packages/auth/permissions/board-permissions.ts b/packages/auth/permissions/board-permissions.ts new file mode 100644 index 000000000..6921d8f86 --- /dev/null +++ b/packages/auth/permissions/board-permissions.ts @@ -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, + }; +}; diff --git a/packages/auth/permissions/index.ts b/packages/auth/permissions/index.ts new file mode 100644 index 000000000..8090541aa --- /dev/null +++ b/packages/auth/permissions/index.ts @@ -0,0 +1 @@ +export * from "./board-permissions"; diff --git a/packages/auth/permissions/test/board-permissions.spec.ts b/packages/auth/permissions/test/board-permissions.spec.ts new file mode 100644 index 000000000..91a348f6f --- /dev/null +++ b/packages/auth/permissions/test/board-permissions.spec.ts @@ -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); + }); +}); diff --git a/packages/auth/shared.ts b/packages/auth/shared.ts new file mode 100644 index 000000000..972144448 --- /dev/null +++ b/packages/auth/shared.ts @@ -0,0 +1 @@ +export * from "./permissions"; diff --git a/packages/widgets/src/server/runner.tsx b/packages/widgets/src/server/runner.tsx index bd4ed8798..fa4a7d451 100644 --- a/packages/widgets/src/server/runner.tsx +++ b/packages/widgets/src/server/runner.tsx @@ -7,7 +7,7 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from ".."; import { ClientServerDataInitalizer } from "./client"; import { GlobalItemServerDataProvider } from "./provider"; -type Board = RouterOutputs["board"]["default"]; +type Board = RouterOutputs["board"]["getDefaultBoard"]; type Props = PropsWithChildren<{ board: Board;