From 38fc5dd582bab30b4da8d5e202b9046c1a86d7e5 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 17 Aug 2026 15:47:42 +0200 Subject: [PATCH] feat(custom-widgets): configure missing credentials --- .../custom-widgets/_custom-widget-list.tsx | 155 ++++++++++-------- .../custom-widget-source-setup-dialog.tsx | 140 ++++++++++++++++ .../components/manage/manage-collection.tsx | 4 +- packages/translation/src/lang/en.json | 12 +- 4 files changed, 245 insertions(+), 66 deletions(-) create mode 100644 apps/nextjs/src/components/custom-widgets/custom-widget-source-setup-dialog.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx index 228231d4b..c1dac890b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx @@ -1,12 +1,14 @@ "use client"; -import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core"; +import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; +import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog"; import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection"; import { NoResults } from "~/components/no-results"; import { CustomWidgetRowActions } from "./_custom-widget-actions"; @@ -41,75 +43,92 @@ type WidgetDef = RouterOutputs["customWidget"]["list"][number]; function CustomWidgetRow({ widget }: { widget: WidgetDef }) { const t = useScopedI18n("customWidget"); + const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false); const origins = [...new Set(widget.sources.map((source) => source.origin))]; return ( - - - - } - title={ - - {widget.name} - - } - badges={} - description={ - - {widget.description || t("page.list.noDescription")} - - } - metadata={ - - - {t("page.list.requestCount", { count: widget.requestCount })} + <> + + + + } + title={ + + {widget.name} - {origins.map((origin) => ( - - · {origin} + } + badges={} + description={ + + {widget.description || t("page.list.noDescription")} + + } + metadata={ + + + {t("page.list.requestCount", { count: widget.requestCount })} - ))} - - } - actions={ - - {widget.valid && ( - - )} - - - } - /> + {origins.map((origin) => ( + + · {origin} + + ))} + + } + actions={ + + {widget.valid && ( + + )} + + + } + actionsAlignment="center" + /> + {widget.valid && ( + + )} + ); } /** Everything that decides whether this widget can actually run right now. */ -function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) { +function CustomWidgetStatusBadges({ + widget, + onConfigureSources, +}: { + widget: WidgetDef; + onConfigureSources(): void; +}) { const t = useScopedI18n("customWidget"); if (widget.migrationRequired) { @@ -157,9 +176,17 @@ function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) { multiline maw={320} > - }> - {t("page.list.missingCredentials", { count: widget.missingSecrets.length })} - + + } + style={{ cursor: "pointer" }} + > + {t("page.list.missingCredentials", { count: widget.missingSecrets.length })} + + )} diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-source-setup-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-source-setup-dialog.tsx new file mode 100644 index 000000000..2d686065e --- /dev/null +++ b/apps/nextjs/src/components/custom-widgets/custom-widget-source-setup-dialog.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core"; +import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core"; +import { + createCustomWidgetSourceSetupValues, + CustomWidgetSourceSetupPanel, + isCustomWidgetSourceSetupReady, +} from "@homarr/custom-widgets/workbench"; +import type { + CustomWidgetSourceSetupMessages, + CustomWidgetSourceSetupValue, +} from "@homarr/custom-widgets/workbench"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +interface CustomWidgetSourceSetupDialogProps { + definitionId: string; + opened: boolean; + onClose(): void; +} + +export function CustomWidgetSourceSetupDialog({ + definitionId, + opened, + onClose, +}: CustomWidgetSourceSetupDialogProps) { + const t = useScopedI18n("customWidget"); + const router = useRouter(); + const utils = clientApi.useUtils(); + const definitionQuery = clientApi.customWidget.get.useQuery({ id: definitionId }, { enabled: opened }); + const configureMutation = clientApi.customWidget.sourceConfigure.useMutation(); + const [saving, setSaving] = useState(false); + const [values, setValues] = useState>({}); + const setups = useMemo( + () => getCustomWidgetSourceSetups(definitionQuery.data?.sources ?? {}, definitionQuery.data?.secrets ?? []), + [definitionQuery.data], + ); + + useEffect(() => { + if (opened && definitionQuery.data) setValues(createCustomWidgetSourceSetupValues(setups)); + }, [definitionQuery.data, opened, setups]); + + const messages: CustomWidgetSourceSetupMessages = { + title: t("importReview.sourceSetup.title"), + description: t("sourceSetupDialog.description"), + suggestedUrl: t("importReview.sourceSetup.suggestedUrl"), + baseUrl: t("workbench.sources.baseUrl"), + networkScope: t("workbench.sources.networkScope"), + authentication: t("workbench.sources.authentication"), + confirmUrl: t("importReview.sourceSetup.confirmUrl"), + ready: t("importReview.sourceSetup.ready"), + needsUrl: t("importReview.sourceSetup.needsUrl"), + credentialsMissing: t("importReview.sourceSetup.credentialsMissing"), + credentialsOptional: t("sourceSetupDialog.credentialsRequired"), + configured: t("workbench.sources.configured"), + secret: (kind) => t(`importReview.credentials.field.${kind}`), + urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`), + }; + + const save = async () => { + if (!isCustomWidgetSourceSetupReady(setups, values) || saving) return; + setSaving(true); + try { + for (const setup of setups) { + const value = values[setup.sourceId]; + if (!value) continue; + const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) => + secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [], + ); + await configureMutation.mutateAsync({ + definitionId, + sourceId: setup.sourceId, + baseUrl: value.baseUrl, + networkScope: value.networkScope, + secrets, + }); + } + showSuccessNotification({ + title: t("action.configureSources"), + message: t("notification.sourcesConfigured"), + }); + await utils.customWidget.get.invalidate({ id: definitionId }); + await utils.customWidget.list.invalidate(); + await utils.customWidget.available.invalidate(); + await utils.widget.customApi.getData.invalidate(); + await revalidatePathActionAsync("/manage/custom-widgets"); + router.refresh(); + onClose(); + } catch (error) { + showErrorNotification({ + title: t("action.configureSources"), + message: error instanceof Error ? error.message : t("notification.sourceConfigurationError"), + }); + } finally { + setSaving(false); + } + }; + + return ( + + {definitionQuery.isPending ? ( +
+ +
+ ) : definitionQuery.error ? ( + + {definitionQuery.error.message} + + ) : ( + + setValues((current) => ({ ...current, [sourceId]: value }))} + /> + + + + + + )} +
+ ); +} diff --git a/apps/nextjs/src/components/manage/manage-collection.tsx b/apps/nextjs/src/components/manage/manage-collection.tsx index 1178cca56..6717d8ba4 100644 --- a/apps/nextjs/src/components/manage/manage-collection.tsx +++ b/apps/nextjs/src/components/manage/manage-collection.tsx @@ -37,6 +37,7 @@ export interface ManageCollectionItemProps { metadata?: ReactNode; badges?: ReactNode; actions?: ReactNode; + actionsAlignment?: "flex-start" | "center"; } export const ManageCollectionItem = ({ @@ -46,6 +47,7 @@ export const ManageCollectionItem = ({ metadata, badges, actions, + actionsAlignment = "flex-start", }: ManageCollectionItemProps) => ( @@ -59,7 +61,7 @@ export const ManageCollectionItem = ({ {metadata} {actions && ( - + {actions} )} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 06d6a1f9a..4af3ceb87 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -843,7 +843,7 @@ "migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.", "migrationInstructions": "Open Actions to copy or export the original v1 definition for manual recreation. You can also use the migration prompt with an LLM. Existing board placements keep working after migration.", "missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}", - "missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values." + "missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Select this badge to configure the URL and encrypted values." }, "create": { "title": "Create Custom Widget", @@ -876,6 +876,7 @@ "downloadSkill": "View Custom Widget Skill", "publishWorkshop": "Publish to Workshop", "migrate": "Migrate Custom Widget", + "configureSources": "Configure sources", "copyLegacyDefinition": "Copy original widget content", "exportLegacyDefinition": "Export original widget content", "copyMigrationPrompt": "Copy LLM migration prompt", @@ -889,6 +890,13 @@ "removeItem": "Remove item", "addBar": "Add Bar" }, + "sourceSetupDialog": { + "title": "Configure widget sources", + "description": "Review where this widget connects and add or replace its encrypted credentials.", + "credentialsRequired": "Requests will fail until the required credentials are added.", + "cancel": "Cancel", + "save": "Save configuration" + }, "field": { "name": "Name", "description": "Description", @@ -1240,6 +1248,8 @@ "legacyDefinitionCopyError": "Failed to copy the original widget content.", "migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.", "migrationPromptCopyError": "Failed to copy the migration prompt.", + "sourcesConfigured": "Widget sources and credentials saved.", + "sourceConfigurationError": "Failed to configure widget sources.", "aiPromptCopied": "AI prompt copied to clipboard", "aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).", "aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",