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 <span class="peach-pwgen-compact-output">{password()}</span>173 <button174 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"175 onClick={() => regenerate()}176 aria-label="Regenerate"177 >178 <RefreshIcon />179 </button>180 <button181 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}182 onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}183 aria-label="Copy"184 >185 <CopyIcon /><span class="peach-copy-sheen" />186 </button>187 </div>188 <div class="peach-pwgen-compact-controls">189 <input190 type="range"191 class="peach-pwgen-slider peach-pwgen-compact-slider"192 min="8"193 max="128"194 value={length()}195 onInput={(e) => {196 const n = parseInt((e.target as HTMLInputElement).value, 10);197 setLength(n);198 regenerate({ length: n });199 }}200 />201 <Show202 when={editingLength()}203 fallback={204 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>205 }206 >207 <input208 class="peach-pwgen-len-input"209 type="text"210 inputmode="numeric"211 value={lengthDraft()}212 ref={(el) => requestAnimationFrame(() => el.select())}213 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}214 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}215 onBlur={commitLength}216 />217 </Show>218 <div class="peach-pwgen-compact-pills">219 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>220 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>221 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>222 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>223 </div>224 <button225 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"226 onClick={() => props.onUse(password())}227 >228 use229 </button>230 </div>231 </Show>232233 {}234 <Show when={!props.compact}>235 <div class="peach-pwgen-display-row">236 <p237 class="peach-pwgen-output-display"238 style={{ "font-size": passwordFontSize() }}239 >240 {password()}241 </p>242 <div class="peach-pwgen-display-actions">243 <button244 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"245 onClick={() => regenerate()}246 aria-label="Regenerate password"247 >248 <RefreshIcon />249 </button>250 <button251 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}252 onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}253 aria-label="Copy password"254 >255 <CopyIcon /><span class="peach-copy-sheen" />256 </button>257 </div>258 </div>259260 <div class="peach-pwgen-length-row">261 <div class="peach-pwgen-length-labels">262 <span class="peach-label">length</span>263 <Show264 when={editingLength()}265 fallback={266 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>267 }268 >269 <input270 class="peach-pwgen-len-input"271 type="text"272 inputmode="numeric"273 value={lengthDraft()}274 ref={(el) => requestAnimationFrame(() => el.select())}275 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}276 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}277 onBlur={commitLength}278 />279 </Show>280 </div>281 <input282 type="range"283 class="peach-pwgen-slider"284 min="8"285 max="128"286 value={length()}287 onInput={(event) => {288 const nextLength = parseInt(289 (event.target as HTMLInputElement).value,290 10291 );292 setLength(nextLength);293 regenerate({ length: nextLength });294 }}295 />296 </div>297298 <div class="peach-pwgen-charset-pills">299 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>300 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>301 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>302 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>303 </div>304305 <button306 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"307 onClick={() => props.onUse(password())}308 >309 use this password310 </button>311 </Show>312313 </div>314 </div>315 </div>316 );317}