fix(custom-widgets): configure secrets during import
This commit is contained in:
16 files changed
+331
-93
No files matched your search
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user