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

@@ -4,6 +4,7 @@ import { useCallback } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
@@ -12,7 +13,6 @@ import { useScopedI18n } from "@homarr/translation/client";
import { ActionIcon, IconTrash } from "@homarr/ui";
import { revalidatePathAction } from "../../../revalidatePathAction";
import { modalEvents } from "../../modals";
interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number];
@@ -20,10 +20,11 @@ interface AppDeleteButtonProps {
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useScopedI18n("app.page.delete");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
const onClick = useCallback(() => {
modalEvents.openConfirmModal({
openConfirmModal({
title: t("title"),
children: t("message", app),
onConfirm: () => {
@@ -47,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
);
},
});
}, [app, mutate, t]);
}, [app, mutate, t, openConfirmModal]);
return (
<ActionIcon

View File

@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
@@ -11,7 +12,6 @@ import { useScopedI18n } from "@homarr/translation/client";
import { ActionIcon, IconTrash } from "@homarr/ui";
import { revalidatePathAction } from "../../../revalidatePathAction";
import { modalEvents } from "../../modals";
interface DeleteIntegrationActionButtonProps {
count: number;
@@ -24,6 +24,7 @@ export const DeleteIntegrationActionButton = ({
}: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
return (
@@ -32,7 +33,7 @@ export const DeleteIntegrationActionButton = ({
variant="subtle"
color="red"
onClick={() => {
modalEvents.openConfirmModal({
openConfirmModal({
title: t("title"),
children: t("message", integration),
onConfirm: () => {

View File

@@ -10,6 +10,7 @@ import {
getDefaultSecretKinds,
} from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
@@ -19,7 +20,6 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { modalEvents } from "~/app/[locale]/modals";
import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import {
@@ -35,9 +35,10 @@ interface EditIntegrationForm {
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const secretsKinds =
getAllSecretKindOptions(integration.kind).find((x) =>
integration.secrets.every((y) => x.includes(y.kind)),
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
) ?? getDefaultSecretKinds(integration.kind);
const initialFormValues = {
name: integration.name,
@@ -99,7 +100,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
};
return (
<form onSubmit={form.onSubmit((v) => void handleSubmit(v))}>
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack>
<TestConnectionNoticeAlert />
@@ -128,7 +129,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
) {
return res(true);
}
modalEvents.openConfirmModal({
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => res(false),

View File

@@ -33,7 +33,7 @@ export const IntegrationCreateDropdownContent = () => {
leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(event) => setSearch(event.target.value)}
/>
{filteredKinds.length > 0 ? (

View File

@@ -1,29 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import { ModalsManager } from "../modals";
export const ModalsProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.action");
return (
<ModalsManager
labels={{
cancel: t("cancel"),
confirm: t("confirm"),
}}
modalProps={{
styles: {
title: {
fontSize: "1.25rem",
fontWeight: 500,
},
},
}}
>
{children}
</ModalsManager>
);
};

View File

@@ -54,7 +54,7 @@ export const LoginForm = () => {
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((v) => void handleSubmit(v))}>
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack gap="lg">
<TextInput
label={t("field.username.label")}

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

View File

@@ -51,7 +51,7 @@ export const InitUserForm = () => {
<Stack gap="xl">
<form
onSubmit={form.onSubmit(
(v) => void handleSubmit(v),
(values) => void handleSubmit(values),
(err) => console.log(err),
)}
>

View File

@@ -5,11 +5,11 @@ import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { ColorSchemeScript, createTheme, MantineProvider } from "@homarr/ui";
import { JotaiProvider } from "./_client-providers/jotai";
import { ModalsProvider } from "./_client-providers/modals";
import { NextInternationalProvider } from "./_client-providers/next-international";
import { TRPCReactProvider } from "./_client-providers/trpc";
import { composeWrappers } from "./compose";
@@ -67,7 +67,7 @@ export default function Layout(props: {
})}
/>
),
(innerProps) => <ModalsProvider {...innerProps} />,
(innerProps) => <ModalProvider {...innerProps} />,
]);
return (

View File

@@ -1,13 +1,14 @@
"use client";
import React from "react";
import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Button, IconCategoryPlus } from "@homarr/ui";
import { modalEvents } from "~/app/[locale]/modals";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
interface CreateBoardButtonProps {
boardNames: string[];
@@ -15,6 +16,7 @@ interface CreateBoardButtonProps {
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
const t = useI18n();
const { openModal } = useModalAction(AddBoardModal);
const { mutateAsync, isPending } = clientApi.board.create.useMutation({
onSettled: async () => {
@@ -22,20 +24,16 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
},
});
const onClick = React.useCallback(() => {
modalEvents.openManagedModal({
modal: "addBoardModal",
title: t("management.page.board.button.create"),
innerProps: {
onSuccess: async (values) => {
await mutateAsync({
name: values.name,
});
},
boardNames,
const onClick = useCallback(() => {
openModal({
onSuccess: async (values) => {
await mutateAsync({
name: values.name,
});
},
boardNames,
});
}, [mutateAsync, t, boardNames]);
}, [mutateAsync, boardNames, openModal]);
return (
<Button

View File

@@ -1,20 +0,0 @@
"use client";
import { createModalManager } from "mantine-modal-manager";
import { WidgetEditModal } from "@homarr/widgets";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
import { PreviewDimensionsModal } from "./widgets/[kind]/_dimension-modal";
export const [ModalsManager, modalEvents] = createModalManager({
categoryEditModal: CategoryEditModal,
widgetEditModal: WidgetEditModal,
itemSelectModal: ItemSelectModal,
addBoardModal: AddBoardModal,
boardRenameModal: BoardRenameModal,
dimensionsModal: PreviewDimensionsModal,
});

View File

@@ -3,6 +3,7 @@
import { useCallback, useMemo, useState } from "react";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import {
@@ -17,10 +18,11 @@ import {
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
WidgetEditModal,
widgetImports,
} from "@homarr/widgets";
import { modalEvents } from "../../modals";
import { PreviewDimensionsModal } from "./_dimension-modal";
import type { Dimensions } from "./_dimension-modal";
interface WidgetPreviewPageContentProps {
@@ -38,6 +40,10 @@ export const WidgetPreviewPageContent = ({
integrationData,
}: WidgetPreviewPageContentProps) => {
const t = useScopedI18n("widgetPreview");
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(
PreviewDimensionsModal,
);
const currentDefinition = useMemo(
() => widgetImports[kind].definition,
[kind],
@@ -55,28 +61,25 @@ export const WidgetPreviewPageContent = ({
integrations: [],
});
const Comp = loadWidgetDynamic(kind);
const openWitgetEditModal = useCallback(() => {
return modalEvents.openManagedModal({
modal: "widgetEditModal",
innerProps: {
kind,
value: state,
onSuccessfulEdit: (value) => {
setState(value);
},
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some(
(kind) => kind === integration.kind,
),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
const handleOpenEditWidgetModal = useCallback(() => {
openWidgetEditModal({
kind,
value: state,
onSuccessfulEdit: (value) => {
setState(value);
},
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some(
(kind) => kind === integration.kind,
),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
});
}, [kind, state, integrationData, currentDefinition]);
}, [currentDefinition, integrationData, kind, openWidgetEditModal, state]);
const Comp = loadWidgetDynamic(kind);
const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode);
@@ -86,15 +89,11 @@ export const WidgetPreviewPageContent = ({
}, [editMode, t]);
const openDimensionsModal = useCallback(() => {
modalEvents.openManagedModal({
modal: "dimensionsModal",
title: t("dimensions.title"),
innerProps: {
dimensions,
setDimensions,
},
openPreviewDimensionsModal({
dimensions,
setDimensions,
});
}, [dimensions, t]);
}, [dimensions, openPreviewDimensionsModal]);
return (
<>
@@ -107,7 +106,8 @@ export const WidgetPreviewPageContent = ({
<Comp
options={state.options as never}
integrations={state.integrations.map(
(id) => integrationData.find((x) => x.id === id)!,
(id) =>
integrationData.find((integration) => integration.id === id)!,
)}
width={dimensions.width}
height={dimensions.height}
@@ -119,7 +119,7 @@ export const WidgetPreviewPageContent = ({
size={48}
variant="default"
radius="xl"
onClick={openWitgetEditModal}
onClick={handleOpenEditWidgetModal}
>
<IconPencil size={24} />
</ActionIcon>

View File

@@ -1,8 +1,7 @@
"use client";
import type { ManagedModal } from "mantine-modal-manager";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Button, Group, InputWrapper, Slider, Stack } from "@homarr/ui";
@@ -11,49 +10,50 @@ interface InnerProps {
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsModal: ManagedModal<InnerProps> = ({
actions,
innerProps,
}) => {
const t = useI18n();
const form = useForm({
initialValues: innerProps.dimensions,
});
export const PreviewDimensionsModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: innerProps.dimensions,
});
const handleSubmit = (values: Dimensions) => {
innerProps.setDimensions(values);
actions.closeModal();
};
const handleSubmit = (values: Dimensions) => {
innerProps.setDimensions(values);
actions.closeModal();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.move.field.width.label")}>
<Slider
min={64}
max={1024}
step={64}
{...form.getInputProps("width")}
/>
</InputWrapper>
<InputWrapper label={t("item.move.field.height.label")}>
<Slider
min={64}
max={1024}
step={64}
{...form.getInputProps("height")}
/>
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.move.field.width.label")}>
<Slider
min={64}
max={1024}
step={64}
{...form.getInputProps("width")}
/>
</InputWrapper>
<InputWrapper label={t("item.move.field.height.label")}>
<Slider
min={64}
max={1024}
step={64}
{...form.getInputProps("height")}
/>
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) => t("widgetPreview.dimensions.title"),
});
export interface Dimensions {
width: number;

View File

@@ -41,8 +41,12 @@ export const useItemActions = () => {
({ kind }: CreateItem) => {
updateBoard((previous) => {
const lastSection = previous.sections
.filter((s): s is EmptySection => s.kind === "empty")
.sort((a, b) => b.position - a.position)[0];
.filter(
(section): section is EmptySection => section.kind === "empty",
)
.sort(
(sectionA, sectionB) => sectionB.position - sectionA.position,
)[0];
if (!lastSection) return previous;

View File

@@ -1,6 +1,5 @@
import type { ManagedModal } from "mantine-modal-manager";
import type { WidgetKind } from "@homarr/definitions";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Button, Card, Center, Grid, Stack, Text } from "@homarr/ui";
@@ -9,9 +8,7 @@ import { widgetImports } from "../../../../../../packages/widgets/src";
import type { WidgetDefinition } from "../../../../../../packages/widgets/src/definition";
import { useItemActions } from "./item-actions";
export const ItemSelectModal: ManagedModal<Record<string, never>> = ({
actions,
}) => {
export const ItemSelectModal = createModal<void>(({ actions }) => {
return (
<Grid>
{objectEntries(widgetImports).map(([key, value]) => {
@@ -26,7 +23,10 @@ export const ItemSelectModal: ManagedModal<Record<string, never>> = ({
})}
</Grid>
);
};
}).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: "xl",
});
const WidgetItem = ({
kind,

View File

@@ -1,9 +1,8 @@
"use client";
import type { ManagedModal } from "mantine-modal-manager";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Button, Group, Stack, TextInput } from "@homarr/ui";
import type { validation, z } from "@homarr/validation";
@@ -14,58 +13,60 @@ interface InnerProps {
onSuccess?: (name: string) => void;
}
export const BoardRenameModal: ManagedModal<InnerProps> = ({
actions,
innerProps,
}) => {
const utils = clientApi.useUtils();
const t = useI18n();
const { mutate, isPending } = clientApi.board.rename.useMutation({
onSettled() {
void utils.board.byName.invalidate({ name: innerProps.previousName });
void utils.board.default.invalidate();
},
});
const form = useForm<FormType>({
initialValues: {
name: innerProps.previousName,
},
});
const handleSubmit = (values: FormType) => {
mutate(
{
id: innerProps.id,
name: values.name,
export const BoardRenameModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const utils = clientApi.useUtils();
const t = useI18n();
const { mutate, isPending } = clientApi.board.rename.useMutation({
onSettled() {
void utils.board.byName.invalidate({ name: innerProps.previousName });
void utils.board.default.invalidate();
},
{
onSuccess: () => {
actions.closeModal();
innerProps.onSuccess?.(values.name);
});
const form = useForm<FormType>({
initialValues: {
name: innerProps.previousName,
},
});
const handleSubmit = (values: FormType) => {
mutate(
{
id: innerProps.id,
name: values.name,
},
},
);
};
{
onSuccess: () => {
actions.closeModal();
innerProps.onSuccess?.(values.name);
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("board.field.name.label")}
{...form.getInputProps("name")}
data-autofocus
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("board.field.name.label")}
{...form.getInputProps("name")}
data-autofocus
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.dangerZone.action.rename.modal.title"),
});
type FormType = Omit<z.infer<(typeof validation)["board"]["rename"]>, "id">;

View File

@@ -80,10 +80,10 @@ export const useCategoryActions = () => {
updateBoard((previous) => {
const lastSection = previous.sections
.filter(
(x): x is CategorySection | EmptySection =>
x.kind === "empty" || x.kind === "category",
(section): section is CategorySection | EmptySection =>
section.kind === "empty" || section.kind === "category",
)
.sort((a, b) => b.position - a.position)
.sort((sectionA, sectionB) => sectionB.position - sectionA.position)
.at(0);
if (!lastSection) return previous;

View File

@@ -1,6 +1,5 @@
import type { ManagedModal } from "mantine-modal-manager";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Button, Group, Stack, TextInput } from "@homarr/ui";
@@ -15,42 +14,41 @@ interface InnerProps {
onSuccess: (category: Category) => void;
}
export const CategoryEditModal: ManagedModal<InnerProps> = ({
actions,
innerProps,
}) => {
const t = useI18n();
const form = useForm({
initialValues: {
name: innerProps.category.name,
},
});
export const CategoryEditModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: {
name: innerProps.category.name,
},
});
return (
<form
onSubmit={form.onSubmit((v) => {
void innerProps.onSuccess({
...innerProps.category,
name: v.name,
});
actions.closeModal();
})}
>
<Stack>
<TextInput
label={t("section.category.field.name.label")}
data-autofocus
{...form.getInputProps("name")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
{innerProps.submitLabel}
</Button>
</Group>
</Stack>
</form>
);
};
return (
<form
onSubmit={form.onSubmit((values) => {
void innerProps.onSuccess({
...innerProps.category,
name: values.name,
});
actions.closeModal();
})}
>
<Stack>
<TextInput
label={t("section.category.field.name.label")}
data-autofocus
{...form.getInputProps("name")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
{innerProps.submitLabel}
</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({});

View File

@@ -1,23 +1,24 @@
import { useCallback } from "react";
import { createId } from "@homarr/db/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { modalEvents } from "~/app/[locale]/modals";
import { useCategoryActions } from "./category-actions";
import { CategoryEditModal } from "./category-edit-modal";
export const useCategoryMenuActions = (category: CategorySection) => {
const { openModal } = useModalAction(CategoryEditModal);
const { openConfirmModal } = useConfirmModal();
const { addCategory, moveCategory, removeCategory, renameCategory } =
useCategoryActions();
const t = useI18n();
const createCategoryAtPosition = useCallback(
(position: number) => {
modalEvents.openManagedModal({
title: t("section.category.create.title"),
modal: "categoryEditModal",
innerProps: {
openModal(
{
category: {
id: createId(),
name: t("section.category.create.title"),
@@ -30,9 +31,12 @@ export const useCategoryMenuActions = (category: CategorySection) => {
},
submitLabel: t("section.category.create.submit"),
},
});
{
title: (t) => t("section.category.create.title"),
},
);
},
[addCategory, t],
[addCategory, t, openModal],
);
// creates a new category above the current
@@ -63,7 +67,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
// Removes the current category
const remove = useCallback(() => {
modalEvents.openConfirmModal({
openConfirmModal({
title: t("section.category.remove.title"),
children: t("section.category.remove.message", {
name: category.name,
@@ -73,17 +77,12 @@ export const useCategoryMenuActions = (category: CategorySection) => {
id: category.id,
});
},
confirmProps: {
color: "red",
},
});
}, [category.id, category.name, removeCategory, t]);
}, [category.id, category.name, removeCategory, t, openConfirmModal]);
const edit = () => {
modalEvents.openManagedModal({
modal: "categoryEditModal",
title: t("section.category.edit.title"),
innerProps: {
const edit = useCallback(() => {
openModal(
{
category,
submitLabel: t("section.category.edit.submit"),
onSuccess: (category) => {
@@ -93,8 +92,11 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
},
},
});
};
{
title: (t) => t("section.category.edit.title"),
},
);
}, [category, openModal, renameCategory, t]);
return {
addCategoryAbove,

View File

@@ -6,6 +6,7 @@ import { useElementSize } from "@mantine/hooks";
import cx from "clsx";
import { useAtomValue } from "jotai";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import {
ActionIcon,
@@ -20,11 +21,11 @@ import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
useServerDataFor,
WidgetEditModal,
} from "@homarr/widgets";
import { useRequiredBoard } from "~/app/[locale]/boards/_context";
import type { Item } from "~/app/[locale]/boards/_types";
import { modalEvents } from "~/app/[locale]/modals";
import { editModeAtom } from "../editMode";
import { useItemActions } from "../items/item-actions";
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
@@ -108,43 +109,38 @@ const BoardItem = ({ item, ...dimensions }: ItemProps) => {
const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
const t = useScopedI18n("item");
const { openModal } = useModalAction(WidgetEditModal);
const { openConfirmModal } = useConfirmModal();
const isEditMode = useAtomValue(editModeAtom);
const { updateItemOptions, removeItem } = useItemActions();
if (!isEditMode) return null;
const openEditModal = () => {
modalEvents.openManagedModal({
title: t("edit.title"),
modal: "widgetEditModal",
innerProps: {
kind: item.kind,
value: {
options: item.options,
integrations: item.integrations.map(({ id }) => id),
},
onSuccessfulEdit: ({ options, integrations: _ }) => {
updateItemOptions({
itemId: item.id,
newOptions: options,
});
},
integrationData: [],
integrationSupport: false,
openModal({
kind: item.kind,
value: {
options: item.options,
integrations: item.integrations.map(({ id }) => id),
},
onSuccessfulEdit: ({ options, integrations: _ }) => {
updateItemOptions({
itemId: item.id,
newOptions: options,
});
},
integrationData: [],
integrationSupport: false,
});
};
const openRemoveModal = () => {
modalEvents.openConfirmModal({
openConfirmModal({
title: t("remove.title"),
children: t("remove.message"),
onConfirm: () => {
removeItem({ itemId: item.id });
},
confirmProps: {
color: "red",
},
});
};

View File

@@ -10,7 +10,10 @@ export const navigationCollapsedAtom = atom(true);
export const ClientBurger = () => {
const [collapsed, setCollapsed] = useAtom(navigationCollapsedAtom);
const toggle = useCallback(() => setCollapsed((c) => !c), [setCollapsed]);
const toggle = useCallback(
() => setCollapsed((collapsed) => !collapsed),
[setCollapsed],
);
return (
<Burger opened={!collapsed} onClick={toggle} hiddenFrom="sm" size="sm" />

View File

@@ -1,7 +1,7 @@
import type { ManagedModal } from "mantine-modal-manager";
import { boardSchemas } from "node_modules/@homarr/validation/src/board";
import { useForm, zodResolver } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Button, Group, Stack, TextInput } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -11,48 +11,51 @@ interface InnerProps {
onSuccess: ({ name }: { name: string }) => Promise<void>;
}
export const AddBoardModal: ManagedModal<InnerProps> = ({
actions,
innerProps,
}) => {
const t = useI18n();
const form = useForm({
initialValues: {
name: "",
},
validate: zodResolver(
z.object({
name: boardSchemas.byName.shape.name.refine(
(value) => !innerProps.boardNames.includes(value),
),
}),
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
export const AddBoardModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: {
name: "",
},
validate: zodResolver(
z.object({
name: boardSchemas.byName.shape.name.refine(
(value) => !innerProps.boardNames.includes(value),
),
}),
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
return (
<form
onSubmit={form.onSubmit((values) => {
void innerProps.onSuccess(values);
actions.closeModal();
})}
>
<Stack>
<TextInput
label={t("management.page.board.modal.createBoard.field.name.label")}
data-autofocus
{...form.getInputProps("name")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button disabled={!form.isValid()} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
return (
<form
onSubmit={form.onSubmit((values) => {
void innerProps.onSuccess(values);
actions.closeModal();
})}
>
<Stack>
<TextInput
label={t(
"management.page.board.modal.createBoard.field.name.label",
)}
data-autofocus
{...form.getInputProps("name")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button disabled={!form.isValid()} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) => t("management.page.board.button.create"),
});

View File

@@ -10,7 +10,7 @@ export const env = createEnv({
VERCEL_URL: z
.string()
.optional()
.transform((v) => (v ? `https://${v}` : undefined)),
.transform((url) => (url ? `https://${url}` : undefined)),
PORT: z.coerce.number().default(3000),
},
/**