import { toast } from "@unom/ui/toast"; import { type FC, useEffect, useState } from "react"; import type { ScannerInfo } from "@/api/gen/model/scannerInfo"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { m } from "@/paraglide/messages"; /** * A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema. * * The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves * `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything * goes through the existing session-gated `/plugin-ui//…` proxy, so there is **zero new host * surface** — the browser never learns the plugin's port or secret. * * Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds * the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still * validates by decode host-side either way. */ export const SourceSettingsDialog: FC<{ source: ScannerInfo; onClose: () => void; }> = ({ source, onClose }) => { const pluginId = source.provider ?? source.id; const [state, setState] = useState< | { tag: "loading" } | { tag: "error"; message: string } | { tag: "ready"; schema: JsonSchemaDoc | null; value: JsonObject } >({ tag: "loading" }); const [raw, setRaw] = useState(""); const [saving, setSaving] = useState(false); useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch(`/plugin-ui/${pluginId}/__config`, { credentials: "same-origin", }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = (await res.json()) as { schema: JsonSchemaDoc | null; value: JsonObject | null; }; if (cancelled) return; const value = body.value ?? {}; setState({ tag: "ready", schema: body.schema, value }); setRaw(JSON.stringify(value, null, 2)); } catch (e) { if (!cancelled) { setState({ tag: "error", message: String(e) }); } } })(); return () => { cancelled = true; }; }, [pluginId]); const save = async (value: JsonObject) => { setSaving(true); try { const res = await fetch(`/plugin-ui/${pluginId}/__config`, { method: "PUT", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify(value), }); if (!res.ok) { const body = (await res.json().catch(() => null)) as { issue?: string; } | null; throw new Error(body?.issue ?? `HTTP ${res.status}`); } toast.success(m.library_source_settings_saved()); onClose(); } catch (e) { toast.error(m.library_source_settings_failed({ issue: String(e) })); } finally { setSaving(false); } }; return ( !open && onClose()}> {m.library_source_settings_title({ source: source.label })} {state.tag === "loading" && } {state.tag === "error" && (

{m.library_source_settings_unreachable({ issue: state.message })}

)} {state.tag === "ready" && ( )}
); }; type JsonObject = Record; interface JsonSchemaNode { type?: string; title?: string; description?: string; default?: unknown; enum?: string[]; properties?: Record; items?: JsonSchemaNode; allOf?: JsonSchemaNode[]; } interface JsonSchemaDoc { schema?: JsonSchemaNode; } /** * Flatten a node's `allOf` branches into it. A *checked* schema (effect's `Schema.Int`, or anything * with `.check(...)`) nests its annotations and constraints there rather than at the top level, so * a form that only reads the top level silently loses every title and default on those fields. */ const flatten = (node: JsonSchemaNode): JsonSchemaNode => (node.allOf ?? []).reduce( (acc, branch) => ({ ...acc, ...branch }), { ...node }, ); /** Can this field be rendered as a real input? Anything else sends the whole form to the editor. */ const renderable = (node: JsonSchemaNode): boolean => { const n = flatten(node); if (n.enum) return true; if (n.type === "boolean" || n.type === "string") return true; if (n.type === "number" || n.type === "integer") return true; if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true; if (n.type === "object" && n.properties) { return Object.values(n.properties).every(renderable); } return false; }; const ConfigForm: FC<{ schema: JsonSchemaDoc | null; value: JsonObject; raw: string; onRaw: (v: string) => void; saving: boolean; onSave: (value: JsonObject) => void; }> = ({ schema, value, raw, onRaw, saving, onSave }) => { const [draft, setDraft] = useState(value); const root = schema?.schema ? flatten(schema.schema) : undefined; const props = root?.properties; // Fall back to the JSON editor when there is no schema, or any field is a shape the generic // form can't express (a non-enum union, a $ref). Partial rendering would be worse than none: // a field silently missing from the form is a setting the operator cannot change. const canRender = props !== undefined && Object.values(props).every(renderable); if (!canRender) { return (

{m.library_source_settings_json_hint()}