GeneratorDrawer.tsx

14.6 KB · raw

1import { createEffect, createMemo, 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("");28  const [userHasAdjusted, setUserHasAdjusted] = createSignal(false);29  const [sliderMin, setSliderMin] = createSignal(8);30  const [sliderMax, setSliderMax] = createSignal(128);31  const [sitePolicy, setSitePolicy] = createSignal<PasswordPolicy | null>(null);3233  function initFromPolicy(policy: PasswordPolicy) {34    setSitePolicy(policy);3536    // Update slider bounds to reflect site constraints (cases 5, 6)37    if (policy.minLength) setSliderMin(Math.max(1, policy.minLength));38    if (policy.maxLength) setSliderMax(Math.min(128, policy.maxLength));3940    const currentLength = length();4142    if (!userHasAdjusted()) {43      // Case 2: fresh open — snap to site max (strongest password the site accepts)44      const target = policy.maxLength ?? currentLength;45      // Case 5/6: raise to min if max is somehow below it46      setLength(policy.minLength ? Math.max(target, policy.minLength) : target);47    } else {48      // Case 3: user set higher than site max — snap down49      if (policy.maxLength && currentLength > policy.maxLength) {50        setLength(policy.maxLength);51      }52      // Case 5: user set lower than site min — snap up53      if (policy.minLength && currentLength < policy.minLength) {54        setLength(policy.minLength);55      }56      // Case 4: within bounds — keep user's value (no-op)57    }5859    let nextSymbols = symbols();60    if (policy.forbidsSymbols) nextSymbols = false;61    if (policy.requiresSymbols) nextSymbols = true;62    setSymbols(nextSymbols);63    setForbiddenChars(policy.forbiddenChars ?? "");64  }6566  // Reset adjustment flag each time the drawer opens fresh67  createEffect<boolean>((wasOpen) => {68    const isOpen = props.open;69    if (!wasOpen && isOpen) setUserHasAdjusted(false);70    return isOpen;71  }, false);7273  onMount(async () => {74    if (props.policy) {75      initFromPolicy(props.policy);76    } else {77      const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });78      const data = (res as { ok: boolean; data?: PasswordPolicy }).data;79      if (data && Object.keys(data).length > 0) {80        initFromPolicy(data);81      }82    }83    regenerate();84  });8586  const isConstrained = createMemo(() => {87    const policy = sitePolicy();88    if (!policy) return false;89    const len = length();90    return (!!policy.maxLength && len >= policy.maxLength) ||91           (!!policy.minLength && len <= policy.minLength);92  });9394  const constraintNote = createMemo(() => {95    const policy = sitePolicy();96    if (!policy) return "";97    const len = length();98    if (policy.maxLength && len >= policy.maxLength) {99      return `This site allows max ${policy.maxLength} characters`;100    }101    if (policy.minLength && len <= policy.minLength) {102      return `This site requires at least ${policy.minLength} characters`;103    }104    return "";105  });106107  function handleLengthChange(n: number) {108    setUserHasAdjusted(true);109    setLength(n);110    regenerate({ length: n });111  }112113  function regenerate(options?: {114    length?: number;115    upper?: boolean;116    lower?: boolean;117    numbers?: boolean;118    symbols?: boolean;119  }) {120    const pw = generatePassword({121      length: options?.length ?? length(),122      includeUppercase: options?.upper ?? upper(),123      includeLowercase: options?.lower ?? lower(),124      includeNumbers: options?.numbers ?? numbers(),125      includeSymbols: options?.symbols ?? symbols(),126      forbiddenChars: forbiddenChars(),127    });128    setPassword(pw);129    props.onChange?.(pw);130  }131132  async function copyPassword(button?: HTMLButtonElement) {133    const ok = await popupCopy(password(), "password");134    if (ok) {135      triggerRing();136      triggerPipPulse();137      setCopied(true);138      if (button) {139        button.classList.remove("is-retreat");140        const drain = document.createElement("span");141        drain.className = "peach-copy-drain";142        const bar = document.createElement("span");143        bar.className = "peach-copy-drain-bar";144        drain.appendChild(bar);145        button.appendChild(drain);146        window.setTimeout(() => {147          setCopied(false);148          button.classList.remove("is-copied");149          button.classList.add("is-retreat");150          drain.remove();151          window.setTimeout(() => button.classList.remove("is-retreat"), 160);152        }, 1400);153        return;154      }155      window.setTimeout(() => setCopied(false), 1400);156    }157  }158159  function toggleUppercase() {160    const next = !upper();161    setUpper(next);162    regenerate({ upper: next });163  }164165  function toggleLowercase() {166    const next = !lower();167    setLower(next);168    regenerate({ lower: next });169  }170171  function toggleNumbers() {172    const next = !numbers();173    setNumbers(next);174    regenerate({ numbers: next });175  }176177  function toggleSymbols() {178    const next = !symbols();179    setSymbols(next);180    regenerate({ symbols: next });181  }182183  function startEditingLength() {184    setLengthDraft(String(length()));185    setEditingLength(true);186  }187188  function commitLength() {189    const n = parseInt(lengthDraft(), 10);190    if (!Number.isNaN(n)) {191      const clamped = Math.max(sliderMin(), Math.min(sliderMax(), n));192      handleLengthChange(clamped);193    }194    setEditingLength(false);195  }196197  const passwordFontSize = () => {198    const value = password();199    if (value.length > 48) return "16px";200    if (value.length > 24) return "20px";201    return "24px";202  };203204  const RefreshIcon = () => (205    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">206      <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />207      <path d="M9.5 2.5H11V1" />208    </svg>209  );210211  const CopyIcon = () => (212    <span class="peach-copy-icon">213      <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">214        <rect x="4" y="4" width="8" height="9" rx="1.5" />215        <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" />216      </svg>217    </span>218  );219220  return (221    <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>222      <div class="peach-generator-drawer-inner">223        <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>224225          {/* ── Compact layout ──────────────────────────────── */}226          <Show when={props.compact}>227            <div class="peach-pwgen-compact-display-row">228              <input229                class="peach-pwgen-compact-output"230                type="text"231                autocomplete="off"232                spellcheck={false}233                value={password()}234                onInput={(e) => {235                  const pw = (e.currentTarget as HTMLInputElement).value;236                  setPassword(pw);237                  setLength(pw.length);238                  props.onChange?.(pw);239                }}240                aria-label="Generated password — click to edit"241              />242              <button243                class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"244                onClick={() => regenerate()}245                aria-label="Regenerate"246              >247                <RefreshIcon />248              </button>249              <button250                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}251                onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}252                aria-label="Copy"253              >254                <CopyIcon /><span class="peach-copy-sheen" />255              </button>256            </div>257            <div class="peach-pwgen-compact-controls">258              <input259                type="range"260                class="peach-pwgen-slider peach-pwgen-compact-slider"261                min={sliderMin()}262                max={sliderMax()}263                value={length()}264                onInput={(e) => {265                  const n = parseInt((e.target as HTMLInputElement).value, 10);266                  handleLengthChange(n);267                }}268              />269              <Show270                when={editingLength()}271                fallback={272                  <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>273                }274              >275                <input276                  class="peach-pwgen-len-input"277                  type="text"278                  inputmode="numeric"279                  value={lengthDraft()}280                  ref={(el) => requestAnimationFrame(() => el.select())}281                  onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}282                  onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}283                  onBlur={commitLength}284                />285              </Show>286              <div class="peach-pwgen-compact-pills">287                <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>288                <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>289                <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>290                <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>291              </div>292              <button293                class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"294                onClick={() => props.onUse(password())}295              >296                use297              </button>298            </div>299            <Show when={isConstrained()}>300              <p class="peach-generator-constraint-note">{constraintNote()}</p>301            </Show>302          </Show>303304          {/* ── Full layout ─────────────────────────────────── */}305          <Show when={!props.compact}>306            <div class="peach-pwgen-display-row">307              <p308                class="peach-pwgen-output-display"309                style={{ "font-size": passwordFontSize() }}310              >311                {password()}312              </p>313              <div class="peach-pwgen-display-actions">314                <button315                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"316                  onClick={() => regenerate()}317                  aria-label="Regenerate password"318                >319                  <RefreshIcon />320                </button>321                <button322                  class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}323                  onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}324                  aria-label="Copy password"325                >326                  <CopyIcon /><span class="peach-copy-sheen" />327                </button>328              </div>329            </div>330331            <div class="peach-pwgen-length-row">332              <div class="peach-pwgen-length-labels">333                <span class="peach-label">length</span>334                <Show335                  when={editingLength()}336                  fallback={337                    <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>338                  }339                >340                  <input341                    class="peach-pwgen-len-input"342                    type="text"343                    inputmode="numeric"344                    value={lengthDraft()}345                    ref={(el) => requestAnimationFrame(() => el.select())}346                    onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}347                    onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}348                    onBlur={commitLength}349                  />350                </Show>351              </div>352              <input353                type="range"354                class="peach-pwgen-slider"355                min={sliderMin()}356                max={sliderMax()}357                value={length()}358                onInput={(event) => {359                  const n = parseInt(360                    (event.target as HTMLInputElement).value,361                    10362                  );363                  handleLengthChange(n);364                }}365              />366              <Show when={isConstrained()}>367                <p class="peach-generator-constraint-note">{constraintNote()}</p>368              </Show>369            </div>370371            <div class="peach-pwgen-charset-pills">372              <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>373              <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>374              <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>375              <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>376            </div>377378            <button379              class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"380              onClick={() => props.onUse(password())}381            >382              use this password383            </button>384          </Show>385386        </div>387      </div>388    </div>389  );390}