Compare commits

...
Author SHA1 Message Date
ajnart af13531adc fix: keep destructive confirmations red 2026-08-27 11:33:34 +02:00
ajnart 7a5edb26c1 feat: reduce disruptive UI confirmations 2026-08-27 11:33:34 +02:00
86 changed files with 3201 additions and 2277 deletions

No files matched your search

+3
View File
@@ -29,6 +29,9 @@ Alternatively, you can [open the full interactive API reference](/api-reference)
Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key.
You can obtain API keys from the "Authentication" tab at the top of the page.
API keys will not expire and can be used indefinitely.
When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the
creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation
instead of opening a separate dialog.
### API Key Format
+8 -4
View File
@@ -34,9 +34,11 @@ arrow keys to move through the results and press `Enter` to open the selected bo
## Create a new board
When creating a board, the availability of the board name will be checked for you.
Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board
details without opening a dialog or leaving the board list. The availability of the board name is checked as you type.
![](./img/create-board-modal.png)
The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that
board, and create the copy without navigating to a separate page.
### Name
@@ -56,6 +58,9 @@ This will enable public access for everyone - regardless of whether they are log
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the
same menu item. No separate confirmation dialog is opened.
## Board settings
You navigate to the board settings by clicking on these three dots:
@@ -405,8 +410,7 @@ nor the integrations the user does not have access to.
Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board.
Duplicating a board that contains Custom Widgets also requires an administrator.
You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board".
![](./img/duplicate-board-modal.png)
The name form expands below that board card and can be cancelled without leaving the list.
## Setting boards as your home board
@@ -30,15 +30,12 @@ Navigate to `Management` > `Tools` > `Certificates`.
## Add a certificate
To add a certificate click on the "Add certificate" button.
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
![](./img/add-button.png)
Then select the certificate file you want to add.
![](./img/add-modal.png)
And click the "Add" button.
Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without
leaving the page.
## Delete a certificate
@@ -46,7 +43,7 @@ To delete a certificate you can click on the trash icon in the certificate card.
![](./img/card.png)
Then confirm the deletion.
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
## Managing it through file system
@@ -114,7 +114,8 @@ The install flow always links to the corresponding public Workshop entry under t
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a
dialog. Community moderation is separate from Homarr administration and is not a security boundary.
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
performs definitive validation again at install time.
+6 -4
View File
@@ -108,7 +108,7 @@ However, you can create as many groups as you'd like.
### Creating a group
Click on the button `New group` to create a new one.
Homarr will prompt you for a group name.
The group-name form expands above the group list and can be closed without leaving the page.
### Managing a group
@@ -259,7 +259,8 @@ An invitation link can be used indefinitely.
### Creating an invitation
Click on the button:
Select the create-invitation button. The expiration form expands above the invitation table instead of opening a
dialog or navigating to another page:
![](./img/create-invitation-button.png)
@@ -269,12 +270,12 @@ The default is 4 hours in the future.
![](./img/invitation-expiry-date.png)
Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied:
Next, click `Create` and the same section shows the ID, Token and a link that can be copied:
![](./img/create-invitation-confirmation.png)
Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again.
Clicking the `Copy & close` button will copy the URL to your clipboard.
Use **Copy** to copy the URL, then close the section when you are finished.
### Self-onboarding
@@ -293,3 +294,4 @@ If an invitation needs to be deleted before or after the expiry, the delete butt
The link will become invalid.
If the invitation expires, the link will also expire.
The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog.
@@ -1,15 +1,16 @@
"use client";
import { useCallback } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import { Box, Button, Group, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { boardRenameSchema } from "@homarr/validation/board";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
@@ -17,70 +18,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
const utils = clientApi.useUtils();
const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), {
initialValues: {
name: board.name,
},
});
const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
const onRenameClick = useCallback(
() =>
openModal({
useEffect(() => {
if (isRenaming) renameInputRef.current?.focus();
}, [isRenaming]);
const handleRename = renameForm.onSubmit((values) => {
renameBoard(
{ id: board.id, name: values.name },
{
onSuccess() {
router.push(`/boards/${values.name}/settings`);
},
},
);
});
const cancelRename = () => {
renameForm.reset();
setIsRenaming(false);
};
const handleVisibilityChange = () => {
changeVisibility(
{
id: board.id,
previousName: board.name,
onSuccess: (name) => router.push(`/boards/${name}/settings`),
}),
[board.id, board.name, router, openModal],
);
const onVisibilityClick = useCallback(() => {
openConfirmModal({
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
onConfirm: () => {
changeVisibility(
{
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
);
visibility: visibility === "public" ? "private" : "public",
},
});
}, [
board.id,
board.name,
changeVisibility,
t,
utils.board.getBoardByName,
utils.board.getHomeBoard,
visibility,
openConfirmModal,
]);
const onDeleteClick = useCallback(() => {
openConfirmModal({
title: t("section.dangerZone.action.delete.confirm.title"),
children: t("section.dangerZone.action.delete.confirm.description"),
onConfirm: () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
});
}, [board.id, deleteBoard, router, t, openConfirmModal]);
);
};
const handleDelete = () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
};
return (
<DangerZoneRoot>
@@ -88,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
action={
<Button variant="subtle" color="red" onClick={onRenameClick}>
{t("section.dangerZone.action.rename.button")}
</Button>
isRenaming ? (
<Box component="form" onSubmit={handleRename}>
<Group wrap="wrap" justify="end">
<TextInput
ref={renameInputRef}
aria-label={t("section.dangerZone.action.rename.label")}
size="sm"
{...renameForm.getInputProps("name")}
/>
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
{tCommonAction("cancel")}
</Button>
<Button type="submit" color="red" loading={isRenamePending}>
{tCommonAction("confirm")}
</Button>
</Group>
</Box>
) : (
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
{t("section.dangerZone.action.rename.button")}
</Button>
)
}
/>
{!hideVisibility && (
@@ -98,9 +118,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
action={
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
<InlineConfirmButton
variant="subtle"
color="red"
loading={isChangeVisibilityPending}
onConfirm={handleVisibilityChange}
confirmLabel={tCommonAction("confirm")}
>
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
</Button>
</InlineConfirmButton>
}
/>
)}
@@ -108,9 +134,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={tCommonAction("delete")}
description={t("section.dangerZone.action.delete.description")}
action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
<InlineConfirmButton
variant="subtle"
color="red"
loading={isDeletePending}
onConfirm={handleDelete}
confirmLabel={tCommonAction("confirm")}
>
{tCommonAction("delete")}
</Button>
</InlineConfirmButton>
}
/>
</DangerZoneRoot>
@@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { Board } from "../../_types";
@@ -46,7 +46,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
@@ -84,29 +83,20 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
}
};
const confirmReset = (layout: FormValues["layouts"][number]) => {
openConfirmModal({
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: tBoard("setting.section.layout.reset.confirm.message"),
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
onConfirm() {
setResettingLayoutId(layout.id);
void (async () => {
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
const handleReset = async (layout: FormValues["layouts"][number]) => {
setResettingLayoutId(layout.id);
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
})();
},
});
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
};
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
@@ -236,16 +226,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<Button
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onClick={() => confirmReset(layout)}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</Button>
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
@@ -1,13 +1,12 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -18,48 +17,41 @@ interface AppDeleteButtonProps {
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useI18n("app.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: app.name,
}),
onConfirm: () => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
const onConfirm = useCallback(() => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
});
}, [app, mutate, t, tCommon, openConfirmModal]);
);
}, [app.id, mutate, t, tCommon]);
return (
<ActionIcon
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={tCommon("action.deleteNamed", { name: app.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -8,10 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -25,9 +23,10 @@ interface BoardCardMenuDropdownProps {
RouterOutputs["board"]["getManageOverview"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
>;
onDuplicate: () => void;
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
const t = useI18n("management.page.board.action");
const tRoot = useI18n();
const tCommon = useI18n("common");
@@ -35,9 +34,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
@@ -56,20 +52,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
},
});
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tCommon("action.deleteNamed", { name: board.name }),
children: t("delete.confirm.description", {
name: board.name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await deleteBoardMutation.mutateAsync({
id: board.id,
});
},
});
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
const handleDeletion = useCallback(
() => deleteBoardMutation.mutateAsync({ id: board.id }),
[board.id, deleteBoardMutation],
);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -79,15 +65,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openDuplicateModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -97,7 +74,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
{tRoot("board.action.duplicate.title")}
</Menu.Item>
)}
@@ -117,14 +94,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<>
<Menu.Divider />
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
<Menu.Item
<InlineConfirmMenuItem
c="red.7"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDeletion}
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
disabled={deleteBoardMutation.isPending}
>
{tCommon("action.delete")}
</Menu.Item>
</InlineConfirmMenuItem>
</>
)}
</Menu.Dropdown>
@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Collapse,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { BoardCardMenuDropdown } from "./board-card-menu-dropdown";
import { DuplicateBoardForm } from "./duplicate-board-form";
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
export const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
const [duplicateFormKey, setDuplicateFormKey] = useState(0);
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<>
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown
board={board}
onDuplicate={() => {
setDuplicateFormKey((value) => value + 1);
setIsDuplicateOpen(true);
}}
/>
</Menu>
)}
</Group>
</CardSection>
</Card>
<Collapse expanded={isDuplicateOpen}>
<Card mt="sm" withBorder>
<DuplicateBoardForm key={duplicateFormKey} board={board} onCancel={() => setIsDuplicateOpen(false)} />
</Card>
</Collapse>
</>
);
};
@@ -1,23 +1,55 @@
"use client";
import { Button } from "@mantine/core";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { BoardCreateForm } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n("management.page.board.action");
const { openModal } = useModalAction(AddBoardModal);
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsOpen(true);
}, [searchParams]);
const close = () => {
setIsOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false });
};
const toggle = () => {
if (isOpen) {
close();
return;
}
setFormKey((value) => value + 1);
setIsOpen(true);
};
return (
<ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("new.label")}
</Button>
</ManageMobilePrimaryAction>
<Stack>
<Group justify="end">
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={toggle}>
{t("new.label")}
</Button>
</Group>
<Collapse expanded={isOpen}>
<Card withBorder>
<BoardCreateForm key={formKey} onCancel={close} />
</Card>
</Collapse>
</Stack>
);
};
@@ -1,29 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardDuplicateSchema } from "@homarr/validation/board";
import { createModal } from "../../../modals/src/creator";
import { useBoardNameStatus } from "./add-board-modal";
interface InnerProps {
interface DuplicateBoardFormProps {
board: {
id: string;
name: string;
};
onCancel: () => void;
}
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
mode: "controlled",
initialValues: {
name: innerProps.board.name,
name: board.name,
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
@@ -36,20 +39,21 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
return (
<form
onSubmit={form.onSubmit(async (values) => {
// Prevent submit before name availability check
if (!boardNameStatus.canSubmit) return;
await mutateAsync(
{
...values,
id: innerProps.board.id,
id: board.id,
},
{
onSuccess() {
actions.closeModal();
showSuccessNotification({
title: tBoard("action.duplicate.notification.success.title"),
message: tBoard("action.duplicate.notification.success.message"),
});
onCancel();
router.refresh();
},
onError() {
showErrorNotification({
@@ -61,39 +65,26 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
);
})}
>
<Stack>
<Stack maw={720}>
<Text size="sm" c="gray.6">
{tBoard("action.duplicate.message", { name: innerProps.board.name })}
{tBoard("action.duplicate.message", { name: board.name })}
</Text>
<TextInput
label={tCommon("field.name")}
data-autofocus
autoFocus
{...form.getInputProps("name")}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
description={<BoardNameAvailability status={boardNameStatus} />}
withAsterisk
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
{tBoard("action.duplicate.title")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("board.action.duplicate.title");
},
});
};
@@ -1,38 +1,15 @@
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Grid,
GridCol,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import {
IconDeviceMobile,
IconDotsVertical,
IconHomeFilled,
IconLayoutDashboard,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import { Grid, GridCol } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { getI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { BoardCard } from "./_components/board-card";
import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
@@ -42,24 +19,14 @@ export default async function ManageBoardsPage() {
const canCreateBoards = session?.user.permissions.includes("board-create");
return (
<ManagePageLayout
title={t("title")}
primaryAction={
canCreateBoards ? (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateBoards}
>
<ManagePageLayout title={t("title")}>
{canCreateBoards && (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
)}
{boards.length === 0 && (
<NoResults
icon={IconLayoutDashboard}
title={t("noResults.title")}
description={t("noResults.description")}
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
/>
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
)}
{boards.length > 0 && (
<TourTarget id="manage-boards-list">
@@ -92,97 +59,3 @@ export default async function ManageBoardsPage() {
</ManagePageLayout>
);
}
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown board={board} />
</Menu>
)}
</Group>
</CardSection>
</Card>
);
};
@@ -20,10 +20,9 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { downloadJson } from "~/components/custom-widgets/download";
@@ -40,7 +39,7 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
@@ -173,30 +172,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
};
const handleDelete = () => {
openConfirmModal({
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
);
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
});
);
};
return (
@@ -263,14 +256,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
</>
)}
{!widget.migrationRequired && (
<Menu.Item
<InlineConfirmMenuItem
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</InlineConfirmMenuItem>
)}
</Menu.Dropdown>
</Menu>
@@ -35,7 +35,7 @@ import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
import { WorkshopReportForm } from "~/components/workshop/workshop-report-form";
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
import { useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
@@ -213,6 +213,13 @@ export function WorkshopDetail({ id }: { id: string }) {
</Accordion.Item>
</Accordion>
<WorkshopReportForm
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
<ManageStickyFooter
secondary={
<>
@@ -241,6 +248,8 @@ export function WorkshopDetail({ id }: { id: string }) {
leftSection={<IconFlag size={16} />}
disabled={!session.user}
onClick={reportControls.open}
aria-expanded={reportOpened}
aria-controls="workshop-report-form"
>
{t("report")}
</Button>
@@ -259,13 +268,6 @@ export function WorkshopDetail({ id }: { id: string }) {
</Button>
</ManageStickyFooter>
</Stack>
<WorkshopReportModal
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
</ManagePageLayout>
);
}
@@ -6,11 +6,12 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId, getMantineColor } from "@homarr/common";
import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { CertificateUploadForm } from "~/app/[locale]/manage/tools/certificates/_components/certificate-upload-form";
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
interface CertificateErrorDetailsProps {
@@ -23,80 +24,71 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [showRetryButton, setShowRetryButton] = useState(false);
const [showUploadForm, setShowUploadForm] = useState(false);
const { openModal: openUploadModal } = useModalAction(AddCertificateModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
const { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } =
clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } =
clientApi.certificates.addCertificate.useMutation();
const rootCertificate = getHeighestCertificate(error.data.certificate);
const handleTrustHostname = () => {
const handleTrustHostname = async () => {
const { hostname } = new URL(url);
openConfirmModal({
title: tError("certificate.hostnameMismatch.confirm.title"),
children: tError("certificate.hostnameMismatch.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
},
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
},
});
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
};
const handleTrustSelfSigned = () => {
const handleTrustSelfSigned = async () => {
const { hostname } = new URL(url);
openConfirmModal({
title: tError("certificate.selfSigned.confirm.title"),
children: tError("certificate.selfSigned.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
});
},
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
});
},
});
};
const description = <Text size="md">{tError(`certificate.description.${error.data.reason}`)}</Text>;
let trustConfirmLabel = tError("certificate.selfSigned.confirm.title");
if (error.data.reason === "hostnameMismatch") {
trustConfirmLabel = tError("certificate.hostnameMismatch.confirm.title");
}
if (!isAdmin) {
return (
@@ -130,30 +122,36 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? (
<Button
type="button"
<InlineConfirmButton
variant="default"
fullWidth
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
loading={error.data.reason === "hostnameMismatch" ? isTrustHostnamePending : isTrustSelfSignedPending}
onConfirm={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
confirmLabel={trustConfirmLabel}
>
{tError("certificate.action.trust.label")}
</Button>
</InlineConfirmButton>
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
<Button
type="button"
variant="default"
fullWidth
onClick={() =>
openUploadModal({
onSuccess() {
setShowRetryButton(true);
},
})
}
>
{tError("certificate.action.upload.label")}
</Button>
<Stack gap="sm">
{!showUploadForm && (
<Button variant="default" fullWidth onClick={() => setShowUploadForm(true)}>
{tError("certificate.action.upload.label")}
</Button>
)}
{showUploadForm && (
<Card withBorder>
<CertificateUploadForm
embedded
onCancel={() => setShowUploadForm(false)}
onSuccess={() => {
setShowRetryButton(true);
setShowUploadForm(false);
}}
/>
</Card>
)}
</Stack>
) : null}
</>
);
@@ -1,13 +1,12 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface DeleteIntegrationActionButtonProps {
integration: { id: string; name: string };
@@ -16,46 +15,42 @@ interface DeleteIntegrationActionButtonProps {
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
const t = useI18n("integration.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
const onConfirm = () =>
mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
return (
<ActionIcon
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={() => {
openConfirmModal({
title: t("title"),
children: t("message", integration),
onConfirm: () => {
void mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
},
});
}}
aria-label={tCommon("action.deleteNamed", { name: integration.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -1,40 +1,41 @@
"use client";
import { ActionIcon, Tooltip } from "@mantine/core";
import { Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface DeleteMediaProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
const { openConfirmModal } = useConfirmModal();
const t = useI18n("media");
const tCommon = useI18n("common");
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
const onClick = () => {
openConfirmModal({
title: t("action.delete.label"),
children: t.rich("action.delete.description", { bName: () => <b>{media.name}</b> }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
},
});
const onConfirm = async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
};
return (
<Tooltip label={t("action.delete.label")} openDelay={500}>
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
<InlineConfirmActionIcon
color="red"
variant="subtle"
onConfirm={onConfirm}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
aria-label={t("action.delete.label")}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
</Tooltip>
);
};
@@ -1,15 +1,14 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
@@ -18,48 +17,41 @@ interface SearchEngineDeleteButtonProps {
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useI18n("search.engine.page.delete");
const actionT = useI18n("common.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: searchEngine.name,
}),
onConfirm: () => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
const onConfirm = useCallback(() => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
});
}, [searchEngine, mutate, t, openConfirmModal]);
);
}, [searchEngine.id, mutate, t]);
return (
<ActionIcon
<InlineConfirmActionIcon
confirmLabel={actionT("confirm")}
confirmationAriaLabel={actionT("confirm")}
onConfirm={onConfirm}
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -1,34 +1,67 @@
"use client";
import { useCallback, useMemo } from "react";
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
import { useCallback, useMemo, useState } from "react";
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui";
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
import { NewApiKeyAlert } from "./new-api-key-alert";
interface ApiKeysManagementProps {
apiKeys: RouterOutputs["apiKeys"]["getAll"];
onCreated: () => void;
}
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { openModal } = useModalAction(CopyApiKeyModal);
const { openConfirmModal } = useConfirmModal();
type ApiKey = RouterOutputs["apiKeys"]["getAll"][number];
type ApiKeyCellProps = { row: MRT_Row<ApiKey> };
interface ApiKeysTableMeta {
handleDelete: (id: string) => Promise<void>;
confirmLabel: string;
deleteTitle: string;
isPendingDelete: boolean;
}
const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => (
<Group gap="xs">
<UserAvatar user={row.original.user} size="sm" />
<Text>{row.original.user.name}</Text>
</Group>
);
const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance<ApiKey> }) => {
const meta = table.options.meta as ApiKeysTableMeta;
return (
<Group gap="xs">
<InlineConfirmActionIcon
onConfirm={() => meta.handleDelete(row.original.id)}
confirmLabel={meta.confirmLabel}
confirmationAriaLabel={meta.confirmLabel}
loading={meta.isPendingDelete}
color="red"
variant="subtle"
aria-label={meta.deleteTitle}
>
<IconTrash size="1rem" />
</InlineConfirmActionIcon>
</Group>
);
};
export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => {
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({
apiKey: data.apiKey,
});
await revalidatePathActionAsync("/manage/tools/api");
onSuccess(data) {
setNewApiKey(data.apiKey);
onCreated();
},
});
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
@@ -38,21 +71,19 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
});
const t = useI18n("management.page.tool.api.tab.apiKey");
const tCommon = useI18n("common");
const handleDelete = useCallback(
(id: string) => {
openConfirmModal({
title: t("modal.delete.title"),
children: t("modal.delete.text"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateDeleteAsync({ apiKeyId: id });
},
});
async (id: string) => {
await mutateDeleteAsync({ apiKeyId: id });
},
[t, openConfirmModal, mutateDeleteAsync],
[mutateDeleteAsync],
);
const handleDismissNewApiKey = useCallback(async () => {
setNewApiKey(null);
await revalidatePathActionAsync("/manage/tools/api");
}, []);
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
const columns = useMemo<MRT_ColumnDef<ApiKey>[]>(
() => [
{
accessorKey: "id",
@@ -61,36 +92,32 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
{
accessorKey: "user",
header: t("table.header.createdBy"),
Cell: ({ row }) => (
<Group gap={"xs"}>
<UserAvatar user={row.original.user} size={"sm"} />
<Text>{row.original.user.name}</Text>
</Group>
),
Cell: ApiKeyCreatedByCell,
},
{
header: t("table.header.actions"),
Cell: ({ row }) => (
<Group gap="xs">
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
<IconTrash size="1rem" />
</ActionIcon>
</Group>
),
Cell: ApiKeyActionsCell,
},
],
[t, handleDelete, isPendingDelete],
[t],
);
const table = useMantineReactTable({
columns,
data: apiKeys,
meta: {
handleDelete,
confirmLabel: tCommon("action.confirm"),
deleteTitle: t("modal.delete.title"),
isPendingDelete,
},
renderTopToolbarCustomActions: () => (
<Button
onClick={() => {
mutateCreate();
}}
loading={isPendingCreate}
disabled={newApiKey !== null}
>
{t("button.createApiToken")}
</Button>
@@ -104,6 +131,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
return (
<Stack>
<Title>{t("title")}</Title>
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
<MantineReactTable table={table} />
</Stack>
);
@@ -5,6 +5,8 @@ import type { ReactNode } from "react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { ApiKeysManagement } from "./api-keys";
import { McpInstructions } from "./mcp-instructions";
export interface McpToolGroup {
@@ -17,9 +19,8 @@ interface ApiPageTabsProps {
apiKeyLabel: string;
mcpLabel: string;
documentationPanel: ReactNode;
authenticationPanel: ReactNode;
apiKeys: RouterOutputs["apiKeys"]["getAll"];
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
}
@@ -28,12 +29,12 @@ export function ApiPageTabs({
apiKeyLabel,
mcpLabel,
documentationPanel,
authenticationPanel,
apiKeys,
baseUrl,
hasApiKeys,
toolGroups,
}: ApiPageTabsProps) {
const [activeTab, setActiveTab] = useState<string | null>("documentation");
const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
@@ -48,10 +49,17 @@ export function ApiPageTabs({
{mcpLabel}
</TabsTab>
</TabsList>
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
<TabsPanel value="authentication">
<ApiKeysManagement apiKeys={apiKeys} onCreated={() => setHasApiKeys(true)} />
</TabsPanel>
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
<TabsPanel value="mcp">
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
<McpInstructions
baseUrl={baseUrl}
hasApiKeys={hasApiKeys}
toolGroups={toolGroups}
onApiKeyCreated={() => setHasApiKeys(true)}
/>
</TabsPanel>
</Tabs>
);
@@ -1,34 +0,0 @@
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
const t = useI18n("management.page.tool.api.modal.createApiToken");
const [visible, { toggle }] = useDisclosure(false);
return (
<Stack>
<Text>{t("description")}</Text>
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
<CopyButton value={innerProps.apiKey}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.tool.api.modal.createApiToken.title");
},
});
@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import {
Accordion,
AccordionControl,
@@ -36,12 +37,11 @@ import {
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { CopyApiKeyModal } from "./copy-api-key-modal";
import type { McpToolGroup } from "./api-page-tabs";
import classes from "./mcp-tools-accordion.module.css";
import { NewApiKeyAlert } from "./new-api-key-alert";
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
query: { color: "blue", method: "GET" },
@@ -52,18 +52,23 @@ interface McpInstructionsProps {
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
onApiKeyCreated: () => void;
}
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) {
const t = useI18n("management.page.tool.api.tab.mcp");
const tCommon = useI18n("common");
const { openModal } = useModalAction(CopyApiKeyModal);
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({ apiKey: data.apiKey });
await revalidatePathActionAsync("/manage/tools/api");
onSuccess(data) {
setNewApiKey(data.apiKey);
onApiKeyCreated();
},
});
const handleDismissNewApiKey = useCallback(async () => {
setNewApiKey(null);
await revalidatePathActionAsync("/manage/tools/api");
}, []);
const mcpUrl = `${baseUrl}/api/mcp`;
const streamableHttpConfig = JSON.stringify(
@@ -114,7 +119,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
<Text size="sm">{t("description")}</Text>
</Alert>
{!hasApiKeys && (
{!hasApiKeys && !newApiKey && (
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
<Group justify="space-between" align="center">
<Text size="sm">{t("noApiKey")}</Text>
@@ -131,6 +136,8 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
</Alert>
)}
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
<div>
<Title order={5} mb="xs">
<Group gap="xs">
@@ -305,7 +312,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
<Stack gap={6}>
{group.tools.map((tool) => (
<div key={tool.name} className={classes.toolRow}>
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
<Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light">
{toolTypeDisplay[tool.type].method}
</Badge>
<div>
@@ -0,0 +1,50 @@
"use client";
import { Alert, Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface NewApiKeyAlertProps {
apiKey: string;
onDismiss: () => void | Promise<void>;
}
export const NewApiKeyAlert = ({ apiKey, onDismiss }: NewApiKeyAlertProps) => {
const t = useI18n("management.page.tool.api.modal.createApiToken");
const tCommon = useI18n("common");
const [visible, { toggle }] = useDisclosure(false);
return (
<Alert color="yellow" title={t("title")}>
<Stack gap="sm">
<Text size="sm">{t("description")}</Text>
<PasswordInput value={apiKey} visible={visible} onVisibilityChange={toggle} aria-label={t("title")} readOnly />
<Stack gap="xs" align="stretch">
<CopyButton value={apiKey} timeout={2_000}>
{({ copied, copy }) => {
let color: "green" | undefined;
let icon = <IconCopy size={16} />;
let label = tCommon("action.copy");
if (copied) {
color = "green";
icon = <IconCheck size={16} />;
label = tCommon("action.copied");
}
return (
<Button onClick={copy} variant="default" color={color} leftSection={icon}>
{label}
</Button>
);
}}
</CopyButton>
<Button onClick={onDismiss} variant="subtle">
{tCommon("action.close")}
</Button>
</Stack>
</Stack>
</Alert>
);
};
@@ -11,7 +11,6 @@ import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
import { ScalarApiReference } from "./components/scalar-api-reference";
@@ -87,9 +86,8 @@ export default async function ApiPage() {
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
apiKeys={apiKeys}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
@@ -1,23 +1,35 @@
"use client";
import { Button } from "@mantine/core";
import { Button, Card, Collapse, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
export const AddCertificateButton = () => {
const { openModal } = useModalAction(AddCertificateModal);
import { CertificateUploadForm } from "./certificate-upload-form";
export const AddCertificateSection = () => {
const [opened, { close, toggle }] = useDisclosure(false);
const t = useI18n("certificate");
const handleClick = () => {
openModal({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
};
return <Button onClick={handleClick}>{t("action.create.label")}</Button>;
return (
<Stack gap="sm">
<Button onClick={toggle} variant="default" aria-expanded={opened}>
{t("action.create.label")}
</Button>
<Collapse expanded={opened}>
<Card withBorder>
<CertificateUploadForm
onCancel={close}
onSuccess={async () => {
await revalidatePathActionAsync("/manage/tools/certificates");
close();
}}
/>
</Card>
</Collapse>
</Stack>
);
};
export { CertificateUploadForm };
@@ -0,0 +1,110 @@
"use client";
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { checkCertificateFile } from "@homarr/validation/certificates";
interface CertificateUploadFormProps {
embedded?: boolean;
onSuccess?: () => MaybePromise<void>;
onCancel?: () => void;
}
export const CertificateUploadForm = ({ embedded = false, onSuccess, onCancel }: CertificateUploadFormProps) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.file().check(checkCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync, isPending } = clientApi.certificates.addCertificate.useMutation();
const handleSubmit = async () => {
const validation = form.validate();
if (validation.hasErrors || !form.values.file) return;
const formData = new FormData();
formData.set("file", form.values.file);
await mutateAsync(formData, {
onSuccess: async () => {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
form.reset();
await onSuccess?.();
},
onError: () => {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
};
const handleCancel = () => {
form.reset();
onCancel?.();
};
const submit = () => {
void handleSubmit().catch(() => undefined);
};
let submitButtonType: "button" | "submit" = "submit";
let handleSubmitClick: (() => void) | undefined;
if (embedded) {
submitButtonType = "button";
handleSubmitClick = submit;
}
const content = (
<>
<FileInput
label={t("certificate.action.create.label")}
leftSection={<IconCertificate size={16} />}
{...form.getInputProps("file")}
/>
<Group justify="end">
{onCancel && (
<Button onClick={handleCancel} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
)}
<Button type={submitButtonType} onClick={handleSubmitClick} loading={isPending}>
{t("common.action.add")}
</Button>
</Group>
</>
);
if (embedded) {
return <Stack>{content}</Stack>;
}
return (
<Stack
component="form"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
{content}
</Stack>
);
};
@@ -1,57 +1,56 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface RemoveCertificateProps {
fileName: string;
}
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({
const { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
const t = useI18n("certificate");
const tCommon = useI18n("common");
const handleClick = () => {
openConfirmModal({
title: t("action.remove.label"),
children: t("action.remove.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{ fileName },
{
onSuccess() {
showSuccessNotification({
title: t("action.remove.notification.success.title"),
message: t("action.remove.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.remove.notification.error.title"),
message: t("action.remove.notification.error.message"),
});
},
},
);
const handleRemove = () =>
mutateAsync(
{ fileName },
{
onSuccess() {
showSuccessNotification({
title: t("action.remove.notification.success.title"),
message: t("action.remove.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.remove.notification.error.title"),
message: t("action.remove.notification.error.message"),
});
},
},
});
};
);
return (
<ActionIcon onClick={handleClick} color="red" variant="subtle">
<InlineConfirmActionIcon
onConfirm={handleRemove}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
color="red"
variant="subtle"
aria-label={t("action.remove.label")}
>
<IconTrash color="red" size={16} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -1,13 +1,12 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface RemoveHostnameActionIconProps {
hostname: string;
@@ -15,41 +14,41 @@ interface RemoveHostnameActionIconProps {
}
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({
const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
},
});
const { openConfirmModal } = useConfirmModal();
const t = useI18n("certificate");
const tCommon = useI18n("common");
const handleRemove = () => {
openConfirmModal({
title: t("action.removeHostname.label"),
children: t("action.removeHostname.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(input, {
onSuccess() {
showSuccessNotification({
title: t("action.removeHostname.notification.success.title"),
message: t("action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.removeHostname.notification.error.title"),
message: t("action.removeHostname.notification.error.message"),
});
},
const handleRemove = () =>
mutateAsync(input, {
onSuccess() {
showSuccessNotification({
title: t("action.removeHostname.notification.success.title"),
message: t("action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.removeHostname.notification.error.title"),
message: t("action.removeHostname.notification.error.message"),
});
},
});
};
return (
<ActionIcon color="red" variant="subtle" onClick={handleRemove}>
<InlineConfirmActionIcon
onConfirm={handleRemove}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
color="red"
variant="subtle"
aria-label={t("action.removeHostname.label")}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -13,7 +13,7 @@ import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AddCertificateButton } from "./_components/add-certificate";
import { AddCertificateSection } from "./_components/add-certificate";
import { RemoveCertificate } from "./_components/remove-certificate";
interface CertificatesPageProps {
@@ -69,10 +69,11 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
<Button variant="default" component={Link} href="/manage/tools/certificates/hostnames">
{t("page.list.toHostnames")}
</Button>
<AddCertificateButton />
</Group>
</Group>
<AddCertificateSection />
{x509Certificates.length === 0 && (
<NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} />
)}
@@ -2,20 +2,18 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface DeleteUserButtonProps {
user: RouterOutputs["user"]["getById"];
}
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
const tUser = useI18n("user");
const tCommon = useI18n("common");
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
@@ -23,27 +21,14 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
},
});
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(
() =>
openConfirmModal({
title: tUser("action.delete.label"),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
children: tUser("action.delete.confirm", { username: user.name! }),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateUserDeletionAsync({
userId: user.id,
});
},
}),
[user, mutateUserDeletionAsync, openConfirmModal, tUser],
() => mutateUserDeletionAsync({ userId: user.id }),
[mutateUserDeletionAsync, user.id],
);
return (
<Button onClick={handleDelete} variant="subtle" color="red">
<InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red">
{tCommon("action.delete")}
</Button>
</InlineConfirmButton>
);
};
@@ -8,10 +8,9 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui";
import { useIsMobile } from "@homarr/ui/hooks";
interface UserProfileAvatarForm {
@@ -19,7 +18,7 @@ interface UserProfileAvatarForm {
}
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
const { mutate } = clientApi.user.setProfileImage.useMutation({
const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
@@ -27,7 +26,6 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
});
const [opened, { toggle }] = useDisclosure(false);
const isMobile = useIsMobile();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tManageAvatar = useI18n("user.action.manageAvatar");
@@ -68,32 +66,28 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
[mutate, user.id, tManageAvatar],
);
const handleRemoveAvatar = useCallback(() => {
openConfirmModal({
title: tManageAvatar("removeImage.label"),
children: tManageAvatar("removeImage.confirm"),
onConfirm() {
mutate(
{
userId: user.id,
image: null,
const handleRemoveAvatar = useCallback(
() =>
mutateAsync(
{
userId: user.id,
image: null,
},
{
onSuccess() {
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
},
{
onSuccess() {
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
},
);
},
});
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
},
),
[mutateAsync, user.id, tManageAvatar],
);
return (
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
@@ -124,9 +118,15 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
)}
</FileButton>
{user.image && (
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
<InlineConfirmMenuItem
color="red"
onConfirm={handleRemoveAvatar}
confirmLabel={tCommon("action.confirm")}
disabled={isPending}
leftSection={<IconPhotoX size={16} stroke={1.5} />}
>
{tManageAvatar("removeImage.label")}
</Menu.Item>
</InlineConfirmMenuItem>
)}
</Menu.Dropdown>
</Menu>
@@ -12,7 +12,6 @@ import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
@@ -20,7 +19,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { optionalEmailSchema } from "@homarr/validation/email";
import { userPasswordSchema } from "@homarr/validation/user";
import { GroupSelectModal } from "~/components/access/group-select-modal";
import { GroupSelect } from "~/components/access/group-select";
import { StepperNavigationComponent } from "./stepper-navigation";
interface GroupWithPermissions {
@@ -224,23 +223,6 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
const tCommon = useI18n("common");
const tPermission = useI18n("permission");
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
const { openModal } = useModalAction(GroupSelectModal);
const handleAddClick = () => {
openModal({
presentGroupIds: groups.map((group) => group.id),
withPermissions: true,
onSelect({ id, name, permissions }) {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
},
});
};
const handleGroupRemove = (id: string) => {
filter((group) => group.id !== id);
removeGroup(id);
@@ -256,14 +238,23 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
{tCreate("step.groups.description", { everyoneGroup })}
</Text>
</Stack>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
onClick={handleAddClick}
>
{tCommon("action.add")}
</Button>
<GroupSelect
presentGroupIds={groups.map((group) => group.id)}
withPermissions
onSelect={({ id, name, permissions }) => {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
}}
triggerProps={{
variant: "subtle",
color: "gray",
leftSection: <IconPlus size={16} stroke={1.5} />,
}}
/>
</Group>
<Table>
<Table.Thead>
@@ -2,13 +2,13 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button, useMatches } from "@mantine/core";
import { useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface DeleteGroupProps {
group: {
@@ -20,46 +20,38 @@ interface DeleteGroupProps {
export const DeleteGroup = ({ group }: DeleteGroupProps) => {
const router = useRouter();
const { mutateAsync } = clientApi.group.deleteGroup.useMutation();
const { openConfirmModal } = useConfirmModal();
const tDelete = useI18n("group.action.delete");
const tCommon = useI18n("common");
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tDelete("label"),
children: tDelete("confirm", {
name: group.name,
}),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{
id: group.id,
const handleDeletion = useCallback(
() =>
mutateAsync(
{
id: group.id,
},
{
onSuccess() {
void revalidatePathActionAsync("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
},
{
onSuccess() {
void revalidatePathActionAsync("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
},
);
},
});
}, [tDelete, tCommon, openConfirmModal, group.id, group.name, mutateAsync, router]);
},
),
[tDelete, tCommon, group.id, group.name, mutateAsync, router],
);
const fullWidth = useMatches({
xs: true,
@@ -68,8 +60,14 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
});
return (
<Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}>
<InlineConfirmButton
variant="subtle"
color="red"
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
fullWidth={fullWidth}
>
{tDelete("label")}
</Button>
</InlineConfirmButton>
);
};
@@ -1,15 +1,14 @@
"use client";
import { useCallback, useState } from "react";
import { Button, useMatches } from "@mantine/core";
import { useState } from "react";
import { Button, Group, Stack, Text, useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/components/access/user-select-modal";
import { UserSelect } from "~/components/access/user-select";
interface TransferGroupOwnershipProps {
group: {
@@ -19,75 +18,87 @@ interface TransferGroupOwnershipProps {
};
}
interface SelectedOwner {
id: string;
name: string;
}
export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => {
const tTransfer = useI18n("group.action.transfer");
const tCommon = useI18n("common");
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
const { openModal } = useModalAction(UserSelectModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.group.transferOwnership.useMutation({
const [selectedOwner, setSelectedOwner] = useState<SelectedOwner>();
const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({
async onSuccess() {
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
},
});
const handleTransfer = useCallback(() => {
openModal(
const handleTransfer = async () => {
if (!selectedOwner) return;
await mutateAsync(
{
confirmLabel: tCommon("action.continue"),
presentUserIds: innerOwnerId ? [innerOwnerId] : [],
onSelect: ({ id, name }) => {
openConfirmModal({
title: tTransfer("label"),
children: tTransfer("confirm", {
name: group.name,
username: name,
groupId: group.id,
userId: selectedOwner.id,
},
{
onSuccess() {
setInnerOwnerId(selectedOwner.id);
showSuccessNotification({
title: tCommon("notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: selectedOwner.name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync(
{
groupId: group.id,
userId: id,
},
{
onSuccess() {
setInnerOwnerId(id);
showSuccessNotification({
title: tCommon("notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
},
);
},
});
setSelectedOwner(undefined);
},
onError() {
showErrorNotification({
title: tCommon("notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
},
{
title: tTransfer("label"),
},
);
}, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tCommon, tTransfer]);
};
const fullWidth = useMatches({
xs: true,
sm: true,
md: false,
});
const presentUserIds: string[] = [];
if (innerOwnerId) presentUserIds.push(innerOwnerId);
if (!selectedOwner) {
return (
<UserSelect
presentUserIds={presentUserIds}
onSelect={setSelectedOwner}
triggerLabel={tTransfer("label")}
triggerProps={{ variant: "subtle", color: "red", fullWidth }}
/>
);
}
return (
<Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}>
{tTransfer("label")}
</Button>
<Stack gap="xs" maw={420}>
<Text size="sm">
{tTransfer("confirm", {
name: group.name,
username: selectedOwner.name,
})}
</Text>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={() => setSelectedOwner(undefined)} disabled={isPending}>
{tCommon("action.cancel")}
</Button>
<Button color="red" onClick={() => void handleTransfer()} loading={isPending}>
{tCommon("action.confirm")}
</Button>
</Group>
</Stack>
);
};
@@ -5,11 +5,9 @@ import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/components/access/user-select-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { UserSelect } from "~/components/access/user-select";
interface AddGroupMemberProps {
groupId: string;
@@ -20,27 +18,23 @@ interface AddGroupMemberProps {
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
const tMembersAdd = useI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const { openModal } = useModalAction(UserSelectModal);
const handleAddMember = useCallback(
async ({ id }: { id: string }) => {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
[groupId, mutateAsync],
);
const handleAddMember = useCallback(() => {
openModal(
{
// eslint-disable-next-line no-restricted-syntax
async onSelect({ id }) {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
allowedProviders,
},
{
title: tMembersAdd("label"),
},
);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
return (
<UserSelect
presentUserIds={presentUserIds}
allowedProviders={allowedProviders}
onSelect={handleAddMember}
triggerLabel={tMembersAdd("label")}
/>
);
};
@@ -1,12 +1,11 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface RemoveGroupMemberProps {
groupId: string;
@@ -15,30 +14,25 @@ interface RemoveGroupMemberProps {
export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => {
const tCommon = useI18n("common");
const tRemoveMember = useI18n("group.action.removeMember");
const { mutateAsync } = clientApi.group.removeMember.useMutation();
const { openConfirmModal } = useConfirmModal();
const handleRemove = useCallback(() => {
openConfirmModal({
title: tRemoveMember("label"),
children: tRemoveMember("confirm", {
user: user.name ?? "",
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({
groupId,
userId: user.id,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
const handleRemove = useCallback(async () => {
await mutateAsync({
groupId,
userId: user.id,
});
}, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]);
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
}, [mutateAsync, groupId, user.id]);
return (
<Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}>
<InlineConfirmButton
variant="subtle"
color="red.9"
size="compact-sm"
confirmLabel={tCommon("action.confirm")}
onConfirm={handleRemove}
>
{tCommon("action.remove")}
</Button>
</InlineConfirmButton>
);
};
@@ -1,15 +1,14 @@
"use client";
import { useMemo, useState } from "react";
import { TextInput } from "@mantine/core";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { GroupCreateForm } from "./_components/group-create-form";
import { GroupsTable } from "./_groups-table";
interface GroupsListProps {
@@ -18,6 +17,10 @@ interface GroupsListProps {
export const GroupsList = ({ groups }: GroupsListProps) => {
const [search, setSearch] = useState("");
const router = useRouter();
const searchParams = useSearchParams();
const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
const initialGroupIds = useMemo(
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
[groups],
@@ -31,8 +34,46 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
);
const tGroup = useI18n("group");
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsCreateOpen(true);
}, [searchParams]);
const closeCreate = () => {
setIsCreateOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/users/groups", { scroll: false });
};
const toggleCreate = () => {
if (isCreateOpen) {
closeCreate();
return;
}
setFormKey((value) => value + 1);
setIsCreateOpen(true);
};
return (
<>
<Stack>
<Group justify="end">
<Button onClick={toggleCreate}>{tGroup("action.create.label")}</Button>
</Group>
<Collapse expanded={isCreateOpen}>
<Card withBorder>
<GroupCreateForm
key={formKey}
onCancel={closeCreate}
onCreated={() => {
closeCreate();
router.refresh();
}}
/>
</Card>
</Collapse>
<TextInput
leftSection={<IconSearch size={20} stroke={1.5} />}
value={search}
@@ -40,13 +81,6 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
placeholder={`${tGroup("search")}...`}
/>
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
</>
</Stack>
);
};
export const AddGroupButton = () => {
const tGroup = useI18n("group");
const { openModal } = useModalAction(AddGroupModal);
return <MobileAffixButton onClick={() => openModal()}>{tGroup("action.create.label")}</MobileAffixButton>;
};
@@ -0,0 +1,61 @@
"use client";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
interface GroupCreateFormProps {
onCancel: () => void;
onCreated: () => void;
}
export const GroupCreateForm = ({ onCancel, onCreated }: GroupCreateFormProps) => {
const tGroup = useI18n("group");
const tCommon = useI18n("common");
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
const { mutateAsync, isPending } = clientApi.group.createGroup.useMutation();
const handleSubmit = form.onSubmit(async (values) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.create.success"),
message: tGroup("action.create.notification.success.message"),
});
onCreated();
},
onError() {
showErrorNotification({
title: tCommon("notification.create.error"),
message: tGroup("action.create.notification.error.message"),
});
},
});
});
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} />
<Group justify="end">
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
<Button loading={isPending} type="submit">
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -9,7 +9,7 @@ import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AddGroupButton, GroupsList } from "./_client";
import { GroupsList } from "./_client";
import classes from "./groups.module.css";
export default async function GroupsListPage() {
@@ -26,7 +26,7 @@ export default async function GroupsListPage() {
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
return (
<ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
<ManagePageLayout title={tCommon("entity.groups")}>
<Stack>
{dbEveryoneGroup && (
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
@@ -2,18 +2,15 @@
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
export const InviteCreateButton = () => {
export const InviteCreateButton = ({ onClick }: { onClick: () => void }) => {
const t = useI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
return (
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}>
{t("action.new.title")}
</Button>
);
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Anchor, Button, CopyButton, Group, Mark, Stack, Text } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
type FormType = {
expirationDate: string;
};
type CreatedInvite = RouterOutputs["invite"]["createInvite"];
interface InviteCreateFormProps {
onClose: () => void;
}
export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
const tInvite = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const [createdInvite, setCreatedInvite] = useState<CreatedInvite>();
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
const minDate = dayjs().add(1, "hour").toDate();
const maxDate = dayjs().add(6, "months").toDate();
const form = useForm<FormType>({
initialValues: {
expirationDate: dayjs().add(4, "hours").toDate().toISOString(),
},
});
if (createdInvite) {
return <CreatedInviteDetails invite={createdInvite} onClose={onClose} />;
}
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(
{
expirationDate: new Date(values.expirationDate),
},
{
onSuccess: (result) => {
void utils.invite.getAll.invalidate();
setCreatedInvite(result);
},
},
);
})}
>
<Stack>
<Text>{tInvite("action.new.description")}</Text>
<DateTimePicker
popoverProps={{ withinPortal: true }}
minDate={minDate}
maxDate={maxDate}
withAsterisk
valueFormat="DD MMM YYYY HH:mm"
label={tInvite("field.expirationDate.label")}
variant="filled"
{...form.getInputProps("expirationDate")}
/>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onClose}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
const CreatedInviteDetails = ({ invite, onClose }: { invite: CreatedInvite; onClose: () => void }) => {
const tInvite = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const inviteUrl = createInviteUrl(invite);
return (
<Stack component="output" aria-live="polite">
<Text>
{tInvite.rich("action.copy.description", {
b: bold,
})}
</Text>
<Stack gap="xs">
<Text fw="bold">{tInvite("action.copy.link")}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
<Anchor href={inviteUrl} target="_blank" rel="noreferrer">
{inviteUrl}
</Anchor>
</Mark>
<Text fw="bold">{invariantTechnicalLabels.id}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
{invite.id}
</Mark>
<Text fw="bold">{tInvite("field.token.label")}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
{invite.token}
</Mark>
</Stack>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onClose}>
{tCommon("action.close")}
</Button>
<CopyButton value={inviteUrl}>
{({ copy }) => (
<Button
onClick={() => {
copy();
onClose();
}}
>
{tInvite("action.copy.button")}
</Button>
)}
</CopyButton>
</Group>
</Stack>
);
};
const bold = (children: ReactNode) => <b>{children}</b>;
const createInviteUrl = ({ id, token }: CreatedInvite) => {
const path = `/auth/invite/${id}?token=${token}`;
return new URL(path, window.location.origin).toString();
};
@@ -1,7 +1,8 @@
"use client";
import { useCallback, useMemo } from "react";
import { ActionIcon } from "@mantine/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, Collapse, Group, Stack } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -11,9 +12,11 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { InviteCreateButton } from "./invite-create-button";
import { InviteCreateForm } from "./invite-create-form";
dayjs.extend(relativeTime);
@@ -23,6 +26,10 @@ interface InviteListComponentProps {
export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => {
const t = useI18n("management.page.user.invite");
const router = useRouter();
const searchParams = useSearchParams();
const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
initialData: initialInvites,
refetchOnMount: false,
@@ -73,28 +80,65 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
},
});
return <MantineReactTable table={table} />;
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsCreateOpen(true);
}, [searchParams]);
const closeCreate = () => {
setIsCreateOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/users/invites", { scroll: false });
};
const toggleCreate = () => {
if (isCreateOpen) {
closeCreate();
return;
}
setFormKey((value) => value + 1);
setIsCreateOpen(true);
};
return (
<Stack>
<Group justify="end">
<InviteCreateButton onClick={toggleCreate} />
</Group>
<Collapse expanded={isCreateOpen}>
<Card withBorder>
<InviteCreateForm key={formKey} onClose={closeCreate} />
</Card>
</Collapse>
<MantineReactTable table={table} />
</Stack>
);
};
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
const t = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(() => {
openConfirmModal({
title: t("action.delete.title"),
children: t("action.delete.description"),
onConfirm: () => {
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
},
});
}, [openConfirmModal, row.original.id, mutate, utils, t]);
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
}, [row.original.id, mutate, utils]);
return (
<ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}>
<InlineConfirmActionIcon
variant="subtle"
color="red"
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
aria-label={t("action.delete.title")}
>
<IconTrash color="red" size={20} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -5,9 +5,7 @@ import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getI18n } from "@homarr/translation/server";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { InviteCreateButton } from "./_components/invite-create-button";
import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() {
@@ -24,15 +22,7 @@ export default async function InvitesOverviewPage() {
const initialInvites = await api.invite.getAll();
return (
<ManagePageLayout
title={t("title")}
primaryAction={
<ManageMobilePrimaryAction>
<InviteCreateButton />
</ManageMobilePrimaryAction>
}
floatingPrimaryAction
>
<ManagePageLayout title={t("title")}>
<InviteListComponent initialInvites={initialInvites} />
</ManagePageLayout>
);
@@ -20,8 +20,8 @@ import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal";
import type { Dimensions } from "./_dimension-popover";
import { PreviewDimensionsPopover } from "./_dimension-popover";
interface WidgetPreviewPageContentProps {
kind: WidgetKind;
@@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const t = useI18n();
const utils = clientApi.useUtils();
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false);
@@ -98,13 +97,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
});
}, [editMode, t]);
const openDimensionsModal = useCallback(() => {
openPreviewDimensionsModal({
dimensions,
setDimensions,
});
}, [dimensions, openPreviewDimensionsModal]);
const updateOptions = useCallback(
({ newOptions }: { newOptions: Record<string, unknown> }) =>
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
@@ -162,15 +154,21 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
</ActionIcon>
</Affix>
<Affix bottom={12} right={72 + 120}>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={openDimensionsModal}
aria-label={t("widgetPreview.dimensions.title")}
>
<IconDimensions size={24} />
</ActionIcon>
<PreviewDimensionsPopover
dimensions={dimensions}
setDimensions={setDimensions}
target={(onClick) => (
<ActionIcon
size={48}
variant="default"
radius="xl"
aria-label={t("widgetPreview.dimensions.title")}
onClick={onClick}
>
<IconDimensions size={24} />
</ActionIcon>
)}
/>
</Affix>
</>
);
@@ -1,50 +0,0 @@
"use client";
import { Button, Group, InputWrapper, Slider, Stack } from "@mantine/core";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
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();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.moveResize.field.width.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
</InputWrapper>
<InputWrapper label={t("item.moveResize.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;
height: number;
}
@@ -0,0 +1,78 @@
"use client";
import type { ReactNode } from "react";
import { Button, Group, InputWrapper, Popover, Slider, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
interface PreviewDimensionsPopoverProps {
target: (onClick: () => void) => ReactNode;
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsPopover = ({ target, dimensions, setDimensions }: PreviewDimensionsPopoverProps) => {
const t = useI18n();
const [opened, { close, open }] = useDisclosure(false);
const form = useForm({
initialValues: dimensions,
});
const handleSubmit = (values: Dimensions) => {
setDimensions(values);
close();
};
const handleClose = () => {
form.setValues(dimensions);
close();
};
const handleToggle = () => {
if (opened) {
handleClose();
return;
}
open();
};
return (
<Popover
opened={opened}
onChange={(nextOpened) => {
if (nextOpened) open();
if (!nextOpened) handleClose();
}}
position="top-end"
width="min(320px, calc(100vw - 2rem))"
withArrow
>
<Popover.Target>{target(handleToggle)}</Popover.Target>
<Popover.Dropdown>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.moveResize.field.width.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
</InputWrapper>
<InputWrapper label={t("item.moveResize.field.height.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={handleClose}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
</Popover.Dropdown>
</Popover>
);
};
export interface Dimensions {
width: number;
height: number;
}
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core";
import type { ButtonProps } from "@mantine/core";
export interface AccessPickerOption<T> {
value: string;
label: string;
keywords?: readonly string[];
content: ReactNode;
item: T;
}
export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">;
interface AccessPickerProps<T> {
label: string;
searchPlaceholder: string;
emptyMessage: string;
triggerLabel: string;
options: AccessPickerOption<T>[];
isPending: boolean;
onSelect: (option: AccessPickerOption<T>) => void | Promise<void>;
triggerProps?: AccessPickerTriggerProps;
}
export const AccessPicker = <T,>({
label,
searchPlaceholder,
emptyMessage,
triggerLabel,
options,
isPending,
onSelect,
triggerProps,
}: AccessPickerProps<T>) => {
const [search, setSearch] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
requestAnimationFrame(() => combobox.focusTarget());
},
onDropdownOpen: () => {
combobox.selectFirstOption();
requestAnimationFrame(() => combobox.focusSearchInput());
},
});
const normalizedSearch = search.trim().toLowerCase();
const filteredOptions = options.filter((option) => {
if (!normalizedSearch) return true;
const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase();
return searchableText.includes(normalizedSearch);
});
const visibleOptions = filteredOptions.slice(0, 5);
const handleSelect = (value: string) => {
const option = options.find((item) => item.value === value);
if (!option || isSubmitting) return;
combobox.closeDropdown();
setIsSubmitting(true);
const submit = async () => {
try {
await onSelect(option);
} finally {
setIsSubmitting(false);
}
};
void submit().catch(() => undefined);
};
return (
<Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal>
<Combobox.Target targetType="button">
<Button
{...triggerProps}
onClick={() => combobox.toggleDropdown()}
rightSection={
<Group gap={4} wrap="nowrap">
{triggerProps?.rightSection}
<Combobox.Chevron />
</Group>
}
loading={isSubmitting}
disabled={isSubmitting || triggerProps?.disabled}
>
{triggerLabel}
</Button>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
}}
placeholder={searchPlaceholder}
aria-label={label}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Combobox.Options>
{isPending && (
<Center p="md">
<LoaderPlaceholder />
</Center>
)}
{!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>}
{!isPending &&
visibleOptions.map((option) => (
<Combobox.Option value={option.value} key={option.value}>
{option.content}
</Combobox.Option>
))}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Dropdown>
</Combobox>
);
};
const LoaderPlaceholder = () => <Loader size="sm" />;
@@ -2,7 +2,6 @@ import { useState } from "react";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -11,7 +10,7 @@ import { AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal";
import { GroupSelect } from "./group-select";
import type { FormProps } from "./user-access-form";
export const GroupAccessForm = <TPermission extends string>({
@@ -24,8 +23,6 @@ export const GroupAccessForm = <TPermission extends string>({
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
);
const { openModal } = useModalAction(GroupSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -36,23 +33,6 @@ export const GroupAccessForm = <TPermission extends string>({
},
});
const handleAddUser = () => {
openModal({
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
<FormProvider form={form}>
@@ -79,9 +59,21 @@ export const GroupAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<GroupSelect
presentGroupIds={form.values.items.map(({ principalId: id }) => id)}
onSelect={(group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveGroup")}
</Button>
@@ -1,73 +0,0 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={tGroup("label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={tGroup("notFound")}
limit={5}
data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.groupSelect.title"),
});
@@ -0,0 +1,66 @@
"use client";
import { Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableGroup = RouterOutputs["group"]["selectable"][number];
interface GroupSelectProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const GroupSelect = ({
withPermissions,
presentGroupIds,
onSelect,
triggerLabel,
triggerProps,
}: GroupSelectProps) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions });
const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id));
const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({
value: group.id,
label: group.name,
keywords: getGroupPermissions(group) ?? [],
item: group,
content: <Text size="sm">{group.name}</Text>,
}));
const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) =>
onSelect({
id: group.id,
name: group.name,
permissions: getGroupPermissions(group),
});
return (
<AccessPicker
label={tGroup("label")}
searchPlaceholder={tGroup("label")}
emptyMessage={tGroup("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
const getGroupPermissions = (group: SelectableGroup) => {
if ("permissions" in group) return group.permissions as GroupPermissionKey[];
return undefined;
};
@@ -2,7 +2,6 @@ import { useState } from "react";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
@@ -11,7 +10,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType, HandleCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal";
import { UserSelect } from "./user-select";
export interface FormProps<TPermission extends string> {
entity: {
@@ -41,8 +40,6 @@ export const UsersAccessForm = <TPermission extends string>({
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -52,25 +49,8 @@ export const UsersAccessForm = <TPermission extends string>({
})),
},
});
const handleAddUser = () => {
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
openModal({
presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
if (entity.ownerId) presentUserIds.push(entity.ownerId);
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
@@ -101,9 +81,21 @@ export const UsersAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<UserSelect
presentUserIds={presentUserIds}
onSelect={(user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveUser")}
</Button>
@@ -1,103 +0,0 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({
providers: innerProps.allowedProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
email: currentUser.email ?? null,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={tUser("label")}
searchable
clearable
leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
}
nothingFoundMessage={tUser("notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
};
@@ -0,0 +1,75 @@
"use client";
import { Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableUser = RouterOutputs["user"]["selectable"][number];
interface UserSelectProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const UserSelect = ({
presentUserIds,
allowedProviders,
onSelect,
triggerLabel,
triggerProps,
}: UserSelectProps) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders });
const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id));
const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({
value: user.id,
label: user.name ?? "",
keywords: [user.email ?? ""],
item: user,
content: (
<Group wrap="nowrap" gap="xs">
<UserAvatar user={user} size="xs" />
<Stack gap={0}>
<Text size="sm">{user.name}</Text>
{user.email && (
<Text size="xs" c="dimmed">
{user.email}
</Text>
)}
</Stack>
</Group>
),
}));
const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) =>
onSelect({
id: user.id,
name: user.name ?? "",
image: user.image ?? "",
email: user.email ?? null,
});
return (
<AccessPicker
label={tUser("label")}
searchPlaceholder={tUser("label")}
emptyMessage={tUser("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
@@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useConfirmModal } from "@homarr/modals";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
@@ -2001,7 +2001,6 @@ const ThreadListItem = () => {
const t = useI18n("assistant");
const actionT = useI18n("common.action");
const aui = useAui();
const { openConfirmModal } = useConfirmModal();
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
@@ -2054,23 +2053,15 @@ const ThreadListItem = () => {
}
};
const deleteConversation = () => {
openConfirmModal({
title: t("deleteConversation.title"),
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
confirmProps: { color: "red" },
labels: { confirm: actionT("delete"), cancel: actionT("cancel") },
onConfirm: async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
},
});
const deleteConversation = async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
};
if (renameOpened) {
@@ -2152,16 +2143,19 @@ const ThreadListItem = () => {
>
<IconFileExport size={14} />
</ActionIcon>
<ActionIcon
<InlineConfirmActionIcon
variant="subtle"
color="red"
size="sm"
title={actionT("delete")}
aria-label={actionT("delete")}
onClick={deleteConversation}
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
confirmationAriaLabel={t("deleteConversation.title")}
confirmationChildren={<IconCheck size={14} />}
onConfirm={deleteConversation}
>
<IconTrash size={14} />
</ActionIcon>
</InlineConfirmActionIcon>
</Group>
)}
<ActionIcon
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -8,14 +8,14 @@ import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
import itemContentClasses from "./item-content.module.css";
import { useOpenItemMoveModal } from "./item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
@@ -142,11 +142,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<BoardRemoveConfirmationMenuItem
label={tItem("action.remove")}
confirmationLabel={tItem("remove.message")}
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
onConfirm={() => removeItem({ itemId: item.id })}
/>
>
{tItem("action.remove")}
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -1,70 +0,0 @@
"use client";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { boardRenameSchema } from "@homarr/validation/board";
interface InnerProps {
id: string;
previousName: string;
onSuccess?: (name: string) => void;
}
export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => {
const utils = clientApi.useUtils();
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({
name: innerProps.previousName,
});
void utils.board.getHomeBoard.invalidate();
void utils.board.getAllBoards.invalidate();
},
});
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
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={tCommon("field.name")} {...form.getInputProps("name")} data-autofocus />
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.label"),
});
type FormType = Omit<z.infer<typeof boardRenameSchema>, "id">;
@@ -1,12 +1,12 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useContainerActions } from "./container-actions";
@@ -59,11 +59,14 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Divider />
<BoardRemoveConfirmationMenuItem
label={tContainer("action.remove")}
confirmationLabel={tContainer("remove.message")}
<InlineConfirmMenuItem
color="red"
confirmLabel={tContainer("remove.message")}
leftSection={<IconTrash size={16} />}
onConfirm={() => removeContainer({ id: section.id })}
/>
>
{tContainer("action.remove")}
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { Alert, Box, Button, CloseButton, Collapse, Group, Paper, Select, Stack, Text, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
interface WorkshopReportFormProps {
client: WorkshopBackend;
submissionId: string;
opened: boolean;
onClose(): void;
}
export function WorkshopReportForm({ client, submissionId, opened, onClose }: WorkshopReportFormProps) {
const t = useI18n("workshop");
const tCommon = useI18n("common");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
const close = () => {
setCategory("other");
setExplanation("");
report.reset();
onClose();
};
const submit = () =>
report.mutate(
{ submission: submissionId, category, explanation },
{
onSuccess: () => {
showSuccessNotification({
title: t("reportSent"),
message: t("reportSentDescription"),
});
close();
},
},
);
return (
<Collapse id="workshop-report-form" expanded={opened}>
<Paper p="md">
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<Stack>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text fw={600}>{t("reportTitle")}</Text>
<Text size="sm" c="dimmed">
{t("reportExplanation")}
</Text>
</Box>
<CloseButton aria-label={tCommon("action.close")} onClick={close} />
</Group>
<Select
label={t("reportReason")}
value={category}
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
data={categories.map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
autosize
value={explanation}
onChange={(event) => setExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" color="red" loading={report.isPending} disabled={explanation.trim().length < 3}>
{t("reportSend")}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Collapse>
);
}
@@ -1,80 +0,0 @@
"use client";
import { useState } from "react";
import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
interface WorkshopReportModalProps {
client: WorkshopBackend;
submissionId: string;
opened: boolean;
onClose(): void;
}
export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) {
const t = useI18n("workshop");
const tCommon = useI18n("common");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
const close = () => {
setExplanation("");
report.reset();
onClose();
};
const submit = () =>
report.mutate(
{ submission: submissionId, category, explanation },
{
onSuccess: () => {
showSuccessNotification({
title: t("reportSent"),
message: t("reportSentDescription"),
});
close();
},
},
);
return (
<Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md">
<Stack>
<Select
label={t("reportReason")}
value={category}
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
data={categories.map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
autosize
value={explanation}
onChange={(event) => setExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{tCommon("action.cancel")}
</Button>
<Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}>
{t("reportSend")}
</Button>
</Group>
</Stack>
</Modal>
);
}
+1
View File
@@ -43,6 +43,7 @@
"lint": "turbo lint --continue",
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh",
"release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
@@ -1,3 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
@@ -6,12 +8,16 @@ import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board";
export const AddBoardModal = createModal(({ actions }) => {
interface BoardCreateFormProps {
onCancel?: () => void;
}
export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
@@ -38,7 +44,6 @@ export const AddBoardModal = createModal(({ actions }) => {
if (!boardNameStatus.canSubmit) return;
mutate(values, {
onSuccess: (result) => {
actions.closeModal();
showSuccessNotification({
title: tBoard("action.create.notification.success.title"),
message: tBoard("action.create.notification.success.message", { name: values.name }),
@@ -56,24 +61,17 @@ export const AddBoardModal = createModal(({ actions }) => {
});
})}
>
<Stack>
<Stack maw={720}>
<TextInput
label={tCommon("field.name")}
data-autofocus
withAsterisk
autoFocus
{...form.getInputProps("name")}
description={
boardNameStatus.description ? (
<span>
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
</span>
) : null
}
description={<BoardNameAvailability status={boardNameStatus} />}
/>
<InputWrapper label={tBoard("field.columnCount.label")} {...form.getInputProps("columnCount")}>
<Slider
aria-label={tBoard("field.columnCount.label")}
min={boardColumnCountSchema.minValue ?? undefined}
max={boardColumnCountSchema.maxValue ?? undefined}
step={1}
@@ -87,50 +85,71 @@ export const AddBoardModal = createModal(({ actions }) => {
{...form.getInputProps("isPublic")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
<Group justify="end" wrap="wrap">
{onCancel && (
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
)}
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("management.page.board.action.new.label"),
});
};
export const useBoardNameStatus = (name: string) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const [debouncedName] = useDebouncedValue(name, 250);
const isValidName = boardNameSchema.safeParse(name).success;
const isDebouncing = name !== debouncedName;
const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, {
enabled: boardNameSchema.safeParse(debouncedName).success,
});
let description: { label: string; icon?: TablerIcon; color?: string } | undefined;
if (debouncedName.trim() !== "") {
if (isDebouncing || isLoading) {
description = {
label: tBoard("action.create.availability.checking"),
};
} else if (boardExists !== undefined) {
if (boardExists) {
description = {
icon: IconAlertTriangle,
label: tCommon("zod.errors.custom.boardAlreadyExists"),
color: "red",
};
} else {
description = {
icon: IconCircleCheck,
label: tBoard("action.create.availability.available", { name: debouncedName }),
color: "green",
};
}
}
}
return {
canSubmit: !boardExists && !isLoading,
description:
debouncedName.trim() === ""
? undefined
: isLoading
? {
label: tBoard("action.create.availability.checking"),
}
: boardExists === undefined
? undefined
: boardExists
? {
icon: IconAlertTriangle,
label: tCommon("zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists
color: "red",
}
: {
icon: IconCircleCheck,
label: tBoard("action.create.availability.available", { name: debouncedName }),
color: "green",
},
canSubmit: isValidName && !isDebouncing && boardExists === false && !isLoading,
description,
};
};
export type BoardNameStatus = ReturnType<typeof useBoardNameStatus>;
export const BoardNameAvailability = ({ status }: { status: BoardNameStatus }) => {
if (!status.description) return null;
const Icon = status.description.icon;
return (
<Group component="output" c={status.description.color} gap="xs" align="center" aria-live="polite">
{Icon && <Icon size={16} />}
<span>{status.description.label}</span>
</Group>
);
};
+2
View File
@@ -1,6 +1,8 @@
export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./board-create/board-create-form";
export * from "./icon-picker/icon-picker";
export * from "./upload-media/upload-media";
@@ -1,2 +0,0 @@
export { AddBoardModal } from "./add-board-modal";
export { DuplicateBoardModal } from "./duplicate-board-modal";
@@ -1,75 +0,0 @@
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { checkCertificateFile } from "@homarr/validation/certificates";
interface InnerProps {
onSuccess?: () => MaybePromise<void>;
}
export const AddCertificateModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.file().check(checkCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation({
async onSuccess() {
await innerProps.onSuccess?.();
},
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
const formData = new FormData();
formData.set("file", values.file);
await mutateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
actions.closeModal();
},
onError() {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={form.submitting}>
{t("common.action.add")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("certificate.action.create.label");
},
});
@@ -1 +0,0 @@
export * from "./add-certificate-modal";
@@ -1,57 +0,0 @@
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
export const AddGroupModal = createModal<void>(({ actions }) => {
const tGroup = useI18n("group");
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.create.success"),
message: tGroup("action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.create.error"),
message: tGroup("action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput label={tCommon("field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button loading={isPending} type="submit">
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});
@@ -1 +0,0 @@
export { AddGroupModal } from "./add-group-modal";
-4
View File
@@ -1,7 +1,3 @@
export * from "./boards";
export * from "./invites";
export * from "./groups";
export * from "./search-engines";
export * from "./docker";
export * from "./apps";
export * from "./certificates";
@@ -1,2 +0,0 @@
export { InviteCopyModal } from "./invite-copy-modal";
export { InviteCreateModal } from "./invite-create-modal";
@@ -1,61 +0,0 @@
import { usePathname } from "next/navigation";
import { Button, CopyButton, Mark, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite"]>(({ actions, innerProps }) => {
const t = useI18n("management.page.user.invite");
const inviteUrl = useInviteUrl(innerProps);
return (
<Stack>
<Text>
{t.rich("action.copy.description", {
b: (children) => <b>{children}</b>,
})}
</Text>
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
<Stack gap="xs">
<Text fw="bold">{invariantTechnicalLabels.id}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.id}
</Mark>
<Text fw="bold">{t("field.token.label")}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.token}
</Mark>
</Stack>
<CopyButton value={inviteUrl}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("action.copy.button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.user.invite.action.copy.title");
},
});
const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => `/auth/invite/${id}?token=${token}`;
const useInviteUrl = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => {
const pathname = usePathname();
return window.location.href.replace(pathname, createPath({ id, token }));
};
@@ -1,81 +0,0 @@
import { Button, Group, Stack, Text } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InviteCopyModal } from "./invite-copy-modal";
dayjs.extend(relativeTime);
interface FormType {
expirationDate: string;
}
export const InviteCreateModal = createModal<void>(({ actions }) => {
const tInvite = useI18n("management.page.user.invite");
const t = useI18n();
const { openModal } = useModalAction(InviteCopyModal);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
const minDate = dayjs().add(1, "hour").toDate();
const maxDate = dayjs().add(6, "months").toDate();
const form = useForm<FormType>({
initialValues: {
expirationDate: dayjs().add(4, "hours").toDate().toISOString(),
},
});
const handleSubmit = (values: FormType) => {
mutate(
{
expirationDate: new Date(values.expirationDate),
},
{
onSuccess: (result) => {
void utils.invite.getAll.invalidate();
actions.closeModal();
openModal(result);
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Text>{tInvite("action.new.description")}</Text>
<DateTimePicker
popoverProps={{ withinPortal: true }}
minDate={minDate}
maxDate={maxDate}
withAsterisk
valueFormat="DD MMM YYYY HH:mm"
label={tInvite("field.expirationDate.label")}
variant="filled"
{...form.getInputProps("expirationDate")}
/>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.user.invite.action.new.title");
},
});
@@ -12,8 +12,6 @@ import {
import { useSession } from "@homarr/auth/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, AddGroupModal, InviteCreateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
@@ -78,16 +76,7 @@ export const globalCommandGroup = createGroup<Command>({
commandKey: "newBoard",
icon: IconCategoryPlus,
name: tOption("newBoard.label"),
useInteraction() {
const { openModal } = useModalAction(AddBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
useInteraction: interaction.link(() => ({ href: "/manage/boards?create=true" })),
hidden: !session?.user.permissions.includes("board-create"),
},
{
@@ -115,32 +104,14 @@ export const globalCommandGroup = createGroup<Command>({
commandKey: "newInvite",
icon: IconMailForward,
name: tOption("newInvite.label"),
useInteraction() {
const { openModal } = useModalAction(InviteCreateModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
useInteraction: interaction.link(() => ({ href: "/manage/users/invites?create=true" })),
hidden: !session?.user.permissions.includes("admin"),
},
{
commandKey: "newGroup",
icon: IconUsersGroup,
name: tOption("newGroup.label"),
useInteraction() {
const { openModal } = useModalAction(AddGroupModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
useInteraction: interaction.link(() => ({ href: "/manage/users/groups?create=true" })),
hidden: !session?.user.permissions.includes("admin"),
},
];
+1
View File
@@ -1783,6 +1783,7 @@
"deleteNamed": "Delete {name}",
"clearSearch": "Clear search",
"copy": "Copy",
"copied": "Copied",
"refresh": "Refresh"
},
"here": "here",
+1
View File
@@ -1,5 +1,6 @@
export * from "./count-badge";
export { OverflowBadge } from "./overflow-badge";
export { InlineConfirmActionIcon, InlineConfirmButton, InlineConfirmMenuItem } from "./inline-confirm-action";
export { SearchInput } from "./search-input";
export * from "./select-with-description";
export * from "./select-with-description-and-badge";
@@ -0,0 +1,239 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ActionIcon, Button, Menu } from "@mantine/core";
import type { ActionIconProps, ButtonProps, ElementProps, MenuItemProps } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
const confirmationTimeout = 4_000;
const visuallyHidden = {
border: 0,
clip: "rect(0 0 0 0)",
height: 1,
margin: -1,
overflow: "hidden",
padding: 0,
position: "absolute" as const,
whiteSpace: "nowrap" as const,
width: 1,
};
interface InlineConfirmActionOptions {
confirmLabel: ReactNode;
onConfirm: () => void | Promise<void>;
disabled?: boolean;
pending?: boolean;
timeout?: number;
}
const useInlineConfirmAction = ({
confirmLabel,
onConfirm,
disabled = false,
pending = false,
timeout = confirmationTimeout,
}: InlineConfirmActionOptions) => {
const [confirming, setConfirming] = useState(false);
const [submitting, setSubmitting] = useState(false);
const actionRef = useRef<HTMLButtonElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reset = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setConfirming(false);
}, []);
useEffect(() => {
if (!confirming) return;
const handlePointerDown = (event: PointerEvent) => {
if (event.target instanceof Node && !actionRef.current?.contains(event.target)) reset();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") reset();
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown, true);
timeoutRef.current = setTimeout(reset, timeout);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
};
}, [confirming, reset, timeout]);
useEffect(() => {
if (disabled || pending) reset();
}, [disabled, pending, reset]);
const handleClick = useCallback(async () => {
if (disabled || pending || submitting) return;
if (!confirming) {
setConfirming(true);
return;
}
setSubmitting(true);
try {
await onConfirm();
} finally {
setSubmitting(false);
reset();
}
}, [confirming, disabled, onConfirm, pending, reset, submitting]);
const handleBlur = useCallback(() => reset(), [reset]);
return {
actionRef,
confirming,
confirmLabel,
handleBlur,
handleClick,
reset,
submitting,
};
};
type InlineConfirmButtonProps = Omit<ButtonProps, "children" | "disabled" | "loading" | "onClick"> &
ElementProps<"button", "color" | "disabled" | "onClick"> &
InlineConfirmActionOptions & {
children: ReactNode;
loading?: boolean;
};
export const InlineConfirmButton = ({
children,
confirmLabel,
onConfirm,
disabled,
pending,
loading,
timeout,
variant,
...props
}: InlineConfirmButtonProps) => {
const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout });
const label = action.confirming ? confirmLabel : children;
let nextVariant = variant;
if (action.confirming) nextVariant = "filled";
return (
<>
<Button
{...props}
ref={action.actionRef}
variant={nextVariant}
disabled={disabled || pending || action.submitting}
loading={loading || action.submitting}
onClick={() => void action.handleClick().catch(() => undefined)}
onBlur={action.handleBlur}
aria-label={typeof label === "string" ? label : undefined}
data-confirming={action.confirming || undefined}
>
{label}
</Button>
<span aria-live="polite" style={visuallyHidden}>
{action.confirming ? confirmLabel : null}
</span>
</>
);
};
type InlineConfirmActionIconProps = Omit<ActionIconProps, "children" | "disabled" | "loading" | "onClick"> &
InlineConfirmActionOptions & {
"aria-label"?: string;
children: ReactNode;
confirmationAriaLabel?: string;
confirmationChildren?: ReactNode;
loading?: boolean;
title?: string;
};
export const InlineConfirmActionIcon = ({
children,
confirmLabel,
confirmationAriaLabel,
confirmationChildren,
onConfirm,
disabled,
pending,
loading,
timeout,
variant,
"aria-label": ariaLabel,
...props
}: InlineConfirmActionIconProps) => {
const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout });
const nextAriaLabel = action.confirming
? (confirmationAriaLabel ?? (typeof confirmLabel === "string" ? confirmLabel : ariaLabel))
: ariaLabel;
let nextVariant = variant;
if (action.confirming) nextVariant = "filled";
let nextChildren = children;
if (action.confirming) nextChildren = confirmationChildren ?? <IconCheck size="1rem" />;
return (
<>
<ActionIcon
{...props}
ref={action.actionRef}
variant={nextVariant}
disabled={disabled || pending || action.submitting}
loading={loading || action.submitting}
onClick={() => void action.handleClick().catch(() => undefined)}
onBlur={action.handleBlur}
aria-label={nextAriaLabel}
data-confirming={action.confirming || undefined}
>
{nextChildren}
</ActionIcon>
<span aria-live="polite" style={visuallyHidden}>
{action.confirming ? confirmLabel : null}
</span>
</>
);
};
type InlineConfirmMenuItemProps = Omit<MenuItemProps, "children" | "closeMenuOnClick" | "disabled" | "onClick"> &
InlineConfirmActionOptions & {
children: ReactNode;
};
export const InlineConfirmMenuItem = ({
children,
confirmLabel,
onConfirm,
disabled,
pending,
timeout,
...props
}: InlineConfirmMenuItemProps) => {
const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending, timeout });
const label = action.confirming ? confirmLabel : children;
return (
<>
<Menu.Item
{...props}
ref={action.actionRef}
disabled={disabled || pending || action.submitting}
closeMenuOnClick={action.confirming}
onClick={() => void action.handleClick().catch(() => undefined)}
aria-label={typeof label === "string" ? label : undefined}
data-confirming={action.confirming || undefined}
>
{label}
</Menu.Item>
<span aria-live="polite" style={visuallyHidden}>
{action.confirming ? confirmLabel : null}
</span>
</>
);
};
+1 -2
View File
@@ -4,5 +4,4 @@ import { createFormContext } from "@homarr/form";
import type { WidgetEditModalState } from "../modals/widget-edit-modal";
export const [FormProvider, useFormContext, useForm] =
createFormContext<Omit<WidgetEditModalState, "advancedOptions">>();
export const [FormProvider, useFormContext, useForm] = createFormContext<WidgetEditModalState>();
@@ -1,5 +1,7 @@
// @vitest-environment jsdom
/* eslint-disable unicorn/consistent-function-scoping -- Component mocks are intentionally colocated with their module factory. */
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
@@ -30,6 +32,7 @@ vi.mock("@mantine/core", () => {
return {
Button: Container,
Collapse: Container,
Fieldset: Container,
Group: Container,
NumberInput: Input,
@@ -39,11 +42,6 @@ vi.mock("@mantine/core", () => {
};
});
vi.mock("@homarr/modals", () => ({
createModal: () => ({ withOptions: () => null }),
useModalAction: () => ({ openModal: vi.fn() }),
}));
vi.mock("@homarr/translation/client", () => ({
useCurrentIntlLocale: () => "en",
useI18n: () => (key: string) => key,
@@ -1,17 +1,18 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActionIcon,
Alert,
Anchor,
Button,
Collapse,
Fieldset,
Group,
Loader,
NumberInput,
Paper,
Stack,
Table,
Text,
TextInput,
Tooltip,
@@ -20,7 +21,6 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { formatLocalizedCompactNumber } from "../common/locale";
@@ -33,7 +33,7 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp
const t = useWidgetInputTranslation(kind, property);
const tLocation = useI18n("widget.common.location");
const form = useFormContext();
const { openModal } = useModalAction(LocationSearchModal);
const [searchQuery, setSearchQuery] = useState<string | null>(null);
const fieldPath = `options.${property}`;
const inputProps = form.getInputProps(fieldPath);
const value = isOptionLocation(inputProps.value) ? inputProps.value : options.defaultValue;
@@ -58,26 +58,23 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp
handleChange(location);
form.clearFieldError(`options.${property}.latitude`);
form.clearFieldError(`options.${property}.longitude`);
setSearchQuery(null);
},
[form, handleChange, property],
);
const onSearch = useCallback(() => {
if (!selectionEnabled) return;
setSearchQuery(value.name);
}, [selectionEnabled, value.name]);
openModal({
query: value.name,
onLocationSelect,
});
}, [selectionEnabled, value.name, onLocationSelect, openModal]);
form.watch(`options.${property}.latitude`, ({ value }) => {
if (typeof value !== "number") return;
form.watch(`options.${property}.latitude`, ({ value: nextValue }) => {
if (typeof nextValue !== "number") return;
form.setFieldValue(`options.${property}.name`, unknownLocation);
});
form.watch(`options.${property}.longitude`, ({ value }) => {
if (typeof value !== "number") return;
form.watch(`options.${property}.longitude`, ({ value: nextValue }) => {
if (typeof nextValue !== "number") return;
form.setFieldValue(`options.${property}.name`, unknownLocation);
});
@@ -121,6 +118,16 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp
value={longitudeInputProps.value ?? value.longitude}
/>
</Group>
<Collapse expanded={searchQuery !== null}>
{searchQuery && (
<LocationSearchResults
query={searchQuery}
onLocationSelect={onLocationSelect}
onClose={() => setSearchQuery(null)}
/>
)}
</Collapse>
</Stack>
</Fieldset>
);
@@ -143,124 +150,123 @@ type LocationOnChange = (
},
) => void;
interface LocationSearchInnerProps {
interface LocationSearchResultsProps {
query: string;
onLocationSelect: (location: OptionLocation) => void;
onClose: () => void;
}
const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => {
const t = useI18n("widget.common.location.table");
const LocationSearchResults = ({ query, onLocationSelect, onClose }: LocationSearchResultsProps) => {
const tLocation = useI18n("widget.common.location");
const tCommon = useI18n("common");
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
query: innerProps.query,
});
const { data, isPending, error, refetch } = clientApi.location.searchCity.useQuery({ query });
if (error) {
return (
<Alert title={tCommon("error")} color="red">
{tCommon("action.tryAgain")}
</Alert>
<Stack gap="xs">
<Alert title={tCommon("error")} color="red">
<Button variant="light" color="red" size="compact-sm" onClick={() => void refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Alert>
<Group justify="right">
<Button variant="default" size="compact-sm" onClick={onClose}>
{tCommon("action.close")}
</Button>
</Group>
</Stack>
);
}
return (
<Stack>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th>
<Table.Th style={{ width: "50%" }}>{t("header.country")}</Table.Th>
<Table.Th>{t("header.coordinates")}</Table.Th>
<Table.Th>{t("header.population")}</Table.Th>
<Table.Th style={{ width: 40 }} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isPending && (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center">
<Loader />
</Group>
</Table.Td>
</Table.Tr>
)}
{data?.results.map((city) => (
<LocationSelectTableRow
key={city.id}
city={city}
onLocationSelect={innerProps.onLocationSelect}
closeModal={actions.closeModal}
/>
))}
</Table.Tbody>
</Table>
<Stack component="section" aria-label={tLocation("search")} gap="xs">
<Stack gap={4} mah={320} style={{ overflow: "hidden auto" }}>
{isPending && (
<Group justify="center" py="md">
<Loader />
</Group>
)}
{!isPending && data?.results.length === 0 && (
<Text ta="center" c="dimmed" py="md">
{tCommon("noResults")}
</Text>
)}
{data?.results.map((city) => (
<LocationSelectResult key={city.id} city={city} onLocationSelect={onLocationSelect} />
))}
</Stack>
<Group justify="right">
<Button variant="light" onClick={actions.closeModal}>
{tCommon("action.cancel")}
<Button variant="default" size="compact-sm" onClick={onClose}>
{tCommon("action.close")}
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("widget.common.location.search");
},
size: "xl",
});
};
interface LocationSearchTableRowProps {
interface LocationSelectResultProps {
city: RouterOutputs["location"]["searchCity"]["results"][number];
onLocationSelect: (location: OptionLocation) => void;
closeModal: () => void;
}
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const LocationSelectResult = ({ city, onLocationSelect }: LocationSelectResultProps) => {
const t = useI18n("widget.common.location.table");
const locale = useCurrentIntlLocale();
let populationLabel = t("population.fallback");
if (city.population) {
populationLabel = `${t("header.population")}: ${formatLocalizedCompactNumber(city.population, locale)}`;
}
const onSelect = useCallback(() => {
onLocationSelect({
name: city.name,
latitude: city.latitude,
longitude: city.longitude,
});
closeModal();
}, [city, onLocationSelect, closeModal]);
}, [city, onLocationSelect]);
return (
<Table.Tr>
<Table.Td>
<Text style={{ whiteSpace: "nowrap" }}>{city.name}</Text>
</Table.Td>
<Table.Td>
<Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text>
</Table.Td>
<Table.Td>
<Anchor target="_blank" href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}>
<Text style={{ whiteSpace: "nowrap" }}>
{city.latitude}, {city.longitude}
<Paper withBorder p="xs">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2} miw={0}>
<Text fw={500} style={{ overflowWrap: "anywhere" }}>
{city.name}
</Text>
</Anchor>
</Table.Td>
<Table.Td>
{city.population ? (
<Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text>
) : (
<Text c="gray"> {t("population.fallback")}</Text>
)}
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed" style={{ overflowWrap: "anywhere" }}>
{city.country}
</Text>
<Group gap="xs" wrap="wrap">
<Anchor
size="xs"
target="_blank"
href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}
>
{city.latitude}, {city.longitude}
</Anchor>
<Text size="xs" c="dimmed">
{populationLabel}
</Text>
</Group>
</Stack>
<Tooltip
label={t("action.select", {
city: city.name,
countryCode: city.country_code ?? "??",
})}
>
<ActionIcon color="red" variant="subtle" onClick={onSelect}>
<ActionIcon
flex="0 0 auto"
color="red"
variant="subtle"
onClick={onSelect}
aria-label={t("action.select", {
city: city.name,
countryCode: city.country_code ?? "??",
})}
>
<IconClick size="var(--mantine-font-size-md)" />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
</Group>
</Paper>
);
};
@@ -1,106 +0,0 @@
import { useRef, useState } from "react";
import type { NumberInputHandlers } from "@mantine/core";
import { ActionIcon, Button, Flex, Group, Modal, NumberInput, rem, Stack, Text } from "@mantine/core";
import { IconClockPause } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface TimerModalProps {
opened: boolean;
close: () => void;
selectedIntegrationIds: string[];
disableDns: (data: { duration: number; integrationId: string }) => void;
}
const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: TimerModalProps) => {
const t = useI18n("widget.dnsHoleControls");
const tCommon = useI18n("common");
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const hoursHandlers = useRef<NumberInputHandlers>(null);
const minutesHandlers = useRef<NumberInputHandlers>(null);
const handleSetTimer = () => {
const duration = hours * 3600 + minutes * 60;
selectedIntegrationIds.forEach((integrationId) => {
disableDns({ duration, integrationId });
});
setHours(0);
setMinutes(0);
close();
};
return (
<Modal
withinPortal
radius="lg"
shadow="sm"
size="sm"
opened={opened}
onClose={() => {
close();
setHours(0);
setMinutes(0);
}}
title={t("controls.setTimer")}
>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{tCommon("information.hours")}</Text>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
–
</ActionIcon>
<NumberInput
hideControls
value={hours}
onChange={(val) => setHours(Number(val))}
handlersRef={hoursHandlers}
max={999}
min={0}
step={1}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
<Group>
<Text>{tCommon("information.minutes")}</Text>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
–
</ActionIcon>
<NumberInput
hideControls
value={minutes}
onChange={(val) => setMinutes(Number(val))}
handlersRef={minutesHandlers}
max={59}
min={0}
step={1}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
</Stack>
<Text ta="center" c="dimmed" my={5}>
{t("controls.unlimited")}
</Text>
<Button
variant="light"
color="red"
leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />}
h="2rem"
w="12rem"
onClick={handleSetTimer}
>
{t("controls.set")}
</Button>
</Flex>
</Modal>
);
};
export default TimerModal;
@@ -1,5 +1,8 @@
"use client";
/* eslint-disable react/no-unstable-nested-components -- TimerPopover uses render props to wire controlled targets. */
/* eslint-disable import/no-unassigned-import -- The shared widget stylesheet is loaded for its side effects. */
import "../../widgets-common.css";
import { useState } from "react";
@@ -18,7 +21,6 @@ import {
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -38,7 +40,7 @@ import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../com
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
import TimerPopover from "./timer-popover";
const dnsLightStatus = (enabled: boolean | undefined) =>
`var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`;
@@ -123,11 +125,8 @@ export default function DnsHoleControlsWidget({
const t = useI18n("widget.dnsHoleControls");
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
const [bulkPending, setBulkPending] = useState(false);
const [bulkFailureCount, setBulkFailureCount] = useState(0);
const [opened, { close, open }] = useDisclosure(false);
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
const actionsPending = bulkPending || isEnabling || isDisabling;
@@ -177,26 +176,29 @@ export default function DnsHoleControlsWidget({
</Button>
</Tooltip>
<Tooltip label={t("controls.setTimer")}>
<Button
aria-label={t("controls.setTimer")}
size="xs"
p={0}
className="dns-hole-controls-timer-all-button"
onClick={() => {
setSelectedIntegrationIds(integrationsSummaries.enabled);
open();
}}
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="yellow"
bd={0}
radius={board.itemRadius}
flex={1}
>
<IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" />
</Button>
</Tooltip>
<TimerPopover
target={(onClick) => (
<Tooltip label={t("controls.setTimer")}>
<Button
aria-label={t("controls.setTimer")}
size="xs"
p={0}
className="dns-hole-controls-timer-all-button"
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="yellow"
bd={0}
radius={board.itemRadius}
flex={1}
onClick={onClick}
>
<IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" />
</Button>
</Tooltip>
)}
selectedIntegrationIds={integrationsSummaries.enabled}
disableDns={(input) => void disableDns(input).catch(() => undefined)}
/>
<Tooltip label={t("controls.disableAll")}>
<Button
@@ -230,9 +232,8 @@ export default function DnsHoleControlsWidget({
key={summary.integration.id}
integrationsWithInteractions={integrationsWithInteractions}
toggleDns={toggleDns}
disableDns={(input) => void disableDns(input).catch(() => undefined)}
data={summary}
setSelectedIntegrationIds={setSelectedIntegrationIds}
open={open}
t={t}
hasIconColor={board.iconColor !== null}
rootWidth={width}
@@ -255,13 +256,6 @@ export default function DnsHoleControlsWidget({
</Text>
</Tooltip>
)}
<TimerModal
opened={opened}
close={close}
selectedIntegrationIds={selectedIntegrationIds}
disableDns={(input) => void disableDns(input).catch(() => undefined)}
/>
</Stack>
);
}
@@ -269,9 +263,8 @@ export default function DnsHoleControlsWidget({
interface ControlsCardProps {
integrationsWithInteractions: string[];
toggleDns: (integrationId: string) => Promise<void>;
disableDns: (data: { duration: number; integrationId: string }) => void;
data: AvailableDnsSummaryResult;
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>;
hasIconColor: boolean;
rootWidth: number;
@@ -282,9 +275,8 @@ interface ControlsCardProps {
const ControlsCard: React.FC<ControlsCardProps> = ({
integrationsWithInteractions,
toggleDns,
disableDns,
data,
setSelectedIntegrationIds,
open,
t,
hasIconColor,
rootWidth,
@@ -382,21 +374,24 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
<IconPlayerStop size="var(--mantine-font-size-xs)" />
</ActionIcon>
)}
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("controls.setTimer")}: ${data.integration.name}`}
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
}}
size="sm"
color="yellow"
variant="light"
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
>
<IconClockPause size="var(--mantine-font-size-xs)" />
</ActionIcon>
<TimerPopover
target={(onClick) => (
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("controls.setTimer")}: ${data.integration.name}`}
size="sm"
color="yellow"
variant="light"
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
onClick={onClick}
>
<IconClockPause size="var(--mantine-font-size-xs)" />
</ActionIcon>
)}
selectedIntegrationIds={[data.integration.id]}
disableDns={disableDns}
/>
</Group>
)}
{layout === "md" && (
@@ -445,24 +440,27 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</Flex>
</Flex>
{layout === "md" && (
<ActionIcon
aria-label={`${t("controls.setTimer")}: ${data.integration.name}`}
className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)}
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
color="yellow"
size={30}
radius={board.itemRadius}
bd={0}
ms={"auto"}
variant="subtle"
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
}}
>
<IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" />
</ActionIcon>
<TimerPopover
target={(onClick) => (
<ActionIcon
aria-label={`${t("controls.setTimer")}: ${data.integration.name}`}
className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)}
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
color="yellow"
size={30}
radius={board.itemRadius}
bd={0}
ms={"auto"}
variant="subtle"
onClick={onClick}
>
<IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" />
</ActionIcon>
)}
selectedIntegrationIds={[data.integration.id]}
disableDns={disableDns}
/>
)}
</Flex>
</Card>
@@ -0,0 +1,146 @@
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import type { NumberInputHandlers } from "@mantine/core";
import { ActionIcon, Button, Flex, Group, NumberInput, Popover, rem, Stack, Text } from "@mantine/core";
import { IconClockPause } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { useI18n } from "@homarr/translation/client";
interface TimerPopoverProps {
target: (onClick: () => void) => ReactNode;
selectedIntegrationIds: string[];
disableDns: (data: { duration: number; integrationId: string }) => void;
}
const TimerPopover = ({ target, selectedIntegrationIds, disableDns }: TimerPopoverProps) => {
const t = useI18n("widget.dnsHoleControls");
const tCommon = useI18n("common");
const [opened, { close, open }] = useDisclosure(false);
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const hoursHandlers = useRef<NumberInputHandlers>(null);
const minutesHandlers = useRef<NumberInputHandlers>(null);
const handleSetTimer = () => {
const duration = hours * 3600 + minutes * 60;
selectedIntegrationIds.forEach((integrationId) => {
disableDns({ duration, integrationId });
});
setHours(0);
setMinutes(0);
close();
};
const reset = () => {
close();
setHours(0);
setMinutes(0);
};
const handleToggle = () => {
if (opened) {
reset();
return;
}
open();
};
return (
<Popover
opened={opened}
onChange={(nextOpened) => {
if (nextOpened) open();
if (!nextOpened) reset();
}}
position="bottom"
shadow="sm"
>
<Popover.Target>{target(handleToggle)}</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" maw={280}>
<Text fw={500}>{t("controls.setTimer")}</Text>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{tCommon("information.hours")}</Text>
<ActionIcon
size={35}
variant="default"
onClick={() => hoursHandlers.current?.decrement()}
aria-label={`− ${tCommon("information.hours")}`}
>
–
</ActionIcon>
<NumberInput
hideControls
value={hours}
onChange={(val) => setHours(Number(val))}
handlersRef={hoursHandlers}
aria-label={tCommon("information.hours")}
max={999}
min={0}
step={1}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon
size={35}
variant="default"
onClick={() => hoursHandlers.current?.increment()}
aria-label={`+ ${tCommon("information.hours")}`}
>
+
</ActionIcon>
</Group>
<Group>
<Text>{tCommon("information.minutes")}</Text>
<ActionIcon
size={35}
variant="default"
onClick={() => minutesHandlers.current?.decrement()}
aria-label={`− ${tCommon("information.minutes")}`}
>
–
</ActionIcon>
<NumberInput
hideControls
value={minutes}
onChange={(val) => setMinutes(Number(val))}
handlersRef={minutesHandlers}
aria-label={tCommon("information.minutes")}
max={59}
min={0}
step={1}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon
size={35}
variant="default"
onClick={() => minutesHandlers.current?.increment()}
aria-label={`+ ${tCommon("information.minutes")}`}
>
+
</ActionIcon>
</Group>
</Stack>
<Text ta="center" c="dimmed" my={5}>
{t("controls.unlimited")}
</Text>
<Button
variant="light"
color="red"
leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />}
h="2rem"
w="12rem"
onClick={handleSetTimer}
>
{t("controls.set")}
</Button>
</Flex>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
export default TimerPopover;
@@ -1,66 +0,0 @@
"use client";
import { CloseButton, ColorInput, Input, Stack, TextInput, useMantineTheme } from "@mantine/core";
import { useForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { TextMultiSelect } from "@homarr/ui";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
interface InnerProps {
advancedOptions: BoardItemAdvancedOptions;
onSuccess: (options: BoardItemAdvancedOptions) => void;
}
export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n("item.edit");
const theme = useMantineTheme();
const form = useForm({
initialValues: innerProps.advancedOptions,
});
const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({
...values,
// we want to fallback to null if the title is empty
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
title: values.title?.trim() || null,
});
actions.closeModal();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("field.title.label")}
data-autofocus
{...form.getInputProps("title")}
rightSection={<Input.ClearButton onClick={() => form.setFieldValue("title", "")} />}
/>
<TextMultiSelect label={t("field.customCssClasses.label")} {...form.getInputProps("customCssClasses")} />
<ColorInput
label={t("field.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
<CloseButton
onClick={() => form.setFieldValue("borderColor", "")}
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
/>
}
{...form.getInputProps("borderColor")}
/>
<ModalFormFooter onCancel={actions.closeModal} />
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("item.edit.advancedOptions.title");
},
size: modalSizeForm,
transitionProps: {
duration: 0,
},
});
+132 -100
View File
@@ -5,19 +5,25 @@
import { useEffect, useId, useRef, useState } from "react";
import type { ComponentType, FormEvent, PropsWithChildren } from "react";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Center,
CloseButton,
ColorInput,
Divider,
Group,
Input as MantineInput,
NumberInput,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
Tooltip,
useMantineTheme,
} from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { useElementSize } from "@mantine/hooks";
@@ -30,11 +36,11 @@ import { objectEntries } from "@homarr/common";
import { IntegrationProvider, useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { createModal, modalSizeForm } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { SpotlightProvider } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { IntegrationAvatar, TextMultiSelect } from "@homarr/ui";
import { zodErrorMap } from "@homarr/validation/form/i18n";
import { getInputForType } from "../_inputs";
@@ -47,7 +53,6 @@ import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css";
@@ -314,7 +319,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const tCommon = useI18n("common");
const board = useOptionalBoard();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const theme = useMantineTheme();
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>());
const integrationEditRefCallbacks = useRef(
@@ -368,6 +373,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
options: optionsSchema,
integrationIds: integrationIdsSchema,
advancedOptions: z.object({
title: z.string().max(64).nullable(),
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
@@ -377,7 +383,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
validateInputOnBlur: true,
validateInputOnChange: true,
});
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
@@ -457,7 +462,10 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
innerProps.onSuccessfulEdit(
{
...values,
advancedOptions,
advancedOptions: {
...values.advancedOptions,
title: values.advancedOptions.title?.trim() || null,
},
},
previewSize ?? undefined,
);
@@ -519,14 +527,14 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<Box
className={classes.workspace}
data-with-preview={innerProps.previewComponent ? true : undefined}
data-with-settings={hasWidgetSettings ? true : undefined}
data-with-settings
>
{innerProps.previewComponent && (
<WidgetEditPreview
kind={innerProps.kind}
Component={innerProps.previewComponent}
definition={definition}
state={{ ...form.values, advancedOptions }}
state={form.values}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
dimensions={previewDimensions}
@@ -536,107 +544,131 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
PreviewWrapper={innerProps.previewWrapper}
/>
)}
{innerProps.previewComponent && hasWidgetSettings && (
<Divider orientation="vertical" className={classes.previewDivider} />
)}
<form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit} hidden={!hasWidgetSettings}>
{hasWidgetSettings && (
<Stack className={classes.settingsPanel} gap="sm">
<Group gap="xs" wrap="nowrap">
<IconSettings size={16} />
<Text fw={600} size="sm">
{tItem("settings.label")}
</Text>
</Group>
{canConfigureWidget && innerProps.integrationSupport && (
<Box className={classes.fullWidthField}>
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
</Box>
)}
{canConfigureWidget && (
<Stack gap="sm">
{switchOptions.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
{innerProps.previewComponent && <Divider orientation="vertical" className={classes.previewDivider} />}
<form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit}>
<Stack className={classes.settingsPanel} gap="sm">
{hasWidgetSettings && (
<>
<Group gap="xs" wrap="nowrap">
<IconSettings size={16} />
<Text fw={600} size="sm">
{tItem("settings.label")}
</Text>
</Group>
{canConfigureWidget && innerProps.integrationSupport && (
<Box className={classes.fullWidthField}>
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
</Box>
)}
{canConfigureWidget && (
<Stack gap="sm">
{switchOptions.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Box
key={key}
className={classes.switchOption}
data-option-type={value.type}
onClick={(event) => {
const target = event.target;
if (target instanceof Element && target.closest("label, input")) return;
const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]");
if (!input?.disabled) input?.click();
}}
>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</SimpleGrid>
)}
{otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Box
key={key}
className={classes.switchOption}
data-option-type={value.type}
onClick={(event) => {
const target = event.target;
if (target instanceof Element && target.closest("label, input")) return;
const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]");
if (!input?.disabled) input?.click();
}}
>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</SimpleGrid>
)}
{otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Box key={key} data-option-type={value.type}>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
return (
<Box key={key} data-option-type={value.type}>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</Stack>
)}
</>
)}
<Accordion variant="contained">
<Accordion.Item value="advanced-options">
<Accordion.Control>{tItem("advancedOptions.label")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<TextInput
label={tItem("field.title.label")}
rightSection={
<MantineInput.ClearButton
onClick={() => form.setFieldValue("advancedOptions.title", "")}
disabled={!form.values.advancedOptions.title}
/>
</Box>
);
})}
</Stack>
)}
</Stack>
)}
}
{...form.getInputProps("advancedOptions.title")}
/>
<TextMultiSelect
label={tItem("field.customCssClasses.label")}
{...form.getInputProps("advancedOptions.customCssClasses")}
/>
<ColorInput
label={tItem("field.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
<CloseButton
aria-label={tCommon("action.remove")}
onClick={() => form.setFieldValue("advancedOptions.borderColor", "")}
style={{ display: form.values.advancedOptions.borderColor ? undefined : "none" }}
/>
}
{...form.getInputProps("advancedOptions.borderColor")}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</form>
</Box>
);
const footer = (
<Box className={classes.footer}>
<Group justify="space-between" wrap="nowrap">
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess: setAdvancedOptions,
})
}
>
{tItem("advancedOptions.label")}
</Button>
<Group justify="end" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
+10 -19
View File
@@ -66,6 +66,7 @@ import { useForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { InlineConfirmActionIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
@@ -80,7 +81,6 @@ import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useRequiredBoard } from "@homarr/boards/context";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
const iconProps = {
size: 30,
@@ -285,21 +285,10 @@ export function Notebook({
return false;
}, [editor]);
const { openConfirmModal } = useConfirmModal();
const handleEditCancel = useCallback(() => {
if (savingRef.current) return;
openConfirmModal({
title: t("dismiss.title"),
children: t("dismiss.message"),
labels: {
confirm: t("dismiss.action.discard"),
cancel: t("dismiss.action.keepEditing"),
},
onConfirm: () => {
setIsEditing(handleEditCancelCallback);
},
});
}, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]);
setIsEditing(handleEditCancelCallback);
}, [setIsEditing, handleEditCancelCallback]);
const handleEditToggle = useCallback(async () => {
if (!editor || savingRef.current) return;
@@ -369,8 +358,7 @@ export function Notebook({
},
toolbar: {
backgroundColor: "transparent",
borderColor:
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))",
borderColor: "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))",
padding: "0.5rem",
},
content: {
@@ -554,19 +542,22 @@ export function Notebook({
{isEditing ? <IconDeviceFloppy {...iconProps} /> : <IconEdit {...iconProps} />}
</ActionIcon>
{isEditing && (
<ActionIcon
<InlineConfirmActionIcon
className={`homarr-notebook-action ${actionTargetClasses.root}`}
data-visible
title={tCommon("action.cancel")}
aria-label={tCommon("action.cancel")}
confirmLabel={t("dismiss.message")}
confirmationAriaLabel={t("dismiss.action.discard")}
confirmationChildren={<IconCheck {...iconProps} />}
onConfirm={handleEditCancel}
color={primaryColor}
variant="light"
size={30}
disabled={isSaving}
onClick={handleEditCancel}
>
<IconX {...iconProps} />
</ActionIcon>
</InlineConfirmActionIcon>
)}
</Stack>
)}
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-${BASE_URL:-http://100.111.30.70:3201}}"
SCREENSHOT_DIR="${2:-${SCREENSHOT_DIR:-.screenshots/ux-coherence-browser-agent}}"
BASE_URL="${BASE_URL%/}"
mkdir -p "$SCREENSHOT_DIR"
SCREENSHOT_DIR="$(cd "$SCREENSHOT_DIR" && pwd)"
SESSION="$(mise exec -- agent-browser session id --scope worktree --prefix ux-coherence)"
BOARD_NAME=""
BOARD_CREATE_REQUESTED=0
BOARD_CREATED=0
BOARD_CLEANUP_PENDING=0
API_KEY_CLEANUP_PENDING=0
API_KEY_CREATED_ID=""
CLEANUP_RAN=0
CLEANUP_DEADLINE=0
declare -a API_KEY_IDS_BEFORE=()
declare -A API_KEY_IDS_BEFORE_SET=()
API_KEY_IDS_BEFORE_JSON="[]"
ab() {
mise exec -- agent-browser --session "$SESSION" "$@"
}
fail() {
echo "ux-coherence browser assertion failed: $*" >&2
exit 1
}
assert_count() {
local selector="$1"
local expected="$2"
local message="$3"
local actual
actual="$(ab get count "$selector")"
if [[ "$actual" != "$expected" ]]; then
fail "$message (expected $expected, got $actual)"
fi
}
assert_no_dialog() {
local description="$1"
assert_eval "(() => { const dialog = Array.from(document.querySelectorAll('[role=\"dialog\"]')).find((element) => element.offsetParent !== null); if (dialog) throw new Error('$description opened a visible dialog'); return true; })()"
}
assert_no_modal_dialog() {
local description="$1"
assert_eval "(() => { const modal = Array.from(document.querySelectorAll('[role=\"dialog\"][aria-modal=\"true\"]')).find((element) => element.offsetParent !== null); if (modal) throw new Error('$description opened a visible modal dialog'); return true; })()"
}
assert_eval() {
local expression="$1"
ab eval "$expression" >/dev/null
}
parse_api_key_ids() {
local -n target="$1"
local remaining="$2"
local marker
local id
target=()
while [[ "$remaining" =~ UX_COHERENCE_API_ID=([[:xdigit:]]{8}) ]]; do
marker="${BASH_REMATCH[0]}"
id="${BASH_REMATCH[1],,}"
target+=("$id")
remaining="${remaining#*"$marker"}"
done
}
capture_api_key_ids() {
local target_name="$1"
local -n target="$target_name"
local output
local count
output="$(ab eval "Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => 'UX_COHERENCE_API_ID=' + button.closest('tr')?.querySelector('td')?.textContent?.trim()).join('\\n')")" || return
count="$(ab get count 'button[aria-label="Delete API token"]')" || return
if [[ ! "$count" =~ ^[0-9]+$ ]]; then
return 1
fi
parse_api_key_ids "$target_name" "$output"
[[ "${#target[@]}" -eq "$count" ]]
}
capture_cleanup_api_key_ids() {
local target_name="$1"
local -n target="$target_name"
local output
local count
output="$(cleanup_ab eval "Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => 'UX_COHERENCE_API_ID=' + button.closest('tr')?.querySelector('td')?.textContent?.trim()).join('\\n')")" || return
count="$(cleanup_ab get count 'button[aria-label="Delete API token"]')" || return
if [[ ! "$count" =~ ^[0-9]+$ ]]; then
return 1
fi
parse_api_key_ids "$target_name" "$output"
[[ "${#target[@]}" -eq "$count" ]]
}
api_key_ids_match_baseline() {
local -n current="$1"
local id
if [[ "${#current[@]}" -ne "${#API_KEY_IDS_BEFORE[@]}" ]]; then
return 1
fi
for id in "${current[@]}"; do
if [[ -z "${API_KEY_IDS_BEFORE_SET[$id]+present}" ]]; then
return 1
fi
done
}
api_key_ids_to_json() {
local -n ids="$1"
local separator=""
local id
printf '['
for id in "${ids[@]}"; do
printf '%s"%s"' "$separator" "$id"
separator=","
done
printf ']'
}
capture() {
local path="$1"
# Development tooling is not part of Homarr and obscures the reviewed UI.
ab eval "document.querySelectorAll('nextjs-portal, button[aria-label=\"Open TanStack Devtools\"]').forEach((element) => element.style.setProperty('display', 'none', 'important'))" >/dev/null
ab screenshot --full "$path"
}
retry_action_until() {
local action="$1"
local condition="$2"
local description="$3"
local result
for _ in {1..240}; do
result="$(ab eval "$condition" 2>/dev/null || true)"
if [[ "$result" == "true" ]]; then
return
fi
ab eval "$action" >/dev/null 2>&1 || true
ab wait 250 >/dev/null
done
fail "$description"
}
open_expandable() {
local selector="$1"
local description="$2"
retry_action_until \
"document.querySelector('$selector')?.click()" \
"document.querySelector('$selector')?.getAttribute('aria-expanded') === 'true'" \
"$description did not expand"
}
cleanup_ab() {
local remaining=$((CLEANUP_DEADLINE - SECONDS))
local command_timeout
if ((remaining <= 0)); then
return 124
fi
command_timeout="$remaining"
if ((command_timeout > 10)); then
command_timeout=10
fi
AGENT_BROWSER_DEFAULT_TIMEOUT="$((command_timeout * 1000))" \
timeout --signal=TERM "${command_timeout}s" mise exec -- agent-browser --session "$SESSION" "$@"
}
cleanup_board_resource() {
local opened
if ((BOARD_CREATED)); then
echo "Best-effort cleanup: confirmed board $BOARD_NAME" >&2
else
echo "Best-effort cleanup: board creation request for $BOARD_NAME" >&2
fi
cleanup_ab open "$BASE_URL/manage/boards" >/dev/null 2>&1 || return
cleanup_ab wait --load domcontentloaded >/dev/null 2>&1 || return
cleanup_ab wait --fn "document.querySelector('main') !== null" >/dev/null 2>&1 || return
opened="$(cleanup_ab eval "(() => { const card = Array.from(document.querySelectorAll('main .mantine-Card-root')).find((item) => item.querySelector('a[href=\"/boards/$BOARD_NAME\"]')); if (!card) return false; const settings = card.querySelector('button[aria-label=\"Settings\"]'); if (!settings) throw new Error('Board settings button was not found'); settings.click(); return true; })()" 2>/dev/null)" || return
if [[ "$opened" != "true" ]]; then
BOARD_CLEANUP_PENDING=0
BOARD_CREATED=0
return
fi
cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((item) => item.offsetParent !== null && item.textContent?.trim() === 'Delete')" >/dev/null 2>&1 || return
cleanup_ab eval "(() => { const item = Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete'); if (!item) throw new Error('Board Delete action was not found'); item.click(); return true; })()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((item) => item.offsetParent !== null && item.textContent?.trim() === 'Confirm')" >/dev/null 2>&1 || return
cleanup_ab eval "(() => { const item = Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm'); if (!item) throw new Error('Board Confirm action was not found'); item.click(); return true; })()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "!document.body.innerText.includes('$BOARD_NAME')" >/dev/null 2>&1 || return
BOARD_CLEANUP_PENDING=0
BOARD_CREATED=0
}
cleanup_api_key_resources() {
local id
local -a current_ids=()
local -a created_ids=()
local -a remaining_ids=()
echo "Best-effort cleanup: API keys created after the captured baseline" >&2
cleanup_ab open "$BASE_URL/manage/tools/api" >/dev/null 2>&1 || return
cleanup_ab wait --load domcontentloaded >/dev/null 2>&1 || return
cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"tab\"]')).some((item) => item.textContent?.trim() === 'Authentication')" >/dev/null 2>&1 || return
cleanup_ab eval "Array.from(document.querySelectorAll('[role=\"tab\"]')).find((item) => item.textContent?.trim() === 'Authentication')?.click()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" >/dev/null 2>&1 || return
capture_cleanup_api_key_ids current_ids || return
if [[ -n "$API_KEY_CREATED_ID" ]]; then
for id in "${current_ids[@]}"; do
if [[ "$id" == "$API_KEY_CREATED_ID" ]]; then
created_ids+=("$id")
fi
done
else
for id in "${current_ids[@]}"; do
if [[ -z "${API_KEY_IDS_BEFORE_SET[$id]+present}" ]]; then
created_ids+=("$id")
fi
done
fi
for id in "${created_ids[@]}"; do
cleanup_ab eval "(() => { const id = '$id'; const button = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).find((item) => item.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase() === id); if (!button) throw new Error('Created API key row was not found'); button.click(); return true; })()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "(() => { const id = '$id'; const row = Array.from(document.querySelectorAll('tr')).find((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === id); return row?.querySelector('button[aria-label=\"Confirm\"]')?.offsetParent !== null; })()" >/dev/null 2>&1 || return
cleanup_ab eval "(() => { const id = '$id'; const row = Array.from(document.querySelectorAll('tr')).find((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === id); const button = row?.querySelector('button[aria-label=\"Confirm\"]'); if (!button) throw new Error('Created API key Confirm action was not found'); button.click(); return true; })()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "!Array.from(document.querySelectorAll('tr')).some((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === '$id')" >/dev/null 2>&1 || return
done
capture_cleanup_api_key_ids remaining_ids || return
if api_key_ids_match_baseline remaining_ids; then
API_KEY_CLEANUP_PENDING=0
API_KEY_CREATED_ID=""
fi
}
cleanup() {
local exit_code=$?
if ((CLEANUP_RAN)); then
exit "$exit_code"
fi
CLEANUP_RAN=1
trap - EXIT INT TERM
set +e
CLEANUP_DEADLINE=$((SECONDS + 45))
if ((API_KEY_CLEANUP_PENDING)); then
cleanup_api_key_resources
fi
if ((BOARD_CLEANUP_PENDING && BOARD_CREATE_REQUESTED)); then
cleanup_board_resource
fi
# Closing the session is always the final browser operation.
timeout --signal=TERM 5s mise exec -- agent-browser --session "$SESSION" close >/dev/null 2>&1
exit "$exit_code"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
echo "Browser session: $SESSION"
echo "Base URL: $BASE_URL"
echo "Screenshots: $SCREENSHOT_DIR"
# The first command launches Chromium. This host requires the sandbox override.
ab --args "--no-sandbox" open "$BASE_URL"
ab wait --load domcontentloaded
ab set viewport 1440 1000
ab set media light reduced-motion
ab wait --fn "window.location.pathname.includes('/auth/login') || document.querySelector('button[aria-label=\"Edit board\"]') !== null" --timeout 120000
current_url="$(ab get url)"
if [[ "$current_url" == *"/auth/login"* ]]; then
# These credentials are visibly documented by the writable demo login page.
ab wait --text "Demo mode is enabled"
ab wait --fn "(() => { const input = document.querySelector('input[name=\"username\"]'); return input !== null && Object.keys(input).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000
ab snapshot -i
ab find label "Username" fill "demo" --exact
ab find label "Password" fill "demo" --exact
ab find role button click --name "Login" --exact
ab wait --fn "!window.location.pathname.includes('/auth/login')" --timeout 120000
ab snapshot -i
fi
current_url="$(ab get url)"
if [[ "$current_url" == *"/auth/login"* ]]; then
fail "demo login did not leave the login page"
fi
navigate() {
local path="$1"
ab open "$BASE_URL$path"
ab wait --load domcontentloaded
ab snapshot -i
}
echo "[1/5] Weather dimensions popover"
navigate "/widgets/weather"
ab wait 'button[aria-label="Change dimensions"]' --timeout 120000
open_expandable 'button[aria-label="Change dimensions"]' "Weather dimensions popover"
ab wait --text "Width"
ab snapshot -i
# Mantine gives non-modal popovers a dialog role; verify that no modal shell opened.
assert_no_modal_dialog "Weather dimensions popover"
capture "$SCREENSHOT_DIR/01-weather-dimensions-popover.png"
assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Cancel'); if (!button) throw new Error('Weather dimensions Cancel button was not found'); button.click(); return true; })()"
ab snapshot -i
echo "[2/5] Board creation and inline cleanup"
BOARD_NAME="ux-$(date +%Y%m%d%H%M%S)-$BASHPID"
navigate "/manage/boards"
ab wait --fn "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'New board'); return button !== undefined && Object.keys(button).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000
ab find role button click --name "New board" --exact
ab wait --fn "Array.from(document.querySelectorAll('input')).some((input) => input.getAttribute('aria-label') === 'Name' || input.labels?.[0]?.textContent?.trim().startsWith('Name'))" --timeout 120000
assert_no_modal_dialog "Board creation form"
ab snapshot -i
ab find role textbox fill "$BOARD_NAME" --name "Name" --exact
ab wait --text "$BOARD_NAME is available"
ab snapshot -i
assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create'); if (!button || button.disabled) throw new Error('Create board button is not enabled'); return true; })()"
capture "$SCREENSHOT_DIR/02-board-inline-create.png"
BOARD_CREATE_REQUESTED=1
BOARD_CLEANUP_PENDING=1
assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create'); if (!button || button.disabled) throw new Error('Create board button is not enabled'); button.click(); return true; })()"
ab wait --url "**/boards/$BOARD_NAME*" --timeout 120000
BOARD_CREATED=1
ab snapshot -i
navigate "/manage/boards"
ab wait --text "$BOARD_NAME"
retry_action_until \
"(() => { const card = Array.from(document.querySelectorAll('main .mantine-Card-root')).find((item) => item.querySelector('a[href=\"/boards/$BOARD_NAME\"]')); const settings = card?.querySelector('button[aria-label=\"Settings\"]'); settings?.scrollIntoView({ block: 'center' }); if (settings?.getAttribute('aria-expanded') !== 'true') settings?.click(); })()" \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete')" \
"Created board actions did not open"
ab snapshot -i
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete')?.click()" \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm')" \
"Board Delete action did not enter confirmation state"
ab snapshot -i
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm')?.click()" \
"!document.body.innerText.includes('$BOARD_NAME')" \
"Created board still exists after cleanup"
ab snapshot -i
assert_eval "(() => { if (document.body.innerText.includes('$BOARD_NAME')) throw new Error('Created board still exists after cleanup'); return true; })()"
BOARD_CLEANUP_PENDING=0
BOARD_CREATED=0
echo "[3/5] Calendar inline removal and reload restore"
navigate "/"
ab wait '[data-kind="calendar"]' --timeout 120000
if [[ "$(ab get count 'button[aria-label="Edit board"]')" == "1" ]]; then
retry_action_until \
"document.querySelector('button[aria-label=\"Edit board\"]')?.click()" \
"document.querySelector('button[aria-label=\"Settings for Calendar\"]') !== null" \
"Board did not enter edit mode"
ab snapshot -i
fi
ab hover '[data-kind="calendar"]'
ab wait --fn "(() => { const button = document.querySelector('button[aria-label=\"Settings for Calendar\"]'); return button !== null && button.offsetParent !== null; })()" --timeout 120000
ab snapshot -i
retry_action_until \
"document.querySelector('button[aria-label=\"Settings for Calendar\"]')?.click()" \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Remove item')" \
"Calendar actions did not open"
ab snapshot -i
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Remove item')?.click()" \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Are you sure?')" \
"Calendar removal did not enter confirmation state"
ab snapshot -i
assert_no_dialog "Calendar removal confirmation"
capture "$SCREENSHOT_DIR/03-calendar-inline-remove-confirmation.png"
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Are you sure?')?.click()" \
"document.querySelector('[data-kind=\"calendar\"]') === null" \
"Calendar item was not removed"
ab snapshot -i
ab reload
ab wait --load domcontentloaded
ab wait '[data-kind="calendar"]'
ab snapshot -i
assert_count '[data-kind="calendar"]' 1 "Calendar item was not restored by reloading without Save"
echo "[4/5] API token inline creation and exact-row cleanup"
navigate "/manage/tools/api"
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"tab\"]')).find((element) => element.textContent?.trim() === 'Authentication')?.click()" \
"Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" \
"Authentication tab did not open"
ab snapshot -i
capture_api_key_ids API_KEY_IDS_BEFORE || fail "Could not capture the pre-create API key IDs"
API_KEY_IDS_BEFORE_SET=()
for api_key_id in "${API_KEY_IDS_BEFORE[@]}"; do
API_KEY_IDS_BEFORE_SET["$api_key_id"]=1
done
API_KEY_IDS_BEFORE_JSON="$(api_key_ids_to_json API_KEY_IDS_BEFORE)"
API_KEY_CLEANUP_PENDING=1
ab wait --fn "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create API token'); return button !== undefined && !button.disabled && Object.keys(button).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000
ab find role button click --name "Create API token" --exact
ab wait --text "API token created" --timeout 120000
ab snapshot -i -s "table"
assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create API token'); if (!button?.disabled) throw new Error('Create API token is not disabled while the new token is visible'); return true; })()"
assert_no_dialog "API token creation result"
# The token stays masked; do not snapshot, reveal, copy, or read the secret alert.
capture "$SCREENSHOT_DIR/04-api-token-created-inline.png"
retry_action_until \
"Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Close' && item.offsetParent !== null)?.click()" \
"!document.body.innerText.includes('API token created')" \
"API token result did not close"
ab snapshot -i
ab reload
ab wait --load domcontentloaded
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"tab\"]')).find((element) => element.textContent?.trim() === 'Authentication')?.click()" \
"Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" \
"Authentication tab did not reopen after refreshing API keys"
ab wait --fn "(() => { const previous = new Set($API_KEY_IDS_BEFORE_JSON); const created = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => button.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase()).filter(Boolean).filter((id) => !previous.has(id)); return created.length === 1; })()" --timeout 120000
ab snapshot -i
declare -a API_KEY_IDS_AFTER_CREATE=()
capture_api_key_ids API_KEY_IDS_AFTER_CREATE || fail "Could not capture the post-create API key IDs"
for api_key_id in "${API_KEY_IDS_AFTER_CREATE[@]}"; do
if [[ -z "${API_KEY_IDS_BEFORE_SET[$api_key_id]+present}" ]]; then
if [[ -n "$API_KEY_CREATED_ID" ]]; then
fail "More than one API key appeared after creation"
fi
API_KEY_CREATED_ID="$api_key_id"
fi
done
if [[ -z "$API_KEY_CREATED_ID" ]]; then
fail "Could not identify the newly created API token row"
fi
assert_eval "(() => { const id = '$API_KEY_CREATED_ID'; const button = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).find((item) => item.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase() === id); if (!button) throw new Error('Created API key row was not found'); button.setAttribute('aria-label', 'Delete created API token'); return true; })()"
ab find role button click --name "Delete created API token" --exact
ab wait --fn "document.querySelector('button[aria-label=\"Confirm\"]')?.offsetParent !== null"
ab snapshot -i
assert_no_dialog "API token deletion confirmation"
capture "$SCREENSHOT_DIR/05-api-token-inline-delete-confirmation.png"
ab find role button click --name "Confirm" --exact
ab wait --fn "!Array.from(document.querySelectorAll('tr')).some((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === '$API_KEY_CREATED_ID')" --timeout 120000
ab snapshot -i
assert_eval "(() => { const previous = new Set($API_KEY_IDS_BEFORE_JSON); const current = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => button.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase()).filter(Boolean); if (current.length !== previous.size || current.some((id) => !previous.has(id))) throw new Error('New API token row still exists after cleanup'); return true; })()"
capture "$SCREENSHOT_DIR/06-api-token-cleaned-up.png"
declare -a API_KEY_IDS_AFTER_CLEANUP=()
capture_api_key_ids API_KEY_IDS_AFTER_CLEANUP || fail "Could not verify API key cleanup"
api_key_ids_match_baseline API_KEY_IDS_AFTER_CLEANUP || fail "API key cleanup did not restore the pre-create IDs"
API_KEY_CLEANUP_PENDING=0
API_KEY_CREATED_ID=""
echo "[5/5] Certificate upload inline expansion"
navigate "/manage/tools/certificates"
retry_action_until \
"Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'false')?.click()" \
"Array.from(document.querySelectorAll('button')).some((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'true')" \
"Add certificate section did not expand"
ab snapshot -i
assert_no_dialog "Add certificate section"
capture "$SCREENSHOT_DIR/07-certificate-upload-inline.png"
retry_action_until \
"Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Cancel' && item.offsetParent !== null)?.click()" \
"Array.from(document.querySelectorAll('button')).some((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'false')" \
"Add certificate section did not close"
ab snapshot -i
echo "UX coherence happy paths passed. Screenshots remain in $SCREENSHOT_DIR"