GeneratorDrawer.tsx

12.4 KB · raw

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  /** Called on every generation so the caller can stay in sync without waiting for onUse */12  onChange?: (password: string) => void;13  /** Compact two-row layout for use inside entry edit mode */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          {/* ── Compact layout ──────────────────────────────── */}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          {/* ── Full layout ─────────────────────────────────── */}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}