GeneratorDrawer.tsx

11.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";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              <span class="peach-pwgen-compact-output">{password()}</span>173              <button174                class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"175                onClick={() => regenerate()}176                aria-label="Regenerate"177              >178                <RefreshIcon />179              </button>180              <button181                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}182                onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}183                aria-label="Copy"184              >185                <CopyIcon /><span class="peach-copy-sheen" />186              </button>187            </div>188            <div class="peach-pwgen-compact-controls">189              <input190                type="range"191                class="peach-pwgen-slider peach-pwgen-compact-slider"192                min="8"193                max="128"194                value={length()}195                onInput={(e) => {196                  const n = parseInt((e.target as HTMLInputElement).value, 10);197                  setLength(n);198                  regenerate({ length: n });199                }}200              />201              <Show202                when={editingLength()}203                fallback={204                  <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>205                }206              >207                <input208                  class="peach-pwgen-len-input"209                  type="text"210                  inputmode="numeric"211                  value={lengthDraft()}212                  ref={(el) => requestAnimationFrame(() => el.select())}213                  onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}214                  onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}215                  onBlur={commitLength}216                />217              </Show>218              <div class="peach-pwgen-compact-pills">219                <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>220                <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>221                <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>222                <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>223              </div>224              <button225                class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"226                onClick={() => props.onUse(password())}227              >228                use229              </button>230            </div>231          </Show>232233          {/* ── Full layout ─────────────────────────────────── */}234          <Show when={!props.compact}>235            <div class="peach-pwgen-display-row">236              <p237                class="peach-pwgen-output-display"238                style={{ "font-size": passwordFontSize() }}239              >240                {password()}241              </p>242              <div class="peach-pwgen-display-actions">243                <button244                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"245                  onClick={() => regenerate()}246                  aria-label="Regenerate password"247                >248                  <RefreshIcon />249                </button>250                <button251                  class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}252                  onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}253                  aria-label="Copy password"254                >255                  <CopyIcon /><span class="peach-copy-sheen" />256                </button>257              </div>258            </div>259260            <div class="peach-pwgen-length-row">261              <div class="peach-pwgen-length-labels">262                <span class="peach-label">length</span>263                <Show264                  when={editingLength()}265                  fallback={266                    <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>267                  }268                >269                  <input270                    class="peach-pwgen-len-input"271                    type="text"272                    inputmode="numeric"273                    value={lengthDraft()}274                    ref={(el) => requestAnimationFrame(() => el.select())}275                    onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}276                    onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}277                    onBlur={commitLength}278                  />279                </Show>280              </div>281              <input282                type="range"283                class="peach-pwgen-slider"284                min="8"285                max="128"286                value={length()}287                onInput={(event) => {288                  const nextLength = parseInt(289                    (event.target as HTMLInputElement).value,290                    10291                  );292                  setLength(nextLength);293                  regenerate({ length: nextLength });294                }}295              />296            </div>297298            <div class="peach-pwgen-charset-pills">299              <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>300              <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>301              <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>302              <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>303            </div>304305            <button306              class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"307              onClick={() => props.onUse(password())}308            >309              use this password310            </button>311          </Show>312313        </div>314      </div>315    </div>316  );317}