diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 1fb51cf83..d6bf9be31 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -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. diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index ca74d69f8..710f133d7 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -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(null); - const [pendingImport, setPendingImport] = useState | null>(null); + const [pendingImport, setPendingImport] = useState(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) => { + (value: HomarrCustomWidgetV2) => { setPendingImport(value); openReview(); }, @@ -213,7 +199,6 @@ export const ImportCustomWidgetButton = () => { variant="default" leftSection={} 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")} /> - 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); }} /> 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 fa040325f..be555a2f8 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,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 }) { JSX · {widget.requestCount} + {widget.missingSecrets.length > 0 && ( + + } + style={{ flexShrink: 0 }} + > + {t("page.list.missingCredentials", { count: widget.missingSecrets.length })} + + + )} diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx new file mode 100644 index 000000000..af96d0388 --- /dev/null +++ b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx @@ -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>({}); + 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 ( + t(`preview.request.permission.${permission}` as never), + }} + > + {requirements.length > 0 && ( + + }> + + {t("importReview.credentials.title")} + + {t("importReview.credentials.description")} + + {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 ( + setValue(requirement.sourceId, requirement.kind, event.currentTarget.value)} + autoComplete="off" + /> + ); + })} + + )} + + ); +} diff --git a/apps/nextjs/src/components/workshop/workshop-install-button.tsx b/apps/nextjs/src/components/workshop/workshop-install-button.tsx index 40f58022d..40a2e79d2 100644 --- a/apps/nextjs/src/components/workshop/workshop-install-button.tsx +++ b/apps/nextjs/src/components/workshop/workshop-install-button.tsx @@ -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(null); return ( <>