import type { GeneratePasswordSpec, HumanInputQuestion, HumanInputSecretSaveTo, HumanInputSecretSpec, } from "@opengeni/sdk"; import { EyeIcon, EyeOffIcon, TriangleAlertIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { resolveSecretSaveTo, secretAutocomplete, secretCanGenerate, secretConcealed, secretFieldLabel, secretInputMode, secretPatternMismatch, secretVaultForTarget, type HumanInputFormMessages, type SecretFieldReading, } from "../human-input-model"; import { cn } from "../lib/cn"; import { useSecretsUi } from "../secrets-ui"; import { useOpenGeniText } from "../messages/runtime"; /** Read and clear one secret field at submit time; the value never enters React state. */ export type SecretFieldHandle = { read: () => SecretFieldReading; clear: () => void; focus: () => void; }; const INPUT_CLASS = "h-10 w-full min-w-0 rounded-og-md border border-og-border bg-og-surface-1 px-3 text-og-sm text-og-fg outline-hidden placeholder:text-og-fg-subtle focus:border-og-accent disabled:opacity-50 pointer-coarse:h-11"; export type SecretQuestionFieldProps = { question: HumanInputQuestion & { secret: HumanInputSecretSpec }; fieldId: string; messages: HumanInputFormMessages; autoFocus: boolean; /** Save to targets this person may write. Defaults to all three. */ allowedSaveTo?: readonly HumanInputSecretSaveTo[] | undefined; error?: string | undefined; /** Question prompt chrome rendered by the form (multi-question cards). */ describedBy?: string | undefined; register: (handle: SecretFieldHandle | null) => void; /** The person changed the field: clears a stale validation error. */ onEdited: () => void; }; /** * The ask card's secret field: an uncontrolled password input (read through a * ref, so the value never reaches React state, props, logs or storage) with * show/hide, optional Generate, an optional username placed first so password * managers pair the two, soft `looksLike`/`pattern` hints, and Save to. */ export function SecretQuestionField({ question, fieldId, messages, autoFocus, allowedSaveTo, error, describedBy, register, onEdited, }: SecretQuestionFieldProps) { const t = useOpenGeniText(); const spec = question.secret; const ui = useSecretsUi(); const inputRef = useRef(null); const usernameRef = useRef(null); const saveToChoice = resolveSecretSaveTo(spec, allowedSaveTo); const [saveTo, setSaveTo] = useState(saveToChoice.initial); const saveToRef = useRef(saveTo); saveToRef.current = saveTo; const [revealed, setRevealed] = useState(false); /** No client generator: the server generates, nothing is typed. */ const [serverGenerate, setServerGenerate] = useState(null); const serverGenerateRef = useRef(serverGenerate); serverGenerateRef.current = serverGenerate; const [patternMismatch, setPatternMismatch] = useState(false); const [touched, setTouched] = useState(false); const concealed = secretConcealed(spec); const canGenerate = secretCanGenerate(spec); const autoComplete = secretAutocomplete(spec); const inputMode = secretInputMode(spec); const label = secretFieldLabel(t, spec); const inputId = `${fieldId}-secret`; const usernameId = `${fieldId}-username`; const saveToId = `${fieldId}-save-to`; const hintId = `${fieldId}-looks-like`; const warningId = `${fieldId}-pattern`; const errorId = `${fieldId}-error`; const vaultHintId = `${fieldId}-vault`; const downgradeId = `${fieldId}-downgrade`; const vault = secretVaultForTarget(spec, saveTo); const showWarning = patternMismatch && touched && !serverGenerate; // A callback ref clears an input as it leaves the DOM (unmount, or the // server-generate swap), so the browser never keeps a detached value. const attachInput = useCallback((node: HTMLInputElement | null) => { if (!node && inputRef.current) inputRef.current.value = ""; inputRef.current = node; }, []); const checkPattern = useCallback(() => { setPatternMismatch(secretPatternMismatch(spec.pattern, inputRef.current?.value ?? "")); }, [spec.pattern]); useEffect(() => { register({ read: () => ({ value: serverGenerateRef.current ? null : inputRef.current?.value || null, generate: serverGenerateRef.current, username: usernameRef.current?.value ?? null, saveTo: saveToRef.current, }), clear: () => { if (inputRef.current) inputRef.current.value = ""; setRevealed(false); setPatternMismatch(false); }, focus: () => (inputRef.current ?? usernameRef.current)?.focus({ preventScroll: true }), }); return () => register(null); }, [register]); const generate = () => { onEdited(); if (ui) { const input = inputRef.current; if (!input) return; try { input.value = ui.generatePassword(spec.generate ?? undefined); setRevealed(true); setTouched(true); checkPattern(); return; } catch { // A spec the client generator rejects: let the server generate it. } } setServerGenerate(spec.generate ?? {}); setRevealed(false); }; const undoServerGenerate = () => { onEdited(); setServerGenerate(null); requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true })); }; const inputDescribedBy = [ describedBy, spec.looksLike ? hintId : null, showWarning ? warningId : null, error ? errorId : null, ] .filter(Boolean) .join(" ") || undefined; const focusUsername = autoFocus && Boolean(spec.username) && !spec.username?.value; return (
{spec.username ? (
) : null}
{serverGenerate ? (
{messages.secretWillGenerate}
) : (
{ onEdited(); checkPattern(); }} onBlur={() => { setTouched(true); checkPattern(); }} className={cn( INPUT_CLASS, concealed && "pr-11 pointer-coarse:pr-12", revealed && "font-og-mono", )} /> {concealed ? ( ) : null}
{canGenerate ? ( ) : null}
)} {spec.looksLike && !serverGenerate ? (

{messages.secretLooksLike(spec.looksLike)}

) : null} {showWarning ? (

) : null} {error ? ( ) : null}
{messages.secretVaultHint(saveTo, vault)}
{saveToChoice.downgraded ? (

{messages.secretSaveToDowngraded(saveToChoice.requested, saveToChoice.initial)}

) : null}
); }