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:
Meier Lukas
2024-03-20 20:30:58 +01:00
committed by GitHub
parent 4753bc7162
commit 361700b239
59 changed files with 1763 additions and 1338 deletions

View File

@@ -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>

View 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"),
});

View File

@@ -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">

View File

@@ -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}

View File

@@ -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);

View File

@@ -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
};