GeneratorDrawer.tsx

9.8 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  /** Compact two-row layout for use inside entry edit mode */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);2425  function initFromPolicy(policy: PasswordPolicy) {26    let nextLength = length();27    let nextSymbols = symbols();2829    if (policy.minLength) nextLength = Math.max(nextLength, policy.minLength);30    if (policy.maxLength) nextLength = Math.min(nextLength, policy.maxLength);31    if (policy.forbidsSymbols) nextSymbols = false;32    if (policy.requiresSymbols) nextSymbols = true;3334    setLength(nextLength);35    setSymbols(nextSymbols);36    setForbiddenChars(policy.forbiddenChars ?? "");37  }3839  onMount(async () => {40    if (props.policy) {41      initFromPolicy(props.policy);42    } else {43      const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });44      const data = (res as { ok: boolean; data?: PasswordPolicy }).data;45      if (data && Object.keys(data).length > 0) {46        initFromPolicy(data);47      }48    }49    regenerate();50  });5152  function regenerate(options?: {53    length?: number;54    upper?: boolean;55    lower?: boolean;56    numbers?: boolean;57    symbols?: boolean;58  }) {59    setPassword(60      generatePassword({61        length: options?.length ?? length(),62        includeUppercase: options?.upper ?? upper(),63        includeLowercase: options?.lower ?? lower(),64        includeNumbers: options?.numbers ?? numbers(),65        includeSymbols: options?.symbols ?? symbols(),66        forbiddenChars: forbiddenChars(),67      })68    );69  }7071  async function copyPassword(button?: HTMLButtonElement) {72    const ok = await popupCopy(password(), "password");73    if (ok) {74      triggerRing();75      triggerPipPulse();76      setCopied(true);77      if (button) {78        button.classList.remove("is-retreat");79        const drain = document.createElement("span");80        drain.className = "peach-copy-drain";81        const bar = document.createElement("span");82        bar.className = "peach-copy-drain-bar";83        drain.appendChild(bar);84        button.appendChild(drain);85        window.setTimeout(() => {86          setCopied(false);87          button.classList.remove("is-copied");88          button.classList.add("is-retreat");89          drain.remove();90          window.setTimeout(() => button.classList.remove("is-retreat"), 160);91        }, 1400);92        return;93      }94      window.setTimeout(() => setCopied(false), 1400);95    }96  }9798  function toggleUppercase() {99    const next = !upper();100    setUpper(next);101    regenerate({ upper: next });102  }103104  function toggleLowercase() {105    const next = !lower();106    setLower(next);107    regenerate({ lower: next });108  }109110  function toggleNumbers() {111    const next = !numbers();112    setNumbers(next);113    regenerate({ numbers: next });114  }115116  function toggleSymbols() {117    const next = !symbols();118    setSymbols(next);119    regenerate({ symbols: next });120  }121122  const passwordFontSize = () => {123    const value = password();124    if (value.length > 48) return "16px";125    if (value.length > 24) return "20px";126    return "24px";127  };128129  const RefreshIcon = () => (130    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">131      <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />132      <path d="M9.5 2.5H11V1" />133    </svg>134  );135136  const CopyIcon = () => (137    <span class="peach-copy-icon">138      <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">139        <rect x="4" y="4" width="8" height="9" rx="1.5" />140        <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" />141      </svg>142    </span>143  );144145  return (146    <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>147      <div class="peach-generator-drawer-inner">148        <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>149150          {/* ── Compact layout ──────────────────────────────── */}151          <Show when={props.compact}>152            <div class="peach-pwgen-compact-display-row">153              <span class="peach-pwgen-compact-output">{password()}</span>154              <button155                class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"156                onClick={() => regenerate()}157                aria-label="Regenerate"158              >159                <RefreshIcon />160              </button>161              <button162                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}163                onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}164                aria-label="Copy"165              >166                <CopyIcon /><span class="peach-copy-sheen" />167              </button>168            </div>169            <div class="peach-pwgen-compact-controls">170              <input171                type="range"172                class="peach-pwgen-slider peach-pwgen-compact-slider"173                min="8"174                max="128"175                value={length()}176                onInput={(e) => {177                  const n = parseInt((e.target as HTMLInputElement).value, 10);178                  setLength(n);179                  regenerate({ length: n });180                }}181              />182              <span class="peach-pwgen-len-num">{length()}</span>183              <div class="peach-pwgen-compact-pills">184                <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>185                <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>186                <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>187                <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>188              </div>189              <button190                class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"191                onClick={() => props.onUse(password())}192              >193                use194              </button>195            </div>196          </Show>197198          {/* ── Full layout ─────────────────────────────────── */}199          <Show when={!props.compact}>200            <div class="peach-pwgen-display-row">201              <p202                class="peach-pwgen-output-display"203                style={{ "font-size": passwordFontSize() }}204              >205                {password()}206              </p>207              <div class="peach-pwgen-display-actions">208                <button209                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"210                  onClick={() => regenerate()}211                  aria-label="Regenerate password"212                >213                  <RefreshIcon />214                </button>215                <button216                  class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}217                  onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}218                  aria-label="Copy password"219                >220                  <CopyIcon /><span class="peach-copy-sheen" />221                </button>222              </div>223            </div>224225            <div class="peach-pwgen-length-row">226              <div class="peach-pwgen-length-labels">227                <span class="peach-label">length</span>228                <span class="peach-pwgen-len-num">{length()}</span>229              </div>230              <input231                type="range"232                class="peach-pwgen-slider"233                min="8"234                max="128"235                value={length()}236                onInput={(event) => {237                  const nextLength = parseInt(238                    (event.target as HTMLInputElement).value,239                    10240                  );241                  setLength(nextLength);242                  regenerate({ length: nextLength });243                }}244              />245            </div>246247            <div class="peach-pwgen-charset-pills">248              <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>249              <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>250              <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>251              <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>252            </div>253254            <button255              class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"256              onClick={() => props.onUse(password())}257            >258              use this password259            </button>260          </Show>261262        </div>263      </div>264    </div>265  );266}