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

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