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

@@ -80,7 +80,9 @@ A board item selects a definition from a searchable picker and immediately rende
## Import, export, and Workshop
Export downloads one canonical JSON document without credentials. Import validates and shows the origins, auth modes, network scopes, methods, actions, and required board permissions before creating anything.
Export downloads one canonical JSON document without credentials. Import validates and shows the origins, auth modes, network scopes, methods, actions, and required board permissions before creating anything. The same review screen is used for file, clipboard, and Workshop installs. It provides optional credential fields for every authenticated source; entered values are encrypted locally and never added to the imported document.
Widgets that still need a bearer token, API key, username, or password show a **Credentials missing** indicator on the Custom Widgets management page. Importing without credentials remains supported, and the indicator disappears once every required source credential is configured.
The [Workshop](/docs/management/workshop) publishes valid widgets immediately. Users can vote or report a submission. Workshop administrators may dismiss reports or delete any post; deletion does not remove installed local copies.
@@ -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}`)}
/>
</>
);
}