diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx index 9dbec4377..66a965fac 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx @@ -46,6 +46,7 @@ import { } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { CUSTOM_JSX_METHOD_COLORS } from "@homarr/widgets/custom-api/custom-jsx-display"; import { useScopedI18n } from "@homarr/translation/client"; import { displayComponents } from "@homarr/widgets/custom-api/component"; import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data"; @@ -95,8 +96,7 @@ interface CustomWidgetPreviewProps { onSampleDataChange?: (value: unknown) => void; } -const METHOD_COLORS: Record = { DELETE: "red", GET: "blue" }; -const methodColor = (method: string) => METHOD_COLORS[method] ?? "orange"; +const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray"; function journalEntryColor(entry: { simulated: boolean; status: number | null }): string { if (entry.simulated) return "yellow"; diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts index 277d5ab6b..fed36229c 100644 --- a/packages/api/src/router/custom-widget/custom-widget-router.ts +++ b/packages/api/src/router/custom-widget/custom-widget-router.ts @@ -7,7 +7,7 @@ import { z } from "zod/v4"; import { createId } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { boards, customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; -import { eq } from "@homarr/db"; +import { eq, or } from "@homarr/db"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { customWidgetAuthTypes, @@ -107,7 +107,6 @@ export const customWidgetRouter = createTRPCRouter({ .query(() => getImportJsonSchema()), validate: adminProcedure - .input(z.object({ widget: z.unknown() })) .meta({ mcp: { enabled: true, @@ -115,6 +114,7 @@ export const customWidgetRouter = createTRPCRouter({ "Validate a complete custom-widget JSON draft without saving or making network requests. REQUIRED: widget (the homarr-custom-widget-v3 object). Returns structured issue paths and messages so an agent can correct the draft and validate again.", }, }) + .input(z.object({ widget: z.unknown() })) .query(({ input }) => { const result = customWidgetImportSchema.safeParse(input.widget); if (!result.success) { @@ -161,7 +161,6 @@ export const customWidgetRouter = createTRPCRouter({ }), byId: adminProcedure - .input(z.object({ id: z.string() })) .meta({ mcp: { enabled: true, @@ -169,6 +168,7 @@ export const customWidgetRouter = createTRPCRouter({ "Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.", }, }) + .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id), @@ -198,23 +198,26 @@ export const customWidgetRouter = createTRPCRouter({ }; }), - available: protectedProcedure.input(z.object({ boardId: z.string() })).query(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); - return ctx.db.query.customWidgetDefinitions.findMany({ - where: eq(customWidgetDefinitions.enabled, true), - orderBy: (table, { asc }) => asc(table.name), - columns: { - id: true, - name: true, - description: true, - iconUrl: true, - displayType: true, - }, - }); - }), + available: protectedProcedure + .input(z.object({ boardId: z.string(), currentId: z.string().optional() })) + .query(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + return ctx.db.query.customWidgetDefinitions.findMany({ + where: input.currentId + ? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId)) + : eq(customWidgetDefinitions.enabled, true), + orderBy: (table, { asc }) => asc(table.name), + columns: { + id: true, + name: true, + description: true, + iconUrl: true, + displayType: true, + }, + }); + }), create: adminProcedure - .input(customWidgetCreateSchema) .meta({ mcp: { enabled: true, @@ -222,6 +225,7 @@ export const customWidgetRouter = createTRPCRouter({ "Create a validated custom widget. Admin only. Call customWidget_schema and customWidget_validate first. For Custom JSX use jsxApiVersion 2, named requests, a GET base method, and no inline credentials.", }, }) + .input(customWidgetCreateSchema) .mutation(async ({ ctx, input }) => { const id = createId(); @@ -256,7 +260,6 @@ export const customWidgetRouter = createTRPCRouter({ }), update: adminProcedure - .input(customWidgetUpdateSchema) .meta({ mcp: { enabled: true, @@ -264,6 +267,7 @@ export const customWidgetRouter = createTRPCRouter({ "Update an existing custom widget. Admin only. REQUIRED: id. Read it with customWidget_byId, preserve unrelated fields, validate the resulting complete draft, then send the changed fields. Omit secrets to preserve stored credentials.", }, }) + .input(customWidgetUpdateSchema) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id), @@ -354,7 +358,6 @@ export const customWidgetRouter = createTRPCRouter({ }), readTemplate: adminProcedure - .input(z.object({ id: z.string() })) .meta({ mcp: { enabled: true, @@ -362,6 +365,7 @@ export const customWidgetRouter = createTRPCRouter({ "Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.", }, }) + .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id), @@ -396,6 +400,13 @@ export const customWidgetRouter = createTRPCRouter({ }), writeTemplate: adminProcedure + .meta({ + mcp: { + enabled: true, + description: + "Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.", + }, + }) .input( z .object({ @@ -410,13 +421,6 @@ export const customWidgetRouter = createTRPCRouter({ message: "Provide template or templateLines, not both", }), ) - .meta({ - mcp: { - enabled: true, - description: - "Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.", - }, - }) .mutation(async ({ ctx, input }) => { const resolvedTemplate = input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? ""); @@ -467,6 +471,13 @@ export const customWidgetRouter = createTRPCRouter({ }), patchTemplate: adminProcedure + .meta({ + mcp: { + enabled: true, + description: + "Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.", + }, + }) .input( z.object({ id: z.string(), @@ -483,13 +494,6 @@ export const customWidgetRouter = createTRPCRouter({ .max(100), }), ) - .meta({ - mcp: { - enabled: true, - description: - "Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.", - }, - }) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id), @@ -545,7 +549,6 @@ export const customWidgetRouter = createTRPCRouter({ }), export: adminProcedure - .input(z.object({ id: z.string() })) .meta({ mcp: { enabled: true, @@ -553,6 +556,7 @@ export const customWidgetRouter = createTRPCRouter({ "Export one custom widget as a homarr-custom-widget-v3 object suitable for validation, modification, and re-import. REQUIRED: id. Secrets are excluded.", }, }) + .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id), @@ -583,7 +587,6 @@ export const customWidgetRouter = createTRPCRouter({ }), import: adminProcedure - .input(customWidgetImportSchema) .meta({ mcp: { enabled: true, @@ -591,6 +594,7 @@ export const customWidgetRouter = createTRPCRouter({ "Create a custom widget directly from a complete homarr-custom-widget-v3 import object. Admin only. Use customWidget_validate first. This is the preferred creation tool for AI-generated authoring bundles because secrets are omitted and configured separately.", }, }) + .input(customWidgetImportSchema) .mutation(async ({ ctx, input }) => { const id = createId(); @@ -614,6 +618,13 @@ export const customWidgetRouter = createTRPCRouter({ }), preview: adminProcedure + .meta({ + mcp: { + enabled: true, + description: + "Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.", + }, + }) .input( z.object({ url: z.string().url(), @@ -627,13 +638,6 @@ export const customWidgetRouter = createTRPCRouter({ definitionId: z.string().optional(), }), ) - .meta({ - mcp: { - enabled: true, - description: - "Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.", - }, - }) .mutation(async ({ ctx, input }) => { const secrets = [...input.secrets]; @@ -744,7 +748,6 @@ export const customWidgetRouter = createTRPCRouter({ }), previewQuery: adminProcedure - .input(previewSessionRequestSchema) .meta({ mcp: { enabled: true, @@ -752,6 +755,7 @@ export const customWidgetRouter = createTRPCRouter({ "Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.", }, }) + .input(previewSessionRequestSchema) .query(async ({ ctx, input }) => { const session = await getPreviewSession(input.sessionId, ctx.session.user.id); const request = session.requests.find( @@ -813,7 +817,6 @@ export const customWidgetRouter = createTRPCRouter({ ), previewJournal: adminProcedure - .input(z.object({ sessionId: z.string().min(1) })) .meta({ mcp: { enabled: true, @@ -821,17 +824,18 @@ export const customWidgetRouter = createTRPCRouter({ "Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.", }, }) + .input(z.object({ sessionId: z.string().min(1) })) .query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)), simulatePreviewAction: adminProcedure - .input(previewSessionRequestSchema) .meta({ mcp: { enabled: true, description: - "Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. This MCP tool never enables or executes live actions.", + "Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.", }, }) + .input(previewSessionRequestSchema) .mutation(async ({ ctx, input }) => { const session = await getPreviewSession(input.sessionId, ctx.session.user.id); const request = session.requests.find( diff --git a/packages/definitions/src/custom-jsx.ts b/packages/definitions/src/custom-jsx.ts index 18fe2fd0c..50e2f60a0 100644 --- a/packages/definitions/src/custom-jsx.ts +++ b/packages/definitions/src/custom-jsx.ts @@ -291,7 +291,11 @@ const componentSafeProps: Readonly> = { function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string { if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/"; const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates"; - return `https://mantine.dev/${section}/${name.split(".")[0]?.toLowerCase() ?? ""}/`; + const slug = (name.split(".")[0] ?? "") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") + .toLowerCase(); + return `https://mantine.dev/${section}/${slug}/`; } const describe = ( diff --git a/packages/validation/src/custom-widget.ts b/packages/validation/src/custom-widget.ts index 28a88a0d4..eaab0586d 100644 --- a/packages/validation/src/custom-widget.ts +++ b/packages/validation/src/custom-widget.ts @@ -441,7 +441,7 @@ const customJsxTemplateSchema = z } }) .describe( - "JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct network access is unavailable; v2 templates reference named requests.", + "JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct fetch and XMLHttpRequest access are unavailable; v2 templates reference named requests.", ); const customJsxDisplayConfigV1Schema = z.object({ diff --git a/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx b/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx index 4eff78db5..035c0617e 100644 --- a/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx @@ -18,14 +18,13 @@ export const WidgetCustomWidgetSelectInput = ({ const t = useWidgetInputTranslation(kind, property); const form = useFormContext(); const board = useOptionalBoard(); + const currentValue = form.values.options[property] as string; const { data, isLoading } = clientApi.customWidget.available.useQuery( - { boardId: board?.id ?? "" }, + { boardId: board?.id ?? "", currentId: currentValue || undefined }, { enabled: board !== null }, ); const [search, setSearch] = useState(""); - const currentValue = form.values.options[property] as string; - const definitions = useMemo(() => (data ?? []).map((def) => ({ value: def.id, label: def.name })), [data]); const filteredOptions = useMemo( diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx index c6bf40744..26f26009d 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.tsx +++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx @@ -20,7 +20,7 @@ function appendParseError(prev: string[], message: string): string[] { return [...prev, message]; } -const METHOD_COLORS: Record = { +export const CUSTOM_JSX_METHOD_COLORS: Record = { GET: "blue", POST: "orange", PUT: "yellow", @@ -173,7 +173,7 @@ export default function CustomJsxDisplay({ data }: { data: Record - + {capability.method} diff --git a/packages/widgets/src/custom-api/jsx-sub-fetch.tsx b/packages/widgets/src/custom-api/jsx-sub-fetch.tsx index ff2f95dbf..82b01f9da 100644 --- a/packages/widgets/src/custom-api/jsx-sub-fetch.tsx +++ b/packages/widgets/src/custom-api/jsx-sub-fetch.tsx @@ -185,7 +185,7 @@ export function SubFetch({ const result = query.data as RequestResult | undefined; const requestError = - (query.error ? t("requestFailed") : null) ?? + (query.error && !result ? t("requestFailed") : null) ?? (!result?.ok ? (result?.error ?? `${t("requestFailed")} (${result?.status ?? 0})`) : null); if (!result && query.isPending) { diff --git a/packages/widgets/src/custom-api/jsx-whitelist.ts b/packages/widgets/src/custom-api/jsx-whitelist.ts index aadb16925..fbe56f57b 100644 --- a/packages/widgets/src/custom-api/jsx-whitelist.ts +++ b/packages/widgets/src/custom-api/jsx-whitelist.ts @@ -239,13 +239,25 @@ function isSafeUrl(url: unknown): boolean { function SafeAnchor(props: Record) { const href = props.href; const safeHref = href && isSafeUrl(href) ? href : undefined; - return createElement(Anchor as never, { ...sanitizeCustomJsxProps(props), href: safeHref }); + const sanitized = sanitizeCustomJsxProps(props); + const opensNewWindow = sanitized.target === "_blank"; + return createElement(Anchor as never, { + ...sanitized, + href: safeHref, + rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel, + }); } function SafeNavLink(props: Record) { const href = props.href; const safeHref = href && isSafeUrl(href) ? href : undefined; - return createElement(NavLink as never, { ...sanitizeCustomJsxProps(props), href: safeHref }); + const sanitized = sanitizeCustomJsxProps(props); + const opensNewWindow = sanitized.target === "_blank"; + return createElement(NavLink as never, { + ...sanitized, + href: safeHref, + rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel, + }); } function SafeButton(props: Record) { @@ -301,7 +313,13 @@ function SafeCalendar(props: Record) { } function SafeMiniCalendar(props: Record) { - return createElement(MiniCalendar as never, { ...sanitizeCustomJsxProps(props), static: true }); + return createElement(MiniCalendar as never, { + ...sanitizeCustomJsxProps(props), + style: { pointerEvents: "none" }, + previousControlProps: { disabled: true, tabIndex: -1 }, + nextControlProps: { disabled: true, tabIndex: -1 }, + getDayProps: () => ({ disabled: true, tabIndex: -1 }), + }); } function SafeTree(props: Record) { @@ -309,7 +327,12 @@ function SafeTree(props: Record) { } function SafeDatePicker(props: Record) { - return createElement(DatePicker as never, { ...sanitizeCustomJsxProps(props), static: true }); + return createElement(DatePicker as never, { + ...sanitizeCustomJsxProps(props), + style: { pointerEvents: "none" }, + enableKeyboardNavigation: false, + getDayProps: () => ({ disabled: true, tabIndex: -1 }), + }); } function createSafeDateComponent(component: ComponentType) { @@ -730,7 +753,7 @@ safeDate.toLocaleTimeString = createSafeCallable((v: string | number, locale?: s safeDate.getTime = createSafeCallable((v: string | number) => new Date(v).getTime()); safeDate.getYear = createSafeCallable((v: string | number) => new Date(v).getFullYear()); safeDate.getMonth = createSafeCallable((v: string | number) => new Date(v).getMonth()); -safeDate.getDay = createSafeCallable((v: string | number) => new Date(v).getDay()); +safeDate.getDay = createSafeCallable((v: string | number) => new Date(v).getUTCDay()); Object.freeze(safeDate); export const SAFE_BINDINGS = (apiData: unknown) => ({ diff --git a/packages/widgets/src/custom-api/safe-jsx-interpreter.tsx b/packages/widgets/src/custom-api/safe-jsx-interpreter.tsx index 950630356..399b28969 100644 --- a/packages/widgets/src/custom-api/safe-jsx-interpreter.tsx +++ b/packages/widgets/src/custom-api/safe-jsx-interpreter.tsx @@ -838,7 +838,7 @@ class Interpreter { case "charAt": return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) }; case "split": { - const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 100))); + const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 2_000))); const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit); this.budget.collection(split.length); return { handled: true, value: split };