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 = (
|
||||
prev: RouterOutputs["board"]["default"],
|
||||
) => RouterOutputs["board"]["default"];
|
||||
prev: RouterOutputs["board"]["getDefaultBoard"],
|
||||
) => RouterOutputs["board"]["getDefaultBoard"];
|
||||
|
||||
export const useUpdateBoard = () => {
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -30,7 +30,7 @@ export const useUpdateBoard = () => {
|
||||
if (!boardName) {
|
||||
throw new Error("Board name is not set");
|
||||
}
|
||||
utils.board.byName.setData({ name: boardName }, (previous) =>
|
||||
utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
|
||||
previous ? updaterWithoutUndefined(previous) : previous,
|
||||
);
|
||||
},
|
||||
@@ -16,7 +16,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { updateBoardName } from "./_client";
|
||||
|
||||
const BoardContext = createContext<{
|
||||
board: RouterOutputs["board"]["default"];
|
||||
board: RouterOutputs["board"]["getDefaultBoard"];
|
||||
isReady: boolean;
|
||||
markAsReady: (id: string) => void;
|
||||
} | null>(null);
|
||||
@@ -24,11 +24,13 @@ const BoardContext = createContext<{
|
||||
export const BoardProvider = ({
|
||||
children,
|
||||
initialBoard,
|
||||
}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["byName"] }>) => {
|
||||
}: PropsWithChildren<{
|
||||
initialBoard: RouterOutputs["board"]["getBoardByName"];
|
||||
}>) => {
|
||||
const pathname = usePathname();
|
||||
const utils = clientApi.useUtils();
|
||||
const [readySections, setReadySections] = useState<string[]>([]);
|
||||
const { data } = clientApi.board.byName.useQuery(
|
||||
const { data } = clientApi.board.getBoardByName.useQuery(
|
||||
{ name: initialBoard.name },
|
||||
{
|
||||
initialData: initialBoard,
|
||||
@@ -45,7 +47,7 @@ export const BoardProvider = ({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setReadySections([]);
|
||||
void utils.board.byName.invalidate({ name: initialBoard.name });
|
||||
void utils.board.getBoardByName.invalidate({ name: initialBoard.name });
|
||||
};
|
||||
}, [pathname, utils, initialBoard.name]);
|
||||
|
||||
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 { editModeAtom } from "~/components/board/editMode";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { useRequiredBoard } from "../../_context";
|
||||
import { useRequiredBoard } from "./_context";
|
||||
|
||||
export default function BoardViewHeaderActions() {
|
||||
export const BoardContentHeaderActions = () => {
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
return null; // Hide actions for user without access
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -45,7 +51,7 @@ export default function BoardViewHeaderActions() {
|
||||
</HeaderButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const { openModal: openCategoryEditModal } =
|
||||
@@ -117,28 +123,29 @@ const EditModeMenu = () => {
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void utils.board.byName.invalidate({ name: board.name });
|
||||
void revalidatePathAction(`/boards/${board.name}`);
|
||||
setEditMode(false);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const { mutate: saveBoard, isPending } =
|
||||
clientApi.board.saveBoard.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void revalidatePathAction(`/boards/${board.name}`);
|
||||
setEditMode(false);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) return saveBoard(board);
|
||||
setEditMode(true);
|
||||
};
|
||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||
|
||||
return (
|
||||
<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 {
|
||||
board: Board;
|
||||
initialPermissions: RouterOutputs["board"]["permissions"];
|
||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
}
|
||||
|
||||
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
||||
const { data: permissions } = clientApi.board.permissions.useQuery(
|
||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
||||
{
|
||||
id: board.id,
|
||||
},
|
||||
@@ -64,7 +64,8 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
||||
}),
|
||||
},
|
||||
});
|
||||
const { mutate, isPending } = clientApi.board.savePermissions.useMutation();
|
||||
const { mutate, isPending } =
|
||||
clientApi.board.saveBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
|
||||
@@ -77,12 +78,12 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.board.permissions.invalidate();
|
||||
void utils.board.getBoardPermissions.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[board.id, mutate, utils.board.permissions],
|
||||
[board.id, mutate, utils.board.getBoardPermissions],
|
||||
);
|
||||
|
||||
const handleAddUser = useCallback(() => {
|
||||
@@ -237,7 +238,7 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
||||
};
|
||||
|
||||
interface FormType {
|
||||
permissions: RouterOutputs["board"]["permissions"];
|
||||
permissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
|
||||
@@ -20,8 +20,8 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { generateColors } from "../../_theme";
|
||||
import type { Board } from "../../_types";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { useRequiredBoard } from "../../_context";
|
||||
import { useRequiredBoard } from "../../(content)/_context";
|
||||
import classes from "./danger.module.css";
|
||||
|
||||
export const DangerZoneSettingsContent = () => {
|
||||
@@ -19,9 +19,9 @@ export const DangerZoneSettingsContent = () => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
|
||||
clientApi.board.changeVisibility.useMutation();
|
||||
clientApi.board.changeBoardVisibility.useMutation();
|
||||
const { mutate: deleteBoard, isPending: isDeletePending } =
|
||||
clientApi.board.delete.useMutation();
|
||||
clientApi.board.deleteBoard.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const visibility = board.isPublic ? "public" : "private";
|
||||
|
||||
@@ -51,8 +51,8 @@ export const DangerZoneSettingsContent = () => {
|
||||
},
|
||||
{
|
||||
onSettled() {
|
||||
void utils.board.byName.invalidate({ name: board.name });
|
||||
void utils.board.default.invalidate();
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getDefaultBoard.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -63,8 +63,8 @@ export const DangerZoneSettingsContent = () => {
|
||||
board.name,
|
||||
changeVisibility,
|
||||
t,
|
||||
utils.board.byName,
|
||||
utils.board.default,
|
||||
utils.board.getBoardByName,
|
||||
utils.board.getDefaultBoard,
|
||||
visibility,
|
||||
openConfirmModal,
|
||||
]);
|
||||
|
||||
@@ -20,8 +20,8 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useUpdateBoard } from "../../_client";
|
||||
import type { Board } from "../../_types";
|
||||
import { useUpdateBoard } from "../../(content)/_client";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { Board } from "../../_types";
|
||||
|
||||
export const useSavePartialSettingsMutation = (board: Board) => {
|
||||
const utils = clientApi.useUtils();
|
||||
return clientApi.board.savePartialSettings.useMutation({
|
||||
return clientApi.board.savePartialBoardSettings.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.byName.invalidate({ name: board.name });
|
||||
void utils.board.default.invalidate();
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getDefaultBoard.invalidate();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
IconSettings,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
@@ -24,6 +26,7 @@ import type { TranslationObject } from "@homarr/translation";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissions } from "~/components/board/permissions/server";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { AccessSettingsContent } from "./_access";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
@@ -42,12 +45,29 @@ interface Props {
|
||||
};
|
||||
}
|
||||
|
||||
const getBoardAndPermissions = async (params: Props["params"]) => {
|
||||
try {
|
||||
const board = await api.board.getBoardByName({ name: params.name });
|
||||
const permissions = await api.board.getBoardPermissions({ id: board.id });
|
||||
|
||||
return { board, permissions };
|
||||
} catch (error) {
|
||||
// Ignore not found errors and redirect to 404
|
||||
// error is already logged in _layout-creator.tsx
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default async function BoardSettingsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: Props) {
|
||||
const board = await api.board.byName({ name: params.name });
|
||||
const permissions = await api.board.permissions({ id: board.id });
|
||||
const { board, permissions } = await getBoardAndPermissions(params);
|
||||
const { hasFullAccess } = await getBoardPermissions(board);
|
||||
const t = await getScopedI18n("board.setting");
|
||||
|
||||
return (
|
||||
@@ -73,20 +93,24 @@ export default async function BoardSettingsPage({
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
<AccessSettingsContent
|
||||
board={board}
|
||||
initialPermissions={permissions}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor
|
||||
value="dangerZone"
|
||||
icon={IconAlertTriangle}
|
||||
danger
|
||||
noPadding
|
||||
>
|
||||
<DangerZoneSettingsContent />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
<AccessSettingsContent
|
||||
board={board}
|
||||
initialPermissions={permissions}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor
|
||||
value="dangerZone"
|
||||
icon={IconAlertTriangle}
|
||||
danger
|
||||
noPadding
|
||||
>
|
||||
<DangerZoneSettingsContent />
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
</ActiveTabAccordion>
|
||||
</Stack>
|
||||
</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 { HeaderButton } from "~/components/layout/header/button";
|
||||
import { useRequiredBoard } from "../../../_context";
|
||||
import { useRequiredBoard } from "./(content)/_context";
|
||||
|
||||
export default function BoardViewLayout() {
|
||||
export const BoardOtherHeaderActions = () => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
@@ -13,4 +13,4 @@ export default function BoardViewLayout() {
|
||||
<IconLayoutBoard stroke={1.5} />
|
||||
</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 { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export type Board = RouterOutputs["board"]["default"];
|
||||
export type Board = RouterOutputs["board"]["getDefaultBoard"];
|
||||
export type Section = Board["sections"][number];
|
||||
export type Item = Section["items"][number];
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useConfirmModal } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
|
||||
const iconProps = {
|
||||
size: 16,
|
||||
@@ -18,7 +19,10 @@ const iconProps = {
|
||||
};
|
||||
|
||||
interface BoardCardMenuDropdownProps {
|
||||
board: Pick<RouterOutputs["board"]["getAll"][number], "id" | "name">;
|
||||
board: Pick<
|
||||
RouterOutputs["board"]["getAllBoards"][number],
|
||||
"id" | "name" | "creator" | "permissions" | "isPublic"
|
||||
>;
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({
|
||||
@@ -27,9 +31,11 @@ export const BoardCardMenuDropdown = ({
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.delete.useMutation({
|
||||
const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathAction("/manage/boards");
|
||||
},
|
||||
@@ -51,26 +57,31 @@ export const BoardCardMenuDropdown = ({
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/boards/${board.name}/settings`}
|
||||
leftSection={<IconSettings {...iconProps} />}
|
||||
>
|
||||
{t("settings.label")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.7">
|
||||
{tCommon("menu.section.dangerZone.title")}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
c="red.7"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDeletion}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("delete.label")}
|
||||
</Menu.Item>
|
||||
{hasChangeAccess && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/boards/${board.name}/settings`}
|
||||
leftSection={<IconSettings {...iconProps} />}
|
||||
>
|
||||
{t("settings.label")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.7">
|
||||
{tCommon("menu.section.dangerZone.title")}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
c="red.7"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDeletion}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("delete.label")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.create.useMutation({
|
||||
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathAction("/manage/boards");
|
||||
},
|
||||
|
||||
@@ -19,13 +19,14 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { getBoardPermissions } from "~/components/board/permissions/server";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
export default async function ManageBoardsPage() {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
|
||||
const boards = await api.board.getAll();
|
||||
const boards = await api.board.getAllBoards();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -46,11 +47,12 @@ export default async function ManageBoardsPage() {
|
||||
}
|
||||
|
||||
interface BoardCardProps {
|
||||
board: RouterOutputs["board"]["getAll"][number];
|
||||
board: RouterOutputs["board"]["getAllBoards"][number];
|
||||
}
|
||||
|
||||
const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const { hasChangeAccess: isMenuVisible } = await getBoardPermissions(board);
|
||||
const visibility = board.isPublic ? "public" : "private";
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
@@ -79,14 +81,16 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
>
|
||||
{t("action.open.label")}
|
||||
</Button>
|
||||
<Menu position="bottom-end">
|
||||
<MenuTarget>
|
||||
<ActionIcon variant="default" size="lg">
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuTarget>
|
||||
<BoardCardMenuDropdown board={board} />
|
||||
</Menu>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
<MenuTarget>
|
||||
<ActionIcon variant="default" size="lg">
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuTarget>
|
||||
<BoardCardMenuDropdown board={board} />
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</CardSection>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user