1import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js";2import {3 DEFAULT_PASSPHRASE_WORDS,4 generatePassphrase,5 generatePassword,6 MAX_PASSPHRASE_WORDS,7 MIN_PASSPHRASE_WORDS,8 type PassphraseSeparator,9} from "../../shared/generate";10import type { PasswordPolicy } from "../../shared/types";11import { sendMessage, triggerPipPulse, triggerRing } from "../App";12import { popupCopy } from "../copy";1314interface GeneratorDrawerProps {15 open: boolean;16 policy?: PasswordPolicy;17 onUse: (password: string) => void;18 19 onChange?: (password: string) => void;20 21 compact?: boolean;22}2324export function GeneratorDrawer(props: GeneratorDrawerProps) {25 const [mode, setMode] = createSignal<"password" | "passphrase">("password");26 const [password, setPassword] = createSignal("");27 const [length, setLength] = createSignal(64);28 const [wordCount, setWordCount] = createSignal(DEFAULT_PASSPHRASE_WORDS);29 const [separator, setSeparator] = createSignal<PassphraseSeparator>("-");30 const [upper, setUpper] = createSignal(true);31 const [lower, setLower] = createSignal(true);32 const [numbers, setNumbers] = createSignal(true);33 const [symbols, setSymbols] = createSignal(true);34 const [forbiddenChars, setForbiddenChars] = createSignal("");35 const [copied, setCopied] = createSignal(false);36 const [editingLength, setEditingLength] = createSignal(false);37 const [lengthDraft, setLengthDraft] = createSignal("");38 const [userHasAdjusted, setUserHasAdjusted] = createSignal(false);39 const [sliderMin, setSliderMin] = createSignal(8);40 const [sliderMax, setSliderMax] = createSignal(256);41 const [sitePolicy, setSitePolicy] = createSignal<PasswordPolicy | null>(null);4243 function initFromPolicy(policy: PasswordPolicy) {44 setSitePolicy(policy);4546 47 if (policy.minLength) setSliderMin(Math.max(1, policy.minLength));48 if (policy.maxLength) setSliderMax(Math.min(256, policy.maxLength));4950 const currentLength = length();5152 if (!userHasAdjusted()) {53 54 const target = policy.maxLength ?? currentLength;55 56 setLength(policy.minLength ? Math.max(target, policy.minLength) : target);57 } else {58 59 if (policy.maxLength && currentLength > policy.maxLength) {60 setLength(policy.maxLength);61 }62 63 if (policy.minLength && currentLength < policy.minLength) {64 setLength(policy.minLength);65 }66 67 }6869 let nextSymbols = symbols();70 if (policy.forbidsSymbols) nextSymbols = false;71 if (policy.requiresSymbols) nextSymbols = true;72 setSymbols(nextSymbols);73 setForbiddenChars(policy.forbiddenChars ?? "");74 }7576 77 createEffect<boolean>((wasOpen) => {78 const isOpen = props.open;79 if (!wasOpen && isOpen) setUserHasAdjusted(false);80 return isOpen;81 }, false);8283 onMount(async () => {84 if (props.policy) {85 initFromPolicy(props.policy);86 } else {87 const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });88 const data = (res as { ok: boolean; data?: PasswordPolicy }).data;89 if (data && Object.keys(data).length > 0) {90 initFromPolicy(data);91 }92 }93 regenerate();94 });9596 const isConstrained = createMemo(() => {97 if (mode() === "passphrase") return false;98 const policy = sitePolicy();99 if (!policy) return false;100 const len = length();101 return (!!policy.maxLength && len >= policy.maxLength) ||102 (!!policy.minLength && len <= policy.minLength);103 });104105 const constraintNote = createMemo(() => {106 if (mode() === "passphrase") return "";107 const policy = sitePolicy();108 if (!policy) return "";109 const len = length();110 if (policy.maxLength && len >= policy.maxLength) {111 return `This site allows max ${policy.maxLength} characters`;112 }113 if (policy.minLength && len <= policy.minLength) {114 return `This site requires at least ${policy.minLength} characters`;115 }116 return "";117 });118119 function handleLengthChange(n: number) {120 if (mode() === "passphrase") {121 setWordCount(n);122 regenerate({ wordCount: n });123 return;124 }125 setUserHasAdjusted(true);126 setLength(n);127 regenerate({ length: n });128 }129130 function regenerate(options?: {131 mode?: "password" | "passphrase";132 length?: number;133 wordCount?: number;134 separator?: PassphraseSeparator;135 upper?: boolean;136 lower?: boolean;137 numbers?: boolean;138 symbols?: boolean;139 }) {140 const nextMode = options?.mode ?? mode();141 const pw = nextMode === "passphrase"142 ? generatePassphrase({143 wordCount: options?.wordCount ?? wordCount(),144 separator: options?.separator ?? separator(),145 })146 : generatePassword({147 length: options?.length ?? length(),148 includeUppercase: options?.upper ?? upper(),149 includeLowercase: options?.lower ?? lower(),150 includeNumbers: options?.numbers ?? numbers(),151 includeSymbols: options?.symbols ?? symbols(),152 forbiddenChars: forbiddenChars(),153 });154 setPassword(pw);155 props.onChange?.(pw);156 }157158 async function copyPassword(button?: HTMLButtonElement) {159 const ok = await popupCopy(password(), "password");160 if (ok) {161 triggerRing();162 triggerPipPulse();163 setCopied(true);164 if (button) {165 button.classList.remove("is-retreat");166 const drain = document.createElement("span");167 drain.className = "peach-copy-drain";168 const bar = document.createElement("span");169 bar.className = "peach-copy-drain-bar";170 drain.appendChild(bar);171 button.appendChild(drain);172 window.setTimeout(() => {173 setCopied(false);174 button.classList.remove("is-copied");175 button.classList.add("is-retreat");176 drain.remove();177 window.setTimeout(() => button.classList.remove("is-retreat"), 160);178 }, 1400);179 return;180 }181 window.setTimeout(() => setCopied(false), 1400);182 }183 }184185 function toggleUppercase() {186 const next = !upper();187 setUpper(next);188 regenerate({ upper: next });189 }190191 function toggleLowercase() {192 const next = !lower();193 setLower(next);194 regenerate({ lower: next });195 }196197 function toggleNumbers() {198 const next = !numbers();199 setNumbers(next);200 regenerate({ numbers: next });201 }202203 function toggleSymbols() {204 const next = !symbols();205 setSymbols(next);206 regenerate({ symbols: next });207 }208209 function startEditingLength() {210 setLengthDraft(String(mode() === "passphrase" ? wordCount() : length()));211 setEditingLength(true);212 }213214 function commitLength() {215 const n = parseInt(lengthDraft(), 10);216 if (!Number.isNaN(n)) {217 const min = mode() === "passphrase" ? MIN_PASSPHRASE_WORDS : sliderMin();218 const max = mode() === "passphrase" ? MAX_PASSPHRASE_WORDS : sliderMax();219 const clamped = Math.max(min, Math.min(max, n));220 handleLengthChange(clamped);221 }222 setEditingLength(false);223 }224225 const passwordFontSize = () => {226 const value = password();227 if (value.length > 48) return "16px";228 if (value.length > 24) return "20px";229 return "24px";230 };231232 function selectMode(nextMode: "password" | "passphrase") {233 setMode(nextMode);234 setEditingLength(false);235 regenerate({ mode: nextMode });236 }237238 function selectSeparator(nextSeparator: PassphraseSeparator) {239 setSeparator(nextSeparator);240 regenerate({ separator: nextSeparator });241 }242243 const currentAmount = () => mode() === "passphrase" ? wordCount() : length();244 const currentMin = () => mode() === "passphrase" ? MIN_PASSPHRASE_WORDS : sliderMin();245 const currentMax = () => mode() === "passphrase" ? MAX_PASSPHRASE_WORDS : sliderMax();246247 const ModeControls = () => (248 <div class="peach-pwgen-mode-pills" aria-label="Generator type">249 <button class={`peach-pwgen-pill ${mode() === "password" ? "is-on" : ""}`} aria-pressed={mode() === "password"} onClick={() => selectMode("password")} type="button">password</button>250 <button class={`peach-pwgen-pill ${mode() === "passphrase" ? "is-on" : ""}`} aria-pressed={mode() === "passphrase"} onClick={() => selectMode("passphrase")} type="button">passphrase</button>251 </div>252 );253254 const OptionPills = () => (255 <Show256 when={mode() === "passphrase"}257 fallback={258 <>259 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} aria-pressed={upper()} onClick={toggleUppercase} type="button">A-Z</button>260 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} aria-pressed={lower()} onClick={toggleLowercase} type="button">a-z</button>261 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} aria-pressed={numbers()} onClick={toggleNumbers} type="button">0-9</button>262 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} aria-pressed={symbols()} onClick={toggleSymbols} type="button">!@#</button>263 </>264 }265 >266 {(["-", " ", ".", "_"] as PassphraseSeparator[]).map((value) => (267 <button268 class={`peach-pwgen-pill ${separator() === value ? "is-on" : ""}`}269 aria-label={value === " " ? "Space separator" : `${value} separator`}270 aria-pressed={separator() === value}271 onClick={() => selectSeparator(value)}272 type="button"273 >274 {value === " " ? "space" : value}275 </button>276 ))}277 </Show>278 );279280 const RefreshIcon = () => (281 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">282 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />283 <path d="M9.5 2.5H11V1" />284 </svg>285 );286287 const CopyIcon = () => (288 <span class="peach-copy-icon">289 <svg class="peach-copy-icon-svg" width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">290 <rect x="4" y="4" width="8" height="9" rx="1.5" />291 <path d="M10 4V2.5A1.5 1.5 0 0 0 8.5 1h-6A1.5 1.5 0 0 0 1 2.5v7A1.5 1.5 0 0 0 2.5 11H4" />292 </svg>293 </span>294 );295296 return (297 <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>298 <div class="peach-generator-drawer-inner">299 <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>300301 {}302 <Show when={props.compact}>303 <ModeControls />304 <div class="peach-pwgen-compact-display-row">305 <input306 class="peach-pwgen-compact-output"307 type="text"308 autocomplete="off"309 spellcheck={false}310 value={password()}311 onInput={(e) => {312 const pw = (e.currentTarget as HTMLInputElement).value;313 setPassword(pw);314 if (mode() === "password") setLength(pw.length);315 props.onChange?.(pw);316 }}317 aria-label="Generated password — click to edit"318 />319 <button320 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"321 onClick={() => regenerate()}322 aria-label="Regenerate"323 >324 <RefreshIcon />325 </button>326 <button327 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}328 onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}329 aria-label="Copy"330 >331 <CopyIcon /><span class="peach-copy-sheen" />332 </button>333 </div>334 <div class="peach-pwgen-compact-controls">335 <input336 type="range"337 class="peach-pwgen-slider peach-pwgen-compact-slider"338 min={currentMin()}339 max={currentMax()}340 value={currentAmount()}341 aria-label={mode() === "passphrase" ? "Word count" : "Password length"}342 onInput={(e) => {343 const n = parseInt((e.target as HTMLInputElement).value, 10);344 handleLengthChange(n);345 }}346 />347 <Show348 when={editingLength()}349 fallback={350 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{currentAmount()}</span>351 }352 >353 <input354 class="peach-pwgen-len-input"355 type="text"356 inputmode="numeric"357 value={lengthDraft()}358 ref={(el) => requestAnimationFrame(() => el.select())}359 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}360 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}361 onBlur={commitLength}362 />363 </Show>364 <div class="peach-pwgen-compact-pills">365 <OptionPills />366 </div>367 <button368 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"369 onClick={() => props.onUse(password())}370 >371 use372 </button>373 </div>374 <Show when={isConstrained()}>375 <p class="peach-generator-constraint-note">{constraintNote()}</p>376 </Show>377 </Show>378379 {}380 <Show when={!props.compact}>381 <ModeControls />382 <div class="peach-pwgen-display-row">383 <p384 class="peach-pwgen-output-display"385 style={{ "font-size": passwordFontSize() }}386 >387 {password()}388 </p>389 <div class="peach-pwgen-display-actions">390 <button391 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"392 onClick={() => regenerate()}393 aria-label="Regenerate password"394 >395 <RefreshIcon />396 </button>397 <button398 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}399 onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}400 aria-label="Copy password"401 >402 <CopyIcon /><span class="peach-copy-sheen" />403 </button>404 </div>405 </div>406407 <div class="peach-pwgen-length-row">408 <div class="peach-pwgen-length-labels">409 <span class="peach-label">{mode() === "passphrase" ? "word count" : "length"}</span>410 <Show411 when={editingLength()}412 fallback={413 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{currentAmount()}</span>414 }415 >416 <input417 class="peach-pwgen-len-input"418 type="text"419 inputmode="numeric"420 value={lengthDraft()}421 ref={(el) => requestAnimationFrame(() => el.select())}422 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}423 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}424 onBlur={commitLength}425 />426 </Show>427 </div>428 <input429 type="range"430 class="peach-pwgen-slider"431 min={currentMin()}432 max={currentMax()}433 value={currentAmount()}434 aria-label={mode() === "passphrase" ? "Word count" : "Password length"}435 onInput={(event) => {436 const n = parseInt(437 (event.target as HTMLInputElement).value,438 10439 );440 handleLengthChange(n);441 }}442 />443 <Show when={isConstrained()}>444 <p class="peach-generator-constraint-note">{constraintNote()}</p>445 </Show>446 </div>447448 <div class="peach-pwgen-charset-pills">449 <OptionPills />450 </div>451452 <button453 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"454 onClick={() => props.onUse(password())}455 >456 use this {mode()}457 </button>458 </Show>459460 </div>461 </div>462 </div>463 );464}