fix(custom-widgets): configure secrets during import
This commit is contained in:
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 };
|
||||
}),
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user