feat: add board access settings (#249)
* wip: add board access settings * wip: add user access control * wip: add user access control * feat: add user access control * refactor: move away from mantine-modal-manager * fix: ci issues and failing tests * fix: lint issue * fix: format issue * fix: deepsource issues * chore: address pull request feedback
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -21,10 +23,11 @@ import {
|
||||
Menu,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { editModeAtom } from "~/components/board/editMode";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
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";
|
||||
|
||||
@@ -46,9 +49,36 @@ export default function BoardViewHeaderActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { openModal: openCategoryEditModal } =
|
||||
useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal();
|
||||
}, [openItemSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
@@ -62,14 +92,7 @@ const AddMenu = () => {
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item
|
||||
leftSection={<IconBox size={20} />}
|
||||
onClick={() =>
|
||||
modalEvents.openManagedModal({
|
||||
title: t("item.create.title"),
|
||||
size: "xl",
|
||||
modal: "itemSelectModal",
|
||||
innerProps: {},
|
||||
})
|
||||
}
|
||||
onClick={handleSelectItem}
|
||||
>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
@@ -81,22 +104,7 @@ const AddMenu = () => {
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconBoxAlignTop size={20} />}
|
||||
onClick={() =>
|
||||
modalEvents.openManagedModal({
|
||||
title: t("section.category.create.title"),
|
||||
modal: "categoryEditModal",
|
||||
innerProps: {
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
onClick={handleAddCategory}
|
||||
>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
293
apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx
Normal file
293
apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { boardPermissions } from "@homarr/definitions";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { SelectProps, TablerIcon } from "@homarr/ui";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
initialPermissions: RouterOutputs["board"]["permissions"];
|
||||
}
|
||||
|
||||
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
||||
const { data: permissions } = clientApi.board.permissions.useQuery(
|
||||
{
|
||||
id: board.id,
|
||||
},
|
||||
{
|
||||
initialData: initialPermissions,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
permissions: permissions.sort((permissionA, permissionB) => {
|
||||
if (permissionA.user.id === board.creatorId) return -1;
|
||||
if (permissionB.user.id === board.creatorId) return 1;
|
||||
return permissionA.user.name.localeCompare(permissionB.user.name);
|
||||
}),
|
||||
},
|
||||
});
|
||||
const { mutate, isPending } = clientApi.board.savePermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(v: FormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: board.id,
|
||||
permissions: v.permissions,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.board.permissions.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[board.id, mutate, utils.board.permissions],
|
||||
);
|
||||
|
||||
const handleAddUser = useCallback(() => {
|
||||
const presentUserIds = form.values.permissions.map(
|
||||
(permission) => permission.user.id,
|
||||
);
|
||||
|
||||
openModal({
|
||||
presentUserIds: board.creatorId
|
||||
? presentUserIds.concat(board.creatorId)
|
||||
: presentUserIds,
|
||||
onSelect: (user) => {
|
||||
form.setFieldValue("permissions", [
|
||||
...form.values.permissions,
|
||||
{
|
||||
user,
|
||||
permission: "board-view",
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
}, [form, openModal, board.creatorId]);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>
|
||||
{t("board.setting.section.access.permission.field.user.label")}
|
||||
</TableTh>
|
||||
<TableTh>
|
||||
{t(
|
||||
"board.setting.section.access.permission.field.permission.label",
|
||||
)}
|
||||
</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{board.creator && <CreatorRow user={board.creator} />}
|
||||
{form.values.permissions.map((row, index) => {
|
||||
const Icon = icons[row.permission];
|
||||
return (
|
||||
<TableTr key={row.user.id}>
|
||||
<TableTd>{row.user.name}</TableTd>
|
||||
<TableTd>
|
||||
<Group wrap="nowrap">
|
||||
<Select
|
||||
flex="1"
|
||||
leftSection={<Icon size="1rem" />}
|
||||
renderOption={RenderOption}
|
||||
variant="unstyled"
|
||||
data={boardPermissions.map((permission) => ({
|
||||
value: permission,
|
||||
label: t(
|
||||
`board.setting.section.access.permission.item.${permission}.label`,
|
||||
),
|
||||
}))}
|
||||
{...form.getInputProps(
|
||||
`permissions.${index}.permission`,
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setFieldValue(
|
||||
"permissions",
|
||||
form.values.permissions.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
})}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
rightSection={<IconPlus size="1rem" />}
|
||||
variant="light"
|
||||
onClick={handleAddUser}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreatorRowProps {
|
||||
user: Exclude<Board["creator"], null>;
|
||||
}
|
||||
|
||||
const CreatorRow = ({ user }: CreatorRowProps) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>{user.name}</TableTd>
|
||||
<TableTd>
|
||||
<Group gap={0}>
|
||||
<Flex w={34} h={34} align="center" justify="center">
|
||||
<IconSettings
|
||||
size="1rem"
|
||||
color="var(--input-section-color, var(--mantine-color-dimmed))"
|
||||
/>
|
||||
</Flex>
|
||||
<Text size="sm">
|
||||
{t("board.setting.section.access.permission.item.board-full.label")}
|
||||
</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
const icons = {
|
||||
"board-change": IconPencil,
|
||||
"board-view": IconEye,
|
||||
} satisfies Record<BoardPermission, TablerIcon>;
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: "1rem",
|
||||
};
|
||||
|
||||
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
||||
const Icon = icons[option.value as BoardPermission];
|
||||
return (
|
||||
<Group flex="1" gap="xs">
|
||||
<Icon {...iconProps} />
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormType {
|
||||
permissions: RouterOutputs["board"]["permissions"];
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
presentUserIds: string[];
|
||||
onSelect: (props: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
interface UserSelectFormType {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const UserSelectModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: users } = clientApi.user.selectable.useQuery();
|
||||
const form = useForm<UserSelectFormType>();
|
||||
const handleSubmit = (v: UserSelectFormType) => {
|
||||
const currentUser = users?.find((user) => user.id === v.userId);
|
||||
if (!currentUser) return;
|
||||
innerProps.onSelect({
|
||||
id: currentUser.id,
|
||||
name: currentUser.name ?? "",
|
||||
});
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("userId")}
|
||||
label={t(
|
||||
"board.setting.section.access.permission.userSelect.label",
|
||||
)}
|
||||
searchable
|
||||
nothingFoundMessage={t(
|
||||
"board.setting.section.access.permission.userSelect.notFound",
|
||||
)}
|
||||
limit={5}
|
||||
data={users
|
||||
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
||||
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.add")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) =>
|
||||
t("board.setting.section.access.permission.userSelect.title"),
|
||||
});
|
||||
@@ -4,10 +4,11 @@ import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Button, Divider, Group, Stack, Text } from "@homarr/ui";
|
||||
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { useRequiredBoard } from "../../_context";
|
||||
import classes from "./danger.module.css";
|
||||
|
||||
@@ -15,6 +16,8 @@ export const DangerZoneSettingsContent = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.setting");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
|
||||
clientApi.board.changeVisibility.useMutation();
|
||||
const { mutate: deleteBoard, isPending: isDeletePending } =
|
||||
@@ -24,31 +27,22 @@ export const DangerZoneSettingsContent = () => {
|
||||
|
||||
const onRenameClick = useCallback(
|
||||
() =>
|
||||
modalEvents.openManagedModal({
|
||||
modal: "boardRenameModal",
|
||||
title: t("section.dangerZone.action.rename.modal.title"),
|
||||
innerProps: {
|
||||
id: board.id,
|
||||
previousName: board.name,
|
||||
onSuccess: (name) => {
|
||||
router.push(`/boards/${name}/settings`);
|
||||
},
|
||||
},
|
||||
openModal({
|
||||
id: board.id,
|
||||
previousName: board.name,
|
||||
onSuccess: (name) => router.push(`/boards/${name}/settings`),
|
||||
}),
|
||||
[board.id, board.name, router, t],
|
||||
[board.id, board.name, router, openModal],
|
||||
);
|
||||
|
||||
const onVisibilityClick = useCallback(() => {
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t(
|
||||
`section.dangerZone.action.visibility.confirm.${visibility}.title`,
|
||||
),
|
||||
children: t(
|
||||
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
|
||||
),
|
||||
confirmProps: {
|
||||
color: "red.9",
|
||||
},
|
||||
onConfirm: () => {
|
||||
changeVisibility(
|
||||
{
|
||||
@@ -72,15 +66,13 @@ export const DangerZoneSettingsContent = () => {
|
||||
utils.board.byName,
|
||||
utils.board.default,
|
||||
visibility,
|
||||
openConfirmModal,
|
||||
]);
|
||||
|
||||
const onDeleteClick = useCallback(() => {
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t("section.dangerZone.action.delete.confirm.title"),
|
||||
children: t("section.dangerZone.action.delete.confirm.description"),
|
||||
confirmProps: {
|
||||
color: "red.9",
|
||||
},
|
||||
onConfirm: () => {
|
||||
deleteBoard(
|
||||
{ id: board.id },
|
||||
@@ -92,7 +84,7 @@ export const DangerZoneSettingsContent = () => {
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [board.id, deleteBoard, router, t]);
|
||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
IconLayout,
|
||||
IconPhoto,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { AccessSettingsContent } from "./_access";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { ColorSettingsContent } from "./_colors";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
@@ -43,6 +45,7 @@ export default async function BoardSettingsPage({
|
||||
searchParams,
|
||||
}: Props) {
|
||||
const board = await api.board.byName({ name: params.name });
|
||||
const permissions = await api.board.permissions({ id: board.id });
|
||||
const t = await getScopedI18n("board.setting");
|
||||
|
||||
return (
|
||||
@@ -68,6 +71,12 @@ 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}
|
||||
|
||||
@@ -45,7 +45,9 @@ export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const isReady = useIsBoardReady();
|
||||
|
||||
const sortedSections = board.sections.sort((a, b) => a.position - b.position);
|
||||
const sortedSections = board.sections.sort(
|
||||
(sectionA, sectionB) => sectionA.position - sectionB.position,
|
||||
);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ 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";
|
||||
@@ -51,10 +55,14 @@ export const createBoardPage = <TParams extends Record<string, unknown>>({
|
||||
</GlobalItemServerDataRunner>
|
||||
);
|
||||
},
|
||||
page: () => {
|
||||
// TODO: Add check if board is private and user is not logged in
|
||||
page: async ({ params }: { params: TParams }) => {
|
||||
const board = await getInitialBoard(params);
|
||||
|
||||
return <ClientBoard />;
|
||||
if (await canAccessBoardAsync(board)) {
|
||||
return <ClientBoard />;
|
||||
}
|
||||
|
||||
return notFound();
|
||||
},
|
||||
generateMetadata: async ({
|
||||
params,
|
||||
@@ -63,6 +71,10 @@ export const createBoardPage = <TParams extends Record<string, unknown>>({
|
||||
}): Promise<Metadata> => {
|
||||
const board = await getInitialBoard(params);
|
||||
|
||||
if (!(await canAccessBoardAsync(board))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
|
||||
icons: {
|
||||
@@ -72,3 +84,30 @@ export const createBoardPage = <TParams extends Record<string, unknown>>({
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user