diff --git a/ui/src/components/JsonSchemaForm.test.tsx b/ui/src/components/JsonSchemaForm.test.tsx index 9102bde1..458d2d19 100644 --- a/ui/src/components/JsonSchemaForm.test.tsx +++ b/ui/src/components/JsonSchemaForm.test.tsx @@ -8,6 +8,34 @@ import { JsonSchemaForm } from "./JsonSchemaForm"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +// SecretBindingPicker pulls in CompanyContext + react-query. Stub it so we can +// exercise SecretField in isolation. The stub renders a select with the same +// onChange contract as the real picker. +vi.mock("./SecretBindingPicker", () => ({ + SecretBindingPicker: ({ + value, + onChange, + disabled, + }: { + value: { secretId: string } | null; + onChange: (next: { secretId: string } | null) => void; + disabled?: boolean; + }) => ( + { + const next = event.target.value; + onChange(next ? { secretId: next } : null); + }} + disabled={disabled} + > + none + existing-secret + + ), +})); + describe("JsonSchemaForm secret-ref rendering", () => { let container: HTMLDivElement; @@ -22,7 +50,7 @@ describe("JsonSchemaForm secret-ref rendering", () => { vi.clearAllMocks(); }); - it("renders multiline secret-ref fields as textareas", async () => { + it("renders multiline secret-ref fields as textareas alongside the picker", async () => { const root = createRoot(container); await act(async () => { @@ -44,6 +72,9 @@ describe("JsonSchemaForm secret-ref rendering", () => { ); }); + // The picker is always rendered, and a non-UUID raw value auto-opens the + // textarea fallback. + expect(container.querySelector('[data-testid="secret-binding-picker"]')).not.toBeNull(); expect(container.querySelector("textarea")).not.toBeNull(); expect(container.querySelector('input[type="password"]')).toBeNull(); @@ -51,4 +82,157 @@ describe("JsonSchemaForm secret-ref rendering", () => { root.unmount(); }); }); + + it("renders the picker and hides the raw input when the value is a UUID secret ref", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + />, + ); + }); + + expect( + container.querySelector('[data-testid="secret-binding-picker"]'), + ).not.toBeNull(); + // No raw input or textarea is visible while a secret is bound. + expect(container.querySelector('input[type="password"]')).toBeNull(); + expect(container.querySelector("textarea")).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it("writes the secret id to form values when the picker selects an existing secret", async () => { + const root = createRoot(container); + const onChange = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + const picker = container.querySelector( + '[data-testid="secret-binding-picker"]', + ); + expect(picker).not.toBeNull(); + + const setSelectValue = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value", + )?.set; + expect(setSelectValue).toBeTruthy(); + + await act(async () => { + setSelectValue!.call(picker!, "11111111-1111-4111-8111-111111111111"); + picker!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith({ + apiKey: "11111111-1111-4111-8111-111111111111", + }); + + await act(async () => { + root.unmount(); + }); + }); + + it("auto-opens the raw input when a raw value arrives after mount", async () => { + const root = createRoot(container); + + const schema = { + type: "object" as const, + properties: { + apiKey: { + type: "string" as const, + format: "secret-ref" as const, + }, + }, + }; + + // First render with empty value — picker visible, no raw input. + await act(async () => { + root.render( + {}} />, + ); + }); + expect(container.querySelector('input[type="password"]')).toBeNull(); + + // Parent fills in a previously-saved raw value (the async load case). + await act(async () => { + root.render( + {}} + />, + ); + }); + + const input = container.querySelector('input[type="password"]'); + expect(input).not.toBeNull(); + expect(input?.value).toBe("loaded-from-api"); + + await act(async () => { + root.unmount(); + }); + }); + + it("keeps the password fallback for short raw values", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + />, + ); + }); + + const input = container.querySelector( + 'input[type="password"]', + ); + expect(input).not.toBeNull(); + expect(input?.value).toBe("raw-value"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx index e2926299..458f5a5f 100644 --- a/ui/src/components/JsonSchemaForm.tsx +++ b/ui/src/components/JsonSchemaForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDown, ChevronRight, @@ -7,6 +7,7 @@ import { Plus, Trash2, } from "lucide-react"; +import { isUuidLike } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -20,6 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker"; // --------------------------------------------------------------------------- // Constants @@ -467,7 +469,10 @@ const EnumField = React.memo(({ EnumField.displayName = "EnumField"; /** - * Specialized field for secret-ref values, providing a toggleable password input. + * Specialized field for secret-ref values. Renders a picker for existing + * company secrets plus a raw-value fallback. A UUID-shaped value is treated + * as a bound secret reference; anything else is a raw value that the server + * converts to a stored secret on save. */ const SecretField = React.memo(({ value, @@ -492,94 +497,168 @@ const SecretField = React.memo(({ }) => { const [isVisible, setIsVisible] = useState(false); const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD; + + const stringValue = typeof value === "string" ? value : ""; + const trimmed = stringValue.trim(); + const isBoundToSecret = trimmed.length > 0 && isUuidLike(trimmed); + const hasRawValue = stringValue.length > 0 && !isBoundToSecret; + + const [showRawInput, setShowRawInput] = useState(hasRawValue); + + // Keep the raw-input panel open when the parent loads a raw value after + // mount (e.g. an environment-config form rendering with empty defaults + // before its API response arrives). We only promote to `true` here; manual + // toggles off are still preserved as long as `hasRawValue` is false. + useEffect(() => { + if (hasRawValue) setShowRawInput(true); + }, [hasRawValue]); + + const bindingValue: SecretBindingValue | null = isBoundToSecret + ? { secretId: trimmed } + : null; + + const handlePickerChange = useCallback( + (next: SecretBindingValue | null) => { + if (next) { + onChange(next.secretId); + setShowRawInput(false); + setIsVisible(false); + } else { + onChange(""); + } + }, + [onChange], + ); + + const rawInput = isTextArea ? ( + + {isVisible ? ( + onChange(e.target.value)} + placeholder={String(defaultValue ?? "")} + disabled={disabled} + className="min-h-[140px] pr-10 font-mono text-xs" + aria-invalid={!!error} + /> + ) : ( + + )} + setIsVisible(!isVisible)} + disabled={disabled} + > + {isVisible ? ( + + ) : ( + + )} + + {isVisible ? "Hide secret" : "Show secret"} + + + + ) : ( + + onChange(e.target.value)} + placeholder={String(defaultValue ?? "")} + disabled={disabled} + className="pr-10" + aria-invalid={!!error} + /> + setIsVisible(!isVisible)} + disabled={disabled} + > + {isVisible ? ( + + ) : ( + + )} + + {isVisible ? "Hide secret" : "Show secret"} + + + + ); + return ( - {isTextArea ? ( - - {isVisible ? ( - onChange(e.target.value)} - placeholder={String(defaultValue ?? "")} - disabled={disabled} - className="min-h-[140px] pr-10 font-mono text-xs" - aria-invalid={!!error} - /> + + + {!isBoundToSecret ? ( + showRawInput ? ( + + {rawInput} + {!hasRawValue ? ( + { + setShowRawInput(false); + setIsVisible(false); + }} + disabled={disabled} + > + Hide raw value input + + ) : null} + ) : ( - setShowRawInput(true)} disabled={disabled} - className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground" - aria-invalid={!!error} - /> - )} - setIsVisible(!isVisible)} - disabled={disabled} - > - {isVisible ? ( - - ) : ( - - )} - - {isVisible ? "Hide secret" : "Show secret"} - - - - ) : ( - - onChange(e.target.value)} - placeholder={String(defaultValue ?? "")} - disabled={disabled} - className="pr-10" - aria-invalid={!!error} - /> - setIsVisible(!isVisible)} - disabled={disabled} - > - {isVisible ? ( - - ) : ( - - )} - - {isVisible ? "Hide secret" : "Show secret"} - - - - )} + > + Or paste a raw value + + ) + ) : null} + ); });