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}`)}
/>
</>
);
}
@@ -4,6 +4,7 @@ import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
import { getCustomWidgetSecretRequirements } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
@@ -21,9 +22,11 @@ export const managementQueryProcedures = {
.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
with: { secrets: true },
});
return definitions.map((definition) => {
const widget = parseStoredCustomWidgetDefinition(definition);
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
id: definition.id,
name: widget.name,
@@ -37,6 +40,9 @@ export const managementQueryProcedures = {
authType: auth.type,
})),
requestCount: widget.requests.length,
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
),
defaultOptions: widget.defaultOptions,
updatedAt: definition.updatedAt,
enabled: definition.enabled,
@@ -1,11 +1,9 @@
import { TRPCError } from "@trpc/server";
import { getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
export function requiredSecretKinds(authType: string) {
if (authType === "basic") return ["username", "password"] as const;
if (authType === "bearer" || authType === "apiKeyHeader" || authType === "apiKeyQuery") {
return ["apiKey"] as const;
}
return [] as const;
return getCustomWidgetRequiredSecretKinds(authType as Parameters<typeof getCustomWidgetRequiredSecretKinds>[0]);
}
export function assertSecretSources(
@@ -2,12 +2,14 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { assertSecretSources } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
@@ -22,14 +24,34 @@ export const transferProcedures = {
return parseStoredCustomWidgetDefinition(definition);
}),
import: manageProcedure.input(customWidgetImportSchema).mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition(input),
creatorId: ctx.session.user.id,
});
logger.info("Imported custom widget definition", { id, name: input.name });
return { id };
}),
import: manageProcedure
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
.mutation(async ({ ctx, input }) => {
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing imported widget secrets requires dedicated permission",
});
}
assertSecretSources(input.widget.sources, input.secrets);
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition(input.widget),
creatorId: ctx.session.user.id,
});
if (input.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
input.secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
})),
);
}
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
};
+1 -1
View File
@@ -208,7 +208,7 @@ export function formatCustomWidgetImportIssues(issues: readonly CustomWidgetImpo
return `${visible.join(" ")}${remaining > 0 ? ` (+${remaining} more)` : ""}`;
}
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
export function getImportReview(value: unknown): ImportReview | null {
const parsed = customWidgetImportSchema.safeParse(value);
if (!parsed.success) return null;
const widget = parsed.data;
@@ -7,4 +7,5 @@ export * from "./import";
export * from "./options";
export * from "./schema";
export * from "./schema-references";
export * from "./secret-requirements";
export * from "./tabler-icons";
@@ -0,0 +1,37 @@
import type { CustomWidgetSource } from "./request-schema";
import type { CustomWidgetSecretKind } from "./schema-types";
export interface CustomWidgetSecretRequirement {
sourceId: string;
sourceName: string;
authType: CustomWidgetSource["auth"]["type"];
kind: CustomWidgetSecretKind;
destination?: string;
}
export function getCustomWidgetRequiredSecretKinds(authType: CustomWidgetSource["auth"]["type"]) {
if (authType === "basic") return ["username", "password"] as const;
if (authType === "bearer" || authType === "apiKeyHeader" || authType === "apiKeyQuery") {
return ["apiKey"] as const;
}
return [] as const;
}
export function getCustomWidgetSecretRequirements(
sources: readonly CustomWidgetSource[],
): CustomWidgetSecretRequirement[] {
return sources.flatMap((source) =>
getCustomWidgetRequiredSecretKinds(source.auth.type).map((kind) => ({
sourceId: source.id,
sourceName: source.name,
authType: source.auth.type,
kind,
destination:
source.auth.type === "apiKeyHeader"
? source.auth.headerName
: source.auth.type === "apiKeyQuery"
? source.auth.parameterName
: undefined,
})),
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ export const CUSTOM_JSX_LIMITS = Object.freeze({
templateLength: 50_000,
astDepth: 64,
operations: 25_000,
collectionItems: 2_000,
collectionItems: 4_000,
renderedNodes: 10_000,
stringLength: 200_000,
});
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS } from "../core/bundled-widgets";
import { customJsxExamples, CUSTOM_WIDGET_STARTER } from "../core/examples";
import { getCustomWidgetSecretRequirements } from "../core/secret-requirements";
import { customJsxRequestSchema, customWidgetCreateSchema, customWidgetDefinitionSchema } from "../core/schema";
import { validateCustomWidgetOptions } from "../core/options";
@@ -27,6 +28,39 @@ describe("Custom JSX v2 validation", () => {
}
});
test("derives every credential required by widget sources", () => {
expect(
getCustomWidgetSecretRequirements([
{
id: "default",
name: "Example API",
baseUrl: "https://api.example.com",
networkScope: "public",
auth: { type: "bearer" },
},
{
id: "portainer",
name: "Portainer",
baseUrl: "https://portainer.example.com",
networkScope: "public",
auth: { type: "apiKeyHeader", headerName: "X-API-Key" },
},
{
id: "basic",
name: "Basic API",
baseUrl: "https://basic.example.com",
networkScope: "public",
auth: { type: "basic" },
},
]),
).toEqual([
expect.objectContaining({ sourceId: "default", kind: "apiKey", authType: "bearer" }),
expect.objectContaining({ sourceId: "portainer", kind: "apiKey", destination: "X-API-Key" }),
expect.objectContaining({ sourceId: "basic", kind: "username" }),
expect.objectContaining({ sourceId: "basic", kind: "password" }),
]);
});
test("rejects every legacy schema", () => {
expect(
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, $schema: "homarr-custom-widget-v3" }).success,
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { Alert, Badge, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
@@ -24,6 +25,7 @@ export interface ImportReviewDialogProps {
review: ImportReview | null;
pending: boolean;
messages: ImportReviewMessages;
children?: ReactNode;
onClose(): void;
onConfirm(): void;
}
@@ -39,7 +41,15 @@ function ImportFact({ label, value }: { label: string; value: string }) {
);
}
export function ImportReviewDialog({ opened, review, pending, messages, onClose, onConfirm }: ImportReviewDialogProps) {
export function ImportReviewDialog({
opened,
review,
pending,
messages,
children,
onClose,
onConfirm,
}: ImportReviewDialogProps) {
return (
<Modal opened={opened} onClose={onClose} title={messages.title} centered size="lg">
{review && (
@@ -89,6 +99,7 @@ export function ImportReviewDialog({ opened, review, pending, messages, onClose,
<Text size="sm">{messages.actionWarningDescription}</Text>
</Alert>
)}
{children}
<Group justify="flex-end">
<Button type="button" variant="default" onClick={onClose} disabled={pending}>
{messages.cancel}
+14 -1
View File
@@ -722,7 +722,9 @@
"list": {
"title": "Custom Widgets",
"noResults": "No custom widgets yet. Create your first one!",
"loading": "Loading..."
"loading": "Loading...",
"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."
},
"create": {
"title": "Create Custom Widget",
@@ -973,6 +975,17 @@
"networkScope": "Network scope",
"methods": "HTTP methods",
"permissions": "Required board permissions",
"credentials": {
"title": "Configure credentials now",
"description": "Secrets are encrypted locally and never added to the imported widget. You can leave a field blank and configure it later.",
"header": "Sent through the {name} header",
"query": "Sent through the {name} query parameter",
"field": {
"apiKey": "API key or token",
"username": "Username",
"password": "Password"
}
},
"actionWarning": {
"title": "This widget can change remote data",
"description": "Review every named action, inherited credential, and destructive confirmation after import. Secrets are not included in the file."