1import { 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("");2829 function initFromPolicy(policy: PasswordPolicy) {30 let nextLength = length();31 let nextSymbols = symbols();3233 if (policy.minLength) nextLength = Math.max(nextLength, policy.minLength);34 if (policy.maxLength) nextLength = Math.min(nextLength, policy.maxLength);35 if (policy.forbidsSymbols) nextSymbols = false;36 if (policy.requiresSymbols) nextSymbols = true;3738 setLength(nextLength);39 setSymbols(nextSymbols);40 setForbiddenChars(policy.forbiddenChars ?? "");41 }4243 onMount(async () => {44 if (props.policy) {45 initFromPolicy(props.policy);46 } else {47 const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });48 const data = (res as { ok: boolean; data?: PasswordPolicy }).data;49 if (data && Object.keys(data).length > 0) {50 initFromPolicy(data);51 }52 }53 regenerate();54 });5556 function regenerate(options?: {57 length?: number;58 upper?: boolean;59 lower?: boolean;60 numbers?: boolean;61 symbols?: boolean;62 }) {63 const pw = generatePassword({64 length: options?.length ?? length(),65 includeUppercase: options?.upper ?? upper(),66 includeLowercase: options?.lower ?? lower(),67 includeNumbers: options?.numbers ?? numbers(),68 includeSymbols: options?.symbols ?? symbols(),69 forbiddenChars: forbiddenChars(),70 });71 setPassword(pw);72 props.onChange?.(pw);73 }7475 async function copyPassword(button?: HTMLButtonElement) {76 const ok = await popupCopy(password(), "password");77 if (ok) {78 triggerRing();79 triggerPipPulse();80 setCopied(true);81 if (button) {82 button.classList.remove("is-retreat");83 const drain = document.createElement("span");84 drain.className = "peach-copy-drain";85 const bar = document.createElement("span");86 bar.className = "peach-copy-drain-bar";87 drain.appendChild(bar);88 button.appendChild(drain);89 window.setTimeout(() => {90 setCopied(false);91 button.classList.remove("is-copied");92 button.classList.add("is-retreat");93 drain.remove();94 window.setTimeout(() => button.classList.remove("is-retreat"), 160);95 }, 1400);96 return;97 }98 window.setTimeout(() => setCopied(false), 1400);99 }100 }101102 function toggleUppercase() {103 const next = !upper();104 setUpper(next);105 regenerate({ upper: next });106 }107108 function toggleLowercase() {109 const next = !lower();110 setLower(next);111 regenerate({ lower: next });112 }113114 function toggleNumbers() {115 const next = !numbers();116 setNumbers(next);117 regenerate({ numbers: next });118 }119120 function toggleSymbols() {121 const next = !symbols();122 setSymbols(next);123 regenerate({ symbols: next });124 }125126 function startEditingLength() {127 setLengthDraft(String(length()));128 setEditingLength(true);129 }130131 function commitLength() {132 const n = parseInt(lengthDraft(), 10);133 if (!isNaN(n)) {134 const clamped = Math.max(8, Math.min(128, n));135 setLength(clamped);136 regenerate({ length: clamped });137 }138 setEditingLength(false);139 }140141 const passwordFontSize = () => {142 const value = password();143 if (value.length > 48) return "16px";144 if (value.length > 24) return "20px";145 return "24px";146 };147148 const RefreshIcon = () => (149 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">150 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />151 <path d="M9.5 2.5H11V1" />152 </svg>153 );154155 const CopyIcon = () => (156 <span class="peach-copy-icon">157 <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">158 <rect x="4" y="4" width="8" height="9" rx="1.5" />159 <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" />160 </svg>161 </span>162 );163164 return (165 <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>166 <div class="peach-generator-drawer-inner">167 <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>168169 {}170 <Show when={props.compact}>171 <div class="peach-pwgen-compact-display-row">172 <input173 class="peach-pwgen-compact-output"174 type="text"175 autocomplete="off"176 spellcheck={false}177 value={password()}178 onInput={(e) => {179 const pw = (e.currentTarget as HTMLInputElement).value;180 setPassword(pw);181 setLength(pw.length);182 props.onChange?.(pw);183 }}184 aria-label="Generated password — click to edit"185 />186 <button187 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"188 onClick={() => regenerate()}189 aria-label="Regenerate"190 >191 <RefreshIcon />192 </button>193 <button194 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}195 onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}196 aria-label="Copy"197 >198 <CopyIcon /><span class="peach-copy-sheen" />199 </button>200 </div>201 <div class="peach-pwgen-compact-controls">202 <input203 type="range"204 class="peach-pwgen-slider peach-pwgen-compact-slider"205 min="8"206 max="128"207 value={length()}208 onInput={(e) => {209 const n = parseInt((e.target as HTMLInputElement).value, 10);210 setLength(n);211 regenerate({ length: n });212 }}213 />214 <Show215 when={editingLength()}216 fallback={217 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>218 }219 >220 <input221 class="peach-pwgen-len-input"222 type="text"223 inputmode="numeric"224 value={lengthDraft()}225 ref={(el) => requestAnimationFrame(() => el.select())}226 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}227 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}228 onBlur={commitLength}229 />230 </Show>231 <div class="peach-pwgen-compact-pills">232 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>233 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>234 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>235 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>236 </div>237 <button238 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"239 onClick={() => props.onUse(password())}240 >241 use242 </button>243 </div>244 </Show>245246 {}247 <Show when={!props.compact}>248 <div class="peach-pwgen-display-row">249 <p250 class="peach-pwgen-output-display"251 style={{ "font-size": passwordFontSize() }}252 >253 {password()}254 </p>255 <div class="peach-pwgen-display-actions">256 <button257 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"258 onClick={() => regenerate()}259 aria-label="Regenerate password"260 >261 <RefreshIcon />262 </button>263 <button264 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}265 onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}266 aria-label="Copy password"267 >268 <CopyIcon /><span class="peach-copy-sheen" />269 </button>270 </div>271 </div>272273 <div class="peach-pwgen-length-row">274 <div class="peach-pwgen-length-labels">275 <span class="peach-label">length</span>276 <Show277 when={editingLength()}278 fallback={279 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>280 }281 >282 <input283 class="peach-pwgen-len-input"284 type="text"285 inputmode="numeric"286 value={lengthDraft()}287 ref={(el) => requestAnimationFrame(() => el.select())}288 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}289 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}290 onBlur={commitLength}291 />292 </Show>293 </div>294 <input295 type="range"296 class="peach-pwgen-slider"297 min="8"298 max="128"299 value={length()}300 onInput={(event) => {301 const nextLength = parseInt(302 (event.target as HTMLInputElement).value,303 10304 );305 setLength(nextLength);306 regenerate({ length: nextLength });307 }}308 />309 </div>310311 <div class="peach-pwgen-charset-pills">312 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>313 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>314 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>315 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>316 </div>317318 <button319 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"320 onClick={() => props.onUse(password())}321 >322 use this password323 </button>324 </Show>325326 </div>327 </div>328 </div>329 );330}