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 -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}