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:
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import definition from "./_definition";
|
||||||
|
|
||||||
|
const { layout } = definition;
|
||||||
|
|
||||||
|
export default layout;
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -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]);
|
||||||
|
|
||||||
57
apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
Normal file
57
apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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}>
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import headerActions from "../../[name]/@headeractions/page";
|
|
||||||
|
|
||||||
export default headerActions;
|
|
||||||
@@ -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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
60
apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
Normal file
60
apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>({
|
||||||
|
|||||||
8
apps/nextjs/src/components/board/permissions/client.ts
Normal file
8
apps/nextjs/src/components/board/permissions/client.ts
Normal 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);
|
||||||
|
};
|
||||||
8
apps/nextjs/src/components/board/permissions/server.ts
Normal file
8
apps/nextjs/src/components/board/permissions/server.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
67
packages/api/src/router/board/board-access.ts
Normal file
67
packages/api/src/router/board/board-access.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
188
packages/api/src/router/test/board/board-access.spec.ts
Normal file
188
packages/api/src/router/test/board/board-access.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
11
packages/api/src/router/test/helper.ts
Normal file
11
packages/api/src/router/test/helper.ts
Normal 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>;
|
||||||
|
};
|
||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
35
packages/auth/permissions/board-permissions.ts
Normal file
35
packages/auth/permissions/board-permissions.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
1
packages/auth/permissions/index.ts
Normal file
1
packages/auth/permissions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./board-permissions";
|
||||||
106
packages/auth/permissions/test/board-permissions.spec.ts
Normal file
106
packages/auth/permissions/test/board-permissions.spec.ts
Normal 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
1
packages/auth/shared.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./permissions";
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user