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 compact?: boolean;13}1415export function GeneratorDrawer(props: GeneratorDrawerProps) {16 const [password, setPassword] = createSignal("");17 const [length, setLength] = createSignal(64);18 const [upper, setUpper] = createSignal(true);19 const [lower, setLower] = createSignal(true);20 const [numbers, setNumbers] = createSignal(true);21 const [symbols, setSymbols] = createSignal(true);22 const [forbiddenChars, setForbiddenChars] = createSignal("");23 const [copied, setCopied] = createSignal(false);24 const [editingLength, setEditingLength] = createSignal(false);25 const [lengthDraft, setLengthDraft] = createSignal("");2627 function initFromPolicy(policy: PasswordPolicy) {28 let nextLength = length();29 let nextSymbols = symbols();3031 if (policy.minLength) nextLength = Math.max(nextLength, policy.minLength);32 if (policy.maxLength) nextLength = Math.min(nextLength, policy.maxLength);33 if (policy.forbidsSymbols) nextSymbols = false;34 if (policy.requiresSymbols) nextSymbols = true;3536 setLength(nextLength);37 setSymbols(nextSymbols);38 setForbiddenChars(policy.forbiddenChars ?? "");39 }4041 onMount(async () => {42 if (props.policy) {43 initFromPolicy(props.policy);44 } else {45 const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });46 const data = (res as { ok: boolean; data?: PasswordPolicy }).data;47 if (data && Object.keys(data).length > 0) {48 initFromPolicy(data);49 }50 }51 regenerate();52 });5354 function regenerate(options?: {55 length?: number;56 upper?: boolean;57 lower?: boolean;58 numbers?: boolean;59 symbols?: boolean;60 }) {61 setPassword(62 generatePassword({63 length: options?.length ?? length(),64 includeUppercase: options?.upper ?? upper(),65 includeLowercase: options?.lower ?? lower(),66 includeNumbers: options?.numbers ?? numbers(),67 includeSymbols: options?.symbols ?? symbols(),68 forbiddenChars: forbiddenChars(),69 })70 );71 }7273 async function copyPassword(button?: HTMLButtonElement) {74 const ok = await popupCopy(password(), "password");75 if (ok) {76 triggerRing();77 triggerPipPulse();78 setCopied(true);79 if (button) {80 button.classList.remove("is-retreat");81 const drain = document.createElement("span");82 drain.className = "peach-copy-drain";83 const bar = document.createElement("span");84 bar.className = "peach-copy-drain-bar";85 drain.appendChild(bar);86 button.appendChild(drain);87 window.setTimeout(() => {88 setCopied(false);89 button.classList.remove("is-copied");90 button.classList.add("is-retreat");91 drain.remove();92 window.setTimeout(() => button.classList.remove("is-retreat"), 160);93 }, 1400);94 return;95 }96 window.setTimeout(() => setCopied(false), 1400);97 }98 }99100 function toggleUppercase() {101 const next = !upper();102 setUpper(next);103 regenerate({ upper: next });104 }105106 function toggleLowercase() {107 const next = !lower();108 setLower(next);109 regenerate({ lower: next });110 }111112 function toggleNumbers() {113 const next = !numbers();114 setNumbers(next);115 regenerate({ numbers: next });116 }117118 function toggleSymbols() {119 const next = !symbols();120 setSymbols(next);121 regenerate({ symbols: next });122 }123124 function startEditingLength() {125 setLengthDraft(String(length()));126 setEditingLength(true);127 }128129 function commitLength() {130 const n = parseInt(lengthDraft(), 10);131 if (!isNaN(n)) {132 const clamped = Math.max(8, Math.min(128, n));133 setLength(clamped);134 regenerate({ length: clamped });135 }136 setEditingLength(false);137 }138139 const passwordFontSize = () => {140 const value = password();141 if (value.length > 48) return "16px";142 if (value.length > 24) return "20px";143 return "24px";144 };145146 const RefreshIcon = () => (147 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">148 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />149 <path d="M9.5 2.5H11V1" />150 </svg>151 );152153 const CopyIcon = () => (154 <span class="peach-copy-icon">155 <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">156 <rect x="4" y="4" width="8" height="9" rx="1.5" />157 <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" />158 </svg>159 </span>160 );161162 return (163 <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>164 <div class="peach-generator-drawer-inner">165 <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>166167 {}168 <Show when={props.compact}>169 <div class="peach-pwgen-compact-display-row">170 <span class="peach-pwgen-compact-output">{password()}</span>171 <button172 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"173 onClick={() => regenerate()}174 aria-label="Regenerate"175 >176 <RefreshIcon />177 </button>178 <button179 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}180 onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}181 aria-label="Copy"182 >183 <CopyIcon /><span class="peach-copy-sheen" />184 </button>185 </div>186 <div class="peach-pwgen-compact-controls">187 <input188 type="range"189 class="peach-pwgen-slider peach-pwgen-compact-slider"190 min="8"191 max="128"192 value={length()}193 onInput={(e) => {194 const n = parseInt((e.target as HTMLInputElement).value, 10);195 setLength(n);196 regenerate({ length: n });197 }}198 />199 <Show200 when={editingLength()}201 fallback={202 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>203 }204 >205 <input206 class="peach-pwgen-len-input"207 type="text"208 inputmode="numeric"209 value={lengthDraft()}210 ref={(el) => requestAnimationFrame(() => el.select())}211 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}212 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}213 onBlur={commitLength}214 />215 </Show>216 <div class="peach-pwgen-compact-pills">217 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>218 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>219 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>220 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>221 </div>222 <button223 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"224 onClick={() => props.onUse(password())}225 >226 use227 </button>228 </div>229 </Show>230231 {}232 <Show when={!props.compact}>233 <div class="peach-pwgen-display-row">234 <p235 class="peach-pwgen-output-display"236 style={{ "font-size": passwordFontSize() }}237 >238 {password()}239 </p>240 <div class="peach-pwgen-display-actions">241 <button242 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"243 onClick={() => regenerate()}244 aria-label="Regenerate password"245 >246 <RefreshIcon />247 </button>248 <button249 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}250 onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}251 aria-label="Copy password"252 >253 <CopyIcon /><span class="peach-copy-sheen" />254 </button>255 </div>256 </div>257258 <div class="peach-pwgen-length-row">259 <div class="peach-pwgen-length-labels">260 <span class="peach-label">length</span>261 <Show262 when={editingLength()}263 fallback={264 <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>265 }266 >267 <input268 class="peach-pwgen-len-input"269 type="text"270 inputmode="numeric"271 value={lengthDraft()}272 ref={(el) => requestAnimationFrame(() => el.select())}273 onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}274 onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}275 onBlur={commitLength}276 />277 </Show>278 </div>279 <input280 type="range"281 class="peach-pwgen-slider"282 min="8"283 max="128"284 value={length()}285 onInput={(event) => {286 const nextLength = parseInt(287 (event.target as HTMLInputElement).value,288 10289 );290 setLength(nextLength);291 regenerate({ length: nextLength });292 }}293 />294 </div>295296 <div class="peach-pwgen-charset-pills">297 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>298 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>299 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>300 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>301 </div>302303 <button304 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"305 onClick={() => props.onUse(password())}306 >307 use this password308 </button>309 </Show>310311 </div>312 </div>313 </div>314 );315}