fix(custom-widgets): configure secrets during import

This commit is contained in:
Thomas Camlong committed 2026-07-20 17:21:51 +02:00
1 parent a26846fa31
commit e4388b0712
16 files changed
+331 -93

No files matched your search

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
@@ -9,16 +9,16 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import {
formatCustomWidgetImportIssues,
getImportReview,
looksLikeCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
const iconProps = { size: 16, stroke: 1.5 };
@@ -138,24 +138,10 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<Record<string, unknown> | null>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
closeReview();
setPendingImport(null);
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.list.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
},
});
const review = useMemo(() => getImportReview(pendingImport), [pendingImport]);
const queueImport = useCallback(
(value: Record<string, unknown>) => {
(value: HomarrCustomWidgetV2) => {
setPendingImport(value);
openReview();
},
@@ -213,7 +199,6 @@ export const ImportCustomWidgetButton = () => {
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}
title={t("action.pasteImportHint")}
>
{t("action.import")}
@@ -226,26 +211,12 @@ export const ImportCustomWidgetButton = () => {
onChange={handleImport}
aria-label={t("importReview.fileLabel")}
/>
<ImportReviewDialog
<CustomWidgetImportDialog
opened={reviewOpened}
review={review}
pending={importMutation.isPending}
onClose={closeReview}
onConfirm={() => pendingImport && importMutation.mutate(pendingImport as never)}
messages={{
title: t("importReview.title"),
description: t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
networkScope: t("importReview.networkScope"),
methods: t("importReview.methods"),
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
widget={pendingImport}
onClose={() => {
closeReview();
setPendingImport(null);
}}
/>
</>
@@ -1,7 +1,7 @@
"use client";
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
import { IconApi, IconPencil } from "@tabler/icons-react";
import { IconAlertTriangle, IconApi, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -95,6 +95,23 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
JSX · {widget.requestCount}
</Badge>
{widget.missingSecrets.length > 0 && (
<Tooltip
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
multiline
maw={320}
>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</Tooltip>
)}
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
@@ -0,0 +1,118 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Alert, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { IconKey } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getCustomWidgetSecretRequirements, getImportReview } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetImportDialogProps {
opened: boolean;
widget: HomarrCustomWidgetV2 | null;
onClose(): void;
onImported?(result: { id: string }): void;
}
export function CustomWidgetImportDialog({ opened, widget, onClose, onImported }: CustomWidgetImportDialogProps) {
const t = useScopedI18n("customWidget");
const router = useRouter();
const utils = clientApi.useUtils();
const [values, setValues] = useState<Record<string, string>>({});
const review = useMemo(() => getImportReview(widget), [widget]);
const requirements = useMemo(() => getCustomWidgetSecretRequirements(widget?.sources ?? []), [widget]);
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: (result) => {
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.list.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
onImported?.(result);
onClose();
},
onError: (error) => {
showErrorNotification({ title: t("action.import"), message: error.message || t("notification.importError") });
},
});
useEffect(() => {
if (opened) setValues({});
}, [opened, widget]);
const setValue = (sourceId: string, kind: string, value: string) => {
setValues((current) => ({ ...current, [`${sourceId}:${kind}`]: value }));
};
const importWidget = () => {
if (!widget) return;
const secrets = requirements.flatMap((requirement) => {
const value = values[`${requirement.sourceId}:${requirement.kind}`] ?? "";
return value.trim() ? [{ sourceId: requirement.sourceId, kind: requirement.kind, value }] : [];
});
mutation.mutate({ widget, secrets });
};
return (
<ImportReviewDialog
opened={opened}
review={review}
pending={mutation.isPending}
onClose={onClose}
onConfirm={importWidget}
messages={{
title: t("importReview.title"),
description: t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
networkScope: t("importReview.networkScope"),
methods: t("importReview.methods"),
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
{requirements.length > 0 && (
<Stack gap="sm">
<Alert color="yellow" icon={<IconKey size={16} />}>
<Text fw={600} size="sm">
{t("importReview.credentials.title")}
</Text>
<Text size="sm">{t("importReview.credentials.description")}</Text>
</Alert>
{requirements.map((requirement) => {
const key = `${requirement.sourceId}:${requirement.kind}`;
const label = `${requirement.sourceName} · ${t(`importReview.credentials.field.${requirement.kind}`)}`;
const description = requirement.destination
? t(
requirement.authType === "apiKeyHeader"
? "importReview.credentials.header"
: "importReview.credentials.query",
{ name: requirement.destination },
)
: undefined;
const Input = requirement.kind === "username" ? TextInput : PasswordInput;
return (
<Input
key={key}
label={label}
description={description}
value={values[key] ?? ""}
onChange={(event) => setValue(requirement.sourceId, requirement.kind, event.currentTarget.value)}
autoComplete="off"
/>
);
})}
</Stack>
)}
</ImportReviewDialog>
);
}
@@ -1,21 +1,22 @@
"use client";
import { useState } from "react";
import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { showSuccessNotification } from "@homarr/notifications";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstallButton() {
const t = useScopedI18n("workshop");
const [opened, controls] = useDisclosure(false);
const mutation = clientApi.customWidget.import.useMutation();
const utils = clientApi.useUtils();
const [reviewOpened, reviewControls] = useDisclosure(false);
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={controls.open}>
@@ -25,16 +26,20 @@ export function WorkshopInstallButton() {
<WorkshopBrowser
onInstall={async (submission) => {
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content));
await mutation.mutateAsync(widget);
await utils.customWidget.list.invalidate();
showSuccessNotification({
title: t("installed"),
message: t("installedDescription", { name: widget.name }),
});
controls.close();
setPendingWidget(widget);
reviewControls.open();
}}
/>
</Modal>
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingWidget}
onClose={() => {
reviewControls.close();
setPendingWidget(null);
}}
onImported={controls.close}
/>
</>
);
}
@@ -1,36 +1,39 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { WorkshopSubmissionDetail } from "@homarr/workshop";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstaller() {
const t = useScopedI18n("workshop");
const router = useRouter();
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation();
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, reviewControls] = useDisclosure(false);
const install = async (submission: WorkshopSubmissionDetail) => {
try {
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content) as unknown);
const result = await importMutation.mutateAsync(widget);
await utils.customWidget.list.invalidate();
showSuccessNotification({ title: t("installSuccess"), message: t("installSuccessDescription") });
router.push(`/manage/custom-widgets/edit/${result.id}`);
} catch (error) {
showErrorNotification({
title: t("installError"),
message: error instanceof Error ? error.message : t("installErrorDescription"),
});
throw error;
}
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content) as unknown);
setPendingWidget(widget);
reviewControls.open();
};
return <WorkshopBrowser onInstall={install} />;
return (
<>
<WorkshopBrowser onInstall={install} />
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingWidget}
onClose={() => {
reviewControls.close();
setPendingWidget(null);
}}
onImported={(result) => router.push(`/manage/custom-widgets/edit/${result.id}`)}
/>
</>
);
}