import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"; import { DEFAULT_PASSPHRASE_WORDS, generatePassphrase, generatePassword, MAX_PASSPHRASE_WORDS, MIN_PASSPHRASE_WORDS, type PassphraseSeparator, } from "../../shared/generate"; import type { PasswordPolicy } from "../../shared/types"; import { sendMessage, triggerPipPulse, triggerRing } from "../App"; import { popupCopy } from "../copy"; interface GeneratorDrawerProps { open: boolean; policy?: PasswordPolicy; onUse: (password: string) => void; /** Called on every generation so the caller can stay in sync without waiting for onUse */ onChange?: (password: string) => void; /** Compact two-row layout for use inside entry edit mode */ compact?: boolean; } export function GeneratorDrawer(props: GeneratorDrawerProps) { const [mode, setMode] = createSignal<"password" | "passphrase">("password"); const [password, setPassword] = createSignal(""); const [length, setLength] = createSignal(64); const [wordCount, setWordCount] = createSignal(DEFAULT_PASSPHRASE_WORDS); const [separator, setSeparator] = createSignal("-"); const [upper, setUpper] = createSignal(true); const [lower, setLower] = createSignal(true); const [numbers, setNumbers] = createSignal(true); const [symbols, setSymbols] = createSignal(true); const [forbiddenChars, setForbiddenChars] = createSignal(""); const [copied, setCopied] = createSignal(false); const [editingLength, setEditingLength] = createSignal(false); const [lengthDraft, setLengthDraft] = createSignal(""); const [userHasAdjusted, setUserHasAdjusted] = createSignal(false); const [sliderMin, setSliderMin] = createSignal(8); const [sliderMax, setSliderMax] = createSignal(256); const [sitePolicy, setSitePolicy] = createSignal(null); function initFromPolicy(policy: PasswordPolicy) { setSitePolicy(policy); // Update slider bounds to reflect site constraints (cases 5, 6) if (policy.minLength) setSliderMin(Math.max(1, policy.minLength)); if (policy.maxLength) setSliderMax(Math.min(256, policy.maxLength)); const currentLength = length(); if (!userHasAdjusted()) { // Case 2: fresh open — snap to site max (strongest password the site accepts) const target = policy.maxLength ?? currentLength; // Case 5/6: raise to min if max is somehow below it setLength(policy.minLength ? Math.max(target, policy.minLength) : target); } else { // Case 3: user set higher than site max — snap down if (policy.maxLength && currentLength > policy.maxLength) { setLength(policy.maxLength); } // Case 5: user set lower than site min — snap up if (policy.minLength && currentLength < policy.minLength) { setLength(policy.minLength); } // Case 4: within bounds — keep user's value (no-op) } let nextSymbols = symbols(); if (policy.forbidsSymbols) nextSymbols = false; if (policy.requiresSymbols) nextSymbols = true; setSymbols(nextSymbols); setForbiddenChars(policy.forbiddenChars ?? ""); } // Reset adjustment flag each time the drawer opens fresh createEffect((wasOpen) => { const isOpen = props.open; if (!wasOpen && isOpen) setUserHasAdjusted(false); return isOpen; }, false); onMount(async () => { if (props.policy) { initFromPolicy(props.policy); } else { const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" }); const data = (res as { ok: boolean; data?: PasswordPolicy }).data; if (data && Object.keys(data).length > 0) { initFromPolicy(data); } } regenerate(); }); const isConstrained = createMemo(() => { if (mode() === "passphrase") return false; const policy = sitePolicy(); if (!policy) return false; const len = length(); return (!!policy.maxLength && len >= policy.maxLength) || (!!policy.minLength && len <= policy.minLength); }); const constraintNote = createMemo(() => { if (mode() === "passphrase") return ""; const policy = sitePolicy(); if (!policy) return ""; const len = length(); if (policy.maxLength && len >= policy.maxLength) { return `This site allows max ${policy.maxLength} characters`; } if (policy.minLength && len <= policy.minLength) { return `This site requires at least ${policy.minLength} characters`; } return ""; }); function handleLengthChange(n: number) { if (mode() === "passphrase") { setWordCount(n); regenerate({ wordCount: n }); return; } setUserHasAdjusted(true); setLength(n); regenerate({ length: n }); } function regenerate(options?: { mode?: "password" | "passphrase"; length?: number; wordCount?: number; separator?: PassphraseSeparator; upper?: boolean; lower?: boolean; numbers?: boolean; symbols?: boolean; }) { const nextMode = options?.mode ?? mode(); const pw = nextMode === "passphrase" ? generatePassphrase({ wordCount: options?.wordCount ?? wordCount(), separator: options?.separator ?? separator(), }) : generatePassword({ length: options?.length ?? length(), includeUppercase: options?.upper ?? upper(), includeLowercase: options?.lower ?? lower(), includeNumbers: options?.numbers ?? numbers(), includeSymbols: options?.symbols ?? symbols(), forbiddenChars: forbiddenChars(), }); setPassword(pw); props.onChange?.(pw); } async function copyPassword(button?: HTMLButtonElement) { const ok = await popupCopy(password(), "password"); if (ok) { triggerRing(); triggerPipPulse(); setCopied(true); if (button) { button.classList.remove("is-retreat"); const drain = document.createElement("span"); drain.className = "peach-copy-drain"; const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar"; drain.appendChild(bar); button.appendChild(drain); window.setTimeout(() => { setCopied(false); button.classList.remove("is-copied"); button.classList.add("is-retreat"); drain.remove(); window.setTimeout(() => button.classList.remove("is-retreat"), 160); }, 1400); return; } window.setTimeout(() => setCopied(false), 1400); } } function toggleUppercase() { const next = !upper(); setUpper(next); regenerate({ upper: next }); } function toggleLowercase() { const next = !lower(); setLower(next); regenerate({ lower: next }); } function toggleNumbers() { const next = !numbers(); setNumbers(next); regenerate({ numbers: next }); } function toggleSymbols() { const next = !symbols(); setSymbols(next); regenerate({ symbols: next }); } function startEditingLength() { setLengthDraft(String(mode() === "passphrase" ? wordCount() : length())); setEditingLength(true); } function commitLength() { const n = parseInt(lengthDraft(), 10); if (!Number.isNaN(n)) { const min = mode() === "passphrase" ? MIN_PASSPHRASE_WORDS : sliderMin(); const max = mode() === "passphrase" ? MAX_PASSPHRASE_WORDS : sliderMax(); const clamped = Math.max(min, Math.min(max, n)); handleLengthChange(clamped); } setEditingLength(false); } const passwordFontSize = () => { const value = password(); if (value.length > 48) return "16px"; if (value.length > 24) return "20px"; return "24px"; }; function selectMode(nextMode: "password" | "passphrase") { setMode(nextMode); setEditingLength(false); regenerate({ mode: nextMode }); } function selectSeparator(nextSeparator: PassphraseSeparator) { setSeparator(nextSeparator); regenerate({ separator: nextSeparator }); } const currentAmount = () => mode() === "passphrase" ? wordCount() : length(); const currentMin = () => mode() === "passphrase" ? MIN_PASSPHRASE_WORDS : sliderMin(); const currentMax = () => mode() === "passphrase" ? MAX_PASSPHRASE_WORDS : sliderMax(); const ModeControls = () => (
); const OptionPills = () => ( } > {(["-", " ", ".", "_"] as PassphraseSeparator[]).map((value) => ( ))} ); const RefreshIcon = () => ( ); const CopyIcon = () => ( ); return (
{/* ── Compact layout ──────────────────────────────── */}
{ const pw = (e.currentTarget as HTMLInputElement).value; setPassword(pw); if (mode() === "password") setLength(pw.length); props.onChange?.(pw); }} aria-label="Generated password — click to edit" />
{ const n = parseInt((e.target as HTMLInputElement).value, 10); handleLengthChange(n); }} /> {currentAmount()} } > requestAnimationFrame(() => el.select())} onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }} onBlur={commitLength} />

{constraintNote()}

{/* ── Full layout ─────────────────────────────────── */}

{password()}

{mode() === "passphrase" ? "word count" : "length"} {currentAmount()} } > requestAnimationFrame(() => el.select())} onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }} onBlur={commitLength} />
{ const n = parseInt( (event.target as HTMLInputElement).value, 10 ); handleLengthChange(n); }} />

{constraintNote()}

); }