GeneratorDrawer.tsx

17.3 KB · raw

1import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js";2import {3  DEFAULT_PASSPHRASE_WORDS,4  generatePassphrase,5  generatePassword,6  MAX_PASSPHRASE_WORDS,7  MIN_PASSPHRASE_WORDS,8  type PassphraseSeparator,9} from "../../shared/generate";10import type { PasswordPolicy } from "../../shared/types";11import { sendMessage, triggerPipPulse, triggerRing } from "../App";12import { popupCopy } from "../copy";1314interface GeneratorDrawerProps {15  open: boolean;16  policy?: PasswordPolicy;17  onUse: (password: string) => void;18  /** Called on every generation so the caller can stay in sync without waiting for onUse */19  onChange?: (password: string) => void;20  /** Compact two-row layout for use inside entry edit mode */21  compact?: boolean;22}2324export function GeneratorDrawer(props: GeneratorDrawerProps) {25  const [mode, setMode] = createSignal<"password" | "passphrase">("password");26  const [password, setPassword] = createSignal("");27  const [length, setLength] = createSignal(64);28  const [wordCount, setWordCount] = createSignal(DEFAULT_PASSPHRASE_WORDS);29  const [separator, setSeparator] = createSignal<PassphraseSeparator>("-");30  const [upper, setUpper] = createSignal(true);31  const [lower, setLower] = createSignal(true);32  const [numbers, setNumbers] = createSignal(true);33  const [symbols, setSymbols] = createSignal(true);34  const [forbiddenChars, setForbiddenChars] = createSignal("");35  const [copied, setCopied] = createSignal(false);36  const [editingLength, setEditingLength] = createSignal(false);37  const [lengthDraft, setLengthDraft] = createSignal("");38  const [userHasAdjusted, setUserHasAdjusted] = createSignal(false);39  const [sliderMin, setSliderMin] = createSignal(8);40  const [sliderMax, setSliderMax] = createSignal(256);41  const [sitePolicy, setSitePolicy] = createSignal<PasswordPolicy | null>(null);4243  function initFromPolicy(policy: PasswordPolicy) {44    setSitePolicy(policy);4546    // Update slider bounds to reflect site constraints (cases 5, 6)47    if (policy.minLength) setSliderMin(Math.max(1, policy.minLength));48    if (policy.maxLength) setSliderMax(Math.min(256, policy.maxLength));4950    const currentLength = length();5152    if (!userHasAdjusted()) {53      // Case 2: fresh open — snap to site max (strongest password the site accepts)54      const target = policy.maxLength ?? currentLength;55      // Case 5/6: raise to min if max is somehow below it56      setLength(policy.minLength ? Math.max(target, policy.minLength) : target);57    } else {58      // Case 3: user set higher than site max — snap down59      if (policy.maxLength && currentLength > policy.maxLength) {60        setLength(policy.maxLength);61      }62      // Case 5: user set lower than site min — snap up63      if (policy.minLength && currentLength < policy.minLength) {64        setLength(policy.minLength);65      }66      // Case 4: within bounds — keep user's value (no-op)67    }6869    let nextSymbols = symbols();70    if (policy.forbidsSymbols) nextSymbols = false;71    if (policy.requiresSymbols) nextSymbols = true;72    setSymbols(nextSymbols);73    setForbiddenChars(policy.forbiddenChars ?? "");74  }7576  // Reset adjustment flag each time the drawer opens fresh77  createEffect<boolean>((wasOpen) => {78    const isOpen = props.open;79    if (!wasOpen && isOpen) setUserHasAdjusted(false);80    return isOpen;81  }, false);8283  onMount(async () => {84    if (props.policy) {85      initFromPolicy(props.policy);86    } else {87      const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });88      const data = (res as { ok: boolean; data?: PasswordPolicy }).data;89      if (data && Object.keys(data).length > 0) {90        initFromPolicy(data);91      }92    }93    regenerate();94  });9596  const isConstrained = createMemo(() => {97    if (mode() === "passphrase") return false;98    const policy = sitePolicy();99    if (!policy) return false;100    const len = length();101    return (!!policy.maxLength && len >= policy.maxLength) ||102           (!!policy.minLength && len <= policy.minLength);103  });104105  const constraintNote = createMemo(() => {106    if (mode() === "passphrase") return "";107    const policy = sitePolicy();108    if (!policy) return "";109    const len = length();110    if (policy.maxLength && len >= policy.maxLength) {111      return `This site allows max ${policy.maxLength} characters`;112    }113    if (policy.minLength && len <= policy.minLength) {114      return `This site requires at least ${policy.minLength} characters`;115    }116    return "";117  });118119  function handleLengthChange(n: number) {120    if (mode() === "passphrase") {121      setWordCount(n);122      regenerate({ wordCount: n });123      return;124    }125    setUserHasAdjusted(true);126    setLength(n);127    regenerate({ length: n });128  }129130  function regenerate(options?: {131    mode?: "password" | "passphrase";132    length?: number;133    wordCount?: number;134    separator?: PassphraseSeparator;135    upper?: boolean;136    lower?: boolean;137    numbers?: boolean;138    symbols?: boolean;139  }) {140    const nextMode = options?.mode ?? mode();141    const pw = nextMode === "passphrase"142      ? generatePassphrase({143          wordCount: options?.wordCount ?? wordCount(),144          separator: options?.separator ?? separator(),145        })146      : generatePassword({147          length: options?.length ?? length(),148          includeUppercase: options?.upper ?? upper(),149          includeLowercase: options?.lower ?? lower(),150          includeNumbers: options?.numbers ?? numbers(),151          includeSymbols: options?.symbols ?? symbols(),152          forbiddenChars: forbiddenChars(),153        });154    setPassword(pw);155    props.onChange?.(pw);156  }157158  async function copyPassword(button?: HTMLButtonElement) {159    const ok = await popupCopy(password(), "password");160    if (ok) {161      triggerRing();162      triggerPipPulse();163      setCopied(true);164      if (button) {165        button.classList.remove("is-retreat");166        const drain = document.createElement("span");167        drain.className = "peach-copy-drain";168        const bar = document.createElement("span");169        bar.className = "peach-copy-drain-bar";170        drain.appendChild(bar);171        button.appendChild(drain);172        window.setTimeout(() => {173          setCopied(false);174          button.classList.remove("is-copied");175          button.classList.add("is-retreat");176          drain.remove();177          window.setTimeout(() => button.classList.remove("is-retreat"), 160);178        }, 1400);179        return;180      }181      window.setTimeout(() => setCopied(false), 1400);182    }183  }184185  function toggleUppercase() {186    const next = !upper();187    setUpper(next);188    regenerate({ upper: next });189  }190191  function toggleLowercase() {192    const next = !lower();193    setLower(next);194    regenerate({ lower: next });195  }196197  function toggleNumbers() {198    const next = !numbers();199    setNumbers(next);200    regenerate({ numbers: next });201  }202203  function toggleSymbols() {204    const next = !symbols();205    setSymbols(next);206    regenerate({ symbols: next });207  }208209  function startEditingLength() {210    setLengthDraft(String(mode() === "passphrase" ? wordCount() : length()));211    setEditingLength(true);212  }213214  function commitLength() {215    const n = parseInt(lengthDraft(), 10);216    if (!Number.isNaN(n)) {217      const min = mode() === "passphrase" ? MIN_PASSPHRASE_WORDS : sliderMin();218      const max = mode() === "passphrase" ? MAX_PASSPHRASE_WORDS : sliderMax();219      const clamped = Math.max(min, Math.min(max, n));220      handleLengthChange(clamped);221    }222    setEditingLength(false);223  }224225  const passwordFontSize = () => {226    const value = password();227    if (value.length > 48) return "16px";228    if (value.length > 24) return "20px";229    return "24px";230  };231232  function selectMode(nextMode: "password" | "passphrase") {233    setMode(nextMode);234    setEditingLength(false);235    regenerate({ mode: nextMode });236  }237238  function selectSeparator(nextSeparator: PassphraseSeparator) {239    setSeparator(nextSeparator);240    regenerate({ separator: nextSeparator });241  }242243  const currentAmount = () => mode() === "passphrase" ? wordCount() : length();244  const currentMin = () => mode() === "passphrase" ? MIN_PASSPHRASE_WORDS : sliderMin();245  const currentMax = () => mode() === "passphrase" ? MAX_PASSPHRASE_WORDS : sliderMax();246247  const ModeControls = () => (248    <div class="peach-pwgen-mode-pills" aria-label="Generator type">249      <button class={`peach-pwgen-pill ${mode() === "password" ? "is-on" : ""}`} aria-pressed={mode() === "password"} onClick={() => selectMode("password")} type="button">password</button>250      <button class={`peach-pwgen-pill ${mode() === "passphrase" ? "is-on" : ""}`} aria-pressed={mode() === "passphrase"} onClick={() => selectMode("passphrase")} type="button">passphrase</button>251    </div>252  );253254  const OptionPills = () => (255    <Show256      when={mode() === "passphrase"}257      fallback={258        <>259          <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} aria-pressed={upper()} onClick={toggleUppercase} type="button">A-Z</button>260          <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} aria-pressed={lower()} onClick={toggleLowercase} type="button">a-z</button>261          <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} aria-pressed={numbers()} onClick={toggleNumbers} type="button">0-9</button>262          <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} aria-pressed={symbols()} onClick={toggleSymbols} type="button">!@#</button>263        </>264      }265    >266      {(["-", " ", ".", "_"] as PassphraseSeparator[]).map((value) => (267        <button268          class={`peach-pwgen-pill ${separator() === value ? "is-on" : ""}`}269          aria-label={value === " " ? "Space separator" : `${value} separator`}270          aria-pressed={separator() === value}271          onClick={() => selectSeparator(value)}272          type="button"273        >274          {value === " " ? "space" : value}275        </button>276      ))}277    </Show>278  );279280  const RefreshIcon = () => (281    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">282      <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />283      <path d="M9.5 2.5H11V1" />284    </svg>285  );286287  const CopyIcon = () => (288    <span class="peach-copy-icon">289      <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">290        <rect x="4" y="4" width="8" height="9" rx="1.5" />291        <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" />292      </svg>293    </span>294  );295296  return (297    <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>298      <div class="peach-generator-drawer-inner">299        <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>300301          {/* ── Compact layout ──────────────────────────────── */}302          <Show when={props.compact}>303            <ModeControls />304            <div class="peach-pwgen-compact-display-row">305              <input306                class="peach-pwgen-compact-output"307                type="text"308                autocomplete="off"309                spellcheck={false}310                value={password()}311                onInput={(e) => {312                  const pw = (e.currentTarget as HTMLInputElement).value;313                  setPassword(pw);314                  if (mode() === "password") setLength(pw.length);315                  props.onChange?.(pw);316                }}317                aria-label="Generated password — click to edit"318              />319              <button320                class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"321                onClick={() => regenerate()}322                aria-label="Regenerate"323              >324                <RefreshIcon />325              </button>326              <button327                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}328                onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}329                aria-label="Copy"330              >331                <CopyIcon /><span class="peach-copy-sheen" />332              </button>333            </div>334            <div class="peach-pwgen-compact-controls">335              <input336                type="range"337                class="peach-pwgen-slider peach-pwgen-compact-slider"338                min={currentMin()}339                max={currentMax()}340                value={currentAmount()}341                aria-label={mode() === "passphrase" ? "Word count" : "Password length"}342                onInput={(e) => {343                  const n = parseInt((e.target as HTMLInputElement).value, 10);344                  handleLengthChange(n);345                }}346              />347              <Show348                when={editingLength()}349                fallback={350                  <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{currentAmount()}</span>351                }352              >353                <input354                  class="peach-pwgen-len-input"355                  type="text"356                  inputmode="numeric"357                  value={lengthDraft()}358                  ref={(el) => requestAnimationFrame(() => el.select())}359                  onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}360                  onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}361                  onBlur={commitLength}362                />363              </Show>364              <div class="peach-pwgen-compact-pills">365                <OptionPills />366              </div>367              <button368                class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"369                onClick={() => props.onUse(password())}370              >371                use372              </button>373            </div>374            <Show when={isConstrained()}>375              <p class="peach-generator-constraint-note">{constraintNote()}</p>376            </Show>377          </Show>378379          {/* ── Full layout ─────────────────────────────────── */}380          <Show when={!props.compact}>381            <ModeControls />382            <div class="peach-pwgen-display-row">383              <p384                class="peach-pwgen-output-display"385                style={{ "font-size": passwordFontSize() }}386              >387                {password()}388              </p>389              <div class="peach-pwgen-display-actions">390                <button391                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"392                  onClick={() => regenerate()}393                  aria-label="Regenerate password"394                >395                  <RefreshIcon />396                </button>397                <button398                  class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}399                  onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}400                  aria-label="Copy password"401                >402                  <CopyIcon /><span class="peach-copy-sheen" />403                </button>404              </div>405            </div>406407            <div class="peach-pwgen-length-row">408              <div class="peach-pwgen-length-labels">409                <span class="peach-label">{mode() === "passphrase" ? "word count" : "length"}</span>410                <Show411                  when={editingLength()}412                  fallback={413                    <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{currentAmount()}</span>414                  }415                >416                  <input417                    class="peach-pwgen-len-input"418                    type="text"419                    inputmode="numeric"420                    value={lengthDraft()}421                    ref={(el) => requestAnimationFrame(() => el.select())}422                    onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}423                    onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}424                    onBlur={commitLength}425                  />426                </Show>427              </div>428              <input429                type="range"430                class="peach-pwgen-slider"431                min={currentMin()}432                max={currentMax()}433                value={currentAmount()}434                aria-label={mode() === "passphrase" ? "Word count" : "Password length"}435                onInput={(event) => {436                  const n = parseInt(437                    (event.target as HTMLInputElement).value,438                    10439                  );440                  handleLengthChange(n);441                }}442              />443              <Show when={isConstrained()}>444                <p class="peach-generator-constraint-note">{constraintNote()}</p>445              </Show>446            </div>447448            <div class="peach-pwgen-charset-pills">449              <OptionPills />450            </div>451452            <button453              class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"454              onClick={() => props.onUse(password())}455            >456              use this {mode()}457            </button>458          </Show>459460        </div>461      </div>462    </div>463  );464}