1import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js";2import { generatePassword } from "../../shared/generate";3import type { PasswordPolicy } from "../../shared/types";4import { sendMessage, triggerPipPulse, triggerRing } from "../App";5import { popupCopy } from "../copy";67interface GeneratorDrawerProps {8 open: boolean;9 policy?: PasswordPolicy;10 onUse: (password: string) => void;11 12 onChange?: (password: string) => void;13 14 compact?: boolean;15}1617export function GeneratorDrawer(props: GeneratorDrawerProps) {18 const [password, setPassword] = createSignal("");19 const [length, setLength] = createSignal(64);20 const [upper, setUpper] = createSignal(true);21 const [lower, setLower] = createSignal(true);22 const [numbers, setNumbers] = createSignal(true);23 const [symbols, setSymbols] = createSignal(true);24 const [forbiddenChars, setForbiddenChars] = createSignal("");25 const [copied, setCopied] = createSignal(false);26 const [editingLength, setEditingLength] = createSignal(false);27 const [lengthDraft, setLengthDraft] = createSignal("");28 const [userHasAdjusted, setUserHasAdjusted] = createSignal(false);29 const [sliderMin, setSliderMin] = createSignal(8);30 const [sliderMax, setSliderMax] = createSignal(128);31 const [sitePolicy, setSitePolicy] = createSignal<PasswordPolicy | null>(null);3233 function initFromPolicy(policy: PasswordPolicy) {34 setSitePolicy(policy);3536 37 if (policy.minLength) setSliderMin(Math.max(1, policy.minLength));38 if (policy.maxLength) setSliderMax(Math.min(128, policy.maxLength));3940 const currentLength = length();4142 if (!userHasAdjusted()) {43 44 const target = policy.maxLength ?? currentLength;45 46 setLength(policy.minLength ? Math.max(target, policy.minLength) : target);47 } else {48 49 if (policy.maxLength && currentLength > policy.maxLength) {50 setLength(policy.maxLength);51 }52 53 if (policy.minLength && currentLength < policy.minLength) {54 setLength(policy.minLength);55 }56 57 }5859 let nextSymbols = symbols();60 if (policy.forbidsSymbols) nextSymbols = false;61 if (policy.requiresSymbols) nextSymbols = true;62 setSymbols(nextSymbols);63 setForbiddenChars(policy.forbiddenChars ?? "");64 }6566 67 createEffect<boolean>((wasOpen) => {68 const isOpen = props.open;69 if (!wasOpen && isOpen) setUserHasAdjusted(false);70 return isOpen;71 }, false);7273 onMount(async () => {74 if (props.policy) {75 initFromPolicy(props.policy);76 } else {77 const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });78 const data = (res as { ok: boolean; data?: PasswordPolicy }).data;79 if (data && Object.keys(data).length > 0) {80 initFromPolicy(data);81 }82 }83 regenerate();84 });8586 const isConstrained = createMemo(() => {87 const policy = sitePolicy();88 if (!policy) return false;89 const len = length();90 return (!!policy.maxLength && len >= policy.maxLength) ||91 (!!policy.minLength && len <= policy.minLength);92 });9394 const constraintNote = createMemo(() => {95 const policy = sitePolicy();96 if (!policy) return "";97 const len = length();98 if (policy.maxLength && len >= policy.maxLength) {99 return `This site allows max ${policy.maxLength} characters`;100 }101 if (policy.minLength && len <= policy.minLength) {102 return `This site requires at least ${policy.minLength} characters`;103 }104 return "";105 });106107 function handleLengthChange(n: number) {108 setUserHasAdjusted(true);109 setLength(n);110 regenerate({ length: n });111 }112113 function regenerate(options?: {114 length?: number;115 upper?: boolean;116 lower?: boolean;117 numbers?: boolean;118 symbols?: boolean;119 }) {120 const pw = generatePassword({121 length: options?.length ?? length(),122 includeUppercase: options?.upper ?? upper(),123 includeLowercase: options?.lower ?? lower(),124 includeNumbers: options?.numbers ?? numbers(),125 includeSymbols: options?.symbols ?? symbols(),126 forbiddenChars: forbiddenChars(),127 });128 setPassword(pw);129 props.onChange?.(pw);130 }131132 async function copyPassword(button?: HTMLButtonElement) {133 const ok = await popupCopy(password(), "password");134 if (ok) {135 triggerRing();136 triggerPipPulse();137 setCopied(true);138 if (button) {139 button.classList.remove("is-retreat");140 const drain = document.createElement("span");141 drain.className = "peach-copy-drain";142 const bar = document.createElement("span");143 bar.className = "peach-copy-drain-bar";144 drain.appendChild(bar);145 button.appendChild(drain);146 window.setTimeout(() => {147 setCopied(false);148 button.classList.remove("is-copied");149 button.classList.add("is-retreat");150 drain.remove();151 window.setTimeout(() => button.classList.remove("is-retreat"), 160);152 }, 1400);153 return;154 }155 window.setTimeout(() => setCopied(false), 1400);156 }157 }158159 function toggleUppercase() {160 const next = !upper();161 setUpper(next);162 regenerate({ upper: next });163 }164165 function toggleLowercase() {166 const next = !lower();167 setLower(next);168 regenerate({ lower: next });169 }170171 function toggleNumbers() {172 const next = !numbers();173 setNumbers(next);174 regenerate({ numbers: next });175 }176177 function toggleSymbols() {178 const next = !symbols();179 setSymbols(next);180 regenerate({ symbols: next });181 }182183 function startEditingLength() {184 setLengthDraft(String(length()));185 setEditingLength(true);186 }187188 function commitLength() {189 const n = parseInt(lengthDraft(), 10);190 if (!Number.isNaN(n)) {191 const clamped = Math.max(sliderMin(), Math.min(sliderMax(), n));192 handleLengthChange(clamped);193 }194 setEditingLength(false);195 }196197 const passwordFontSize = () => {198 const value = password();199 if (value.length > 48) return "16px";200 if (value.length > 24) return "20px";201 return "24px";202 };203204 const RefreshIcon = () => (205 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">206 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />207 <path d="M9.5 2.5H11V1" />208 </svg>209 );210211 const CopyIcon = () => (212 <span class="peach-copy-icon">213 <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">214 <rect x="4" y="4" width="8" height="9" rx="1.5" />215 <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" />216 </svg>217 </span>218 );219220 return (221 <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>222 <div class="peach-generator-drawer-inner">223 <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>224225 {}226 <Show when={props.compact}>227 <div class="peach-pwgen-compact-display-row">228 <input229 class="peach-pwgen-compact-output"230 type="text"231 autocomplete="off"232 spellcheck={false}233 value={password()}234 onInput={(e) => {235 const pw = (e.currentTarget as HTMLInputElement).value;236 setPassword(pw);237 setLength(pw.length);238 props.onChange?.(pw);239 }}240 aria-label="Generated password — click to edit"241 />242 <button243 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"244 onClick={() => regenerate()}245 aria-label="Regenerate"246 >247 <RefreshIcon />248 </button>249 <button250 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}251 onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}252 aria-label="Copy"253 >254 <CopyIcon /><span class="peach-copy-sheen" />255 </button>256 </div>257 <div class="peach-pwgen-compact-controls">258 <input259 type="range"260 class="peach-pwgen-slider peach-pwgen-compact-slider"261 min={sliderMin()}262 max={sliderMax()}263 value={length()}264 onInput={(e) => {265 const n = parseInt((e.target as HTMLInputElement).value, 10);266 handleLengthChange(n);267 }}268 />269 <Show270 when={editingLength()}271 fallback={272 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>273 }274 >275 <input276 class="peach-pwgen-len-input"277 type="text"278 inputmode="numeric"279 value={lengthDraft()}280 ref={(el) => requestAnimationFrame(() => el.select())}281 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}282 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}283 onBlur={commitLength}284 />285 </Show>286 <div class="peach-pwgen-compact-pills">287 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>288 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>289 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>290 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>291 </div>292 <button293 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"294 onClick={() => props.onUse(password())}295 >296 use297 </button>298 </div>299 <Show when={isConstrained()}>300 <p class="peach-generator-constraint-note">{constraintNote()}</p>301 </Show>302 </Show>303304 {}305 <Show when={!props.compact}>306 <div class="peach-pwgen-display-row">307 <p308 class="peach-pwgen-output-display"309 style={{ "font-size": passwordFontSize() }}310 >311 {password()}312 </p>313 <div class="peach-pwgen-display-actions">314 <button315 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"316 onClick={() => regenerate()}317 aria-label="Regenerate password"318 >319 <RefreshIcon />320 </button>321 <button322 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}323 onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}324 aria-label="Copy password"325 >326 <CopyIcon /><span class="peach-copy-sheen" />327 </button>328 </div>329 </div>330331 <div class="peach-pwgen-length-row">332 <div class="peach-pwgen-length-labels">333 <span class="peach-label">length</span>334 <Show335 when={editingLength()}336 fallback={337 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>338 }339 >340 <input341 class="peach-pwgen-len-input"342 type="text"343 inputmode="numeric"344 value={lengthDraft()}345 ref={(el) => requestAnimationFrame(() => el.select())}346 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}347 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}348 onBlur={commitLength}349 />350 </Show>351 </div>352 <input353 type="range"354 class="peach-pwgen-slider"355 min={sliderMin()}356 max={sliderMax()}357 value={length()}358 onInput={(event) => {359 const n = parseInt(360 (event.target as HTMLInputElement).value,361 10362 );363 handleLengthChange(n);364 }}365 />366 <Show when={isConstrained()}>367 <p class="peach-generator-constraint-note">{constraintNote()}</p>368 </Show>369 </div>370371 <div class="peach-pwgen-charset-pills">372 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>373 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>374 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>375 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>376 </div>377378 <button379 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"380 onClick={() => props.onUse(password())}381 >382 use this password383 </button>384 </Show>385386 </div>387 </div>388 </div>389 );390}