GeneratorDrawer.tsx

9.9 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";56interface GeneratorDrawerProps {7  open: boolean;8  policy?: PasswordPolicy;9  onUse: (password: string) => void;10  /** Compact two-row layout for use inside entry edit mode */11  compact?: boolean;12}1314export function GeneratorDrawer(props: GeneratorDrawerProps) {15  const [password, setPassword] = createSignal("");16  const [length, setLength] = createSignal(20);17  const [upper, setUpper] = createSignal(true);18  const [lower, setLower] = createSignal(true);19  const [numbers, setNumbers] = createSignal(true);20  const [symbols, setSymbols] = createSignal(true);21  const [forbiddenChars, setForbiddenChars] = createSignal("");22  const [copied, setCopied] = createSignal(false);2324  function initFromPolicy(policy: PasswordPolicy) {25    let nextLength = length();26    let nextSymbols = symbols();2728    if (policy.minLength) nextLength = Math.max(nextLength, policy.minLength);29    if (policy.maxLength) nextLength = Math.min(nextLength, policy.maxLength);30    if (policy.forbidsSymbols) nextSymbols = false;31    if (policy.requiresSymbols) nextSymbols = true;3233    setLength(nextLength);34    setSymbols(nextSymbols);35    setForbiddenChars(policy.forbiddenChars ?? "");36  }3738  onMount(async () => {39    if (props.policy) {40      initFromPolicy(props.policy);41    } else {42      const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });43      const data = (res as { ok: boolean; data?: PasswordPolicy }).data;44      if (data && Object.keys(data).length > 0) {45        initFromPolicy(data);46      }47    }48    regenerate();49  });5051  function regenerate(options?: {52    length?: number;53    upper?: boolean;54    lower?: boolean;55    numbers?: boolean;56    symbols?: boolean;57  }) {58    setPassword(59      generatePassword({60        length: options?.length ?? length(),61        includeUppercase: options?.upper ?? upper(),62        includeLowercase: options?.lower ?? lower(),63        includeNumbers: options?.numbers ?? numbers(),64        includeSymbols: options?.symbols ?? symbols(),65        forbiddenChars: forbiddenChars(),66      })67    );68  }6970  async function copyPassword(button?: HTMLButtonElement) {71    const response = await sendMessage({72      type: "COPY",73      payload: { text: password(), label: "password" },74    });75    if ((response as { ok: boolean }).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  const passwordFontSize = () => {125    const value = password();126    if (value.length > 48) return "16px";127    if (value.length > 24) return "20px";128    return "24px";129  };130131  const RefreshIcon = () => (132    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">133      <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />134      <path d="M9.5 2.5H11V1" />135    </svg>136  );137138  const CopyIcon = () => (139    <span class="peach-copy-icon">140      <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">141        <rect x="4" y="4" width="8" height="9" rx="1.5" />142        <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" />143      </svg>144    </span>145  );146147  return (148    <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>149      <div class="peach-generator-drawer-inner">150        <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>151152          {/* ── Compact layout ──────────────────────────────── */}153          <Show when={props.compact}>154            <div class="peach-pwgen-compact-display-row">155              <span class="peach-pwgen-compact-output">{password()}</span>156              <button157                class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"158                onClick={() => regenerate()}159                aria-label="Regenerate"160              >161                <RefreshIcon />162              </button>163              <button164                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}165                onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}166                aria-label="Copy"167              >168                <CopyIcon /><span class="peach-copy-sheen" />169              </button>170            </div>171            <div class="peach-pwgen-compact-controls">172              <input173                type="range"174                class="peach-pwgen-slider peach-pwgen-compact-slider"175                min="8"176                max="128"177                value={length()}178                onInput={(e) => {179                  const n = parseInt((e.target as HTMLInputElement).value, 10);180                  setLength(n);181                  regenerate({ length: n });182                }}183              />184              <span class="peach-pwgen-len-num">{length()}</span>185              <div class="peach-pwgen-compact-pills">186                <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>187                <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>188                <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>189                <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>190              </div>191              <button192                class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"193                onClick={() => props.onUse(password())}194              >195                use196              </button>197            </div>198          </Show>199200          {/* ── Full layout ─────────────────────────────────── */}201          <Show when={!props.compact}>202            <div class="peach-pwgen-display-row">203              <p204                class="peach-pwgen-output-display"205                style={{ "font-size": passwordFontSize() }}206              >207                {password()}208              </p>209              <div class="peach-pwgen-display-actions">210                <button211                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"212                  onClick={() => regenerate()}213                  aria-label="Regenerate password"214                >215                  <RefreshIcon />216                </button>217                <button218                  class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}219                  onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}220                  aria-label="Copy password"221                >222                  <CopyIcon /><span class="peach-copy-sheen" />223                </button>224              </div>225            </div>226227            <div class="peach-pwgen-length-row">228              <div class="peach-pwgen-length-labels">229                <span class="peach-label">length</span>230                <span class="peach-pwgen-len-num">{length()}</span>231              </div>232              <input233                type="range"234                class="peach-pwgen-slider"235                min="8"236                max="128"237                value={length()}238                onInput={(event) => {239                  const nextLength = parseInt(240                    (event.target as HTMLInputElement).value,241                    10242                  );243                  setLength(nextLength);244                  regenerate({ length: nextLength });245                }}246              />247            </div>248249            <div class="peach-pwgen-charset-pills">250              <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>251              <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>252              <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>253              <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>254            </div>255256            <button257              class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"258              onClick={() => props.onUse(password())}259            >260              use this password261            </button>262          </Show>263264        </div>265      </div>266    </div>267  );268}