Add.tsx

29.5 KB · raw

1import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../App";4import { parseOtpauthURI } from "../../crypto/totp";5import { NoteEditorInline, Toolbar } from "./NoteEditor";6import { GeneratorDrawer } from "../components/GeneratorDrawer";78interface AddProps {9  entry?: VaultEntry;10  entries?: VaultEntry[];11  autoOpenGenerator?: boolean;12  onBack: () => void;13  onSave: (entry?: VaultEntry) => void | Promise<void>;14}1516export function AddView(props: AddProps) {17  const [title, setTitle] = createSignal(props.entry?.title ?? "");18  const [username, setUsername] = createSignal(props.entry?.username ?? "");19  const [password, setPassword] = createSignal(props.entry?.password ?? "");20  const [url, setUrl] = createSignal(props.entry?.url ?? "");21  const [totp, setTotp] = createSignal(props.entry?.totp ?? "");22  const [notes, setNotes] = createSignal(props.entry?.notes ?? "");23  const [saving, setSaving] = createSignal(false);24  const [error, setError] = createSignal("");25  const [passwordStrength, setPasswordStrength] = createSignal<number | null>(null);26  const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null);27  const [tags, setTags] = createSignal<string[]>(props.entry?.tags ?? []);28  const [tagInput, setTagInput] = createSignal("");29  const [showTagSuggestions, setShowTagSuggestions] = createSignal(false);30  const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false);31  const [showPassword, setShowPassword] = createSignal(false);32  const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp));33  const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));34  const [email, setEmail] = createSignal((props.entry as VaultEntry & { email?: string })?.email ?? "");35  const [activeIdentityField, setActiveIdentityField] = createSignal<"username" | "email" | null>(null);36  const [currentTabUrl, setCurrentTabUrl] = createSignal("");37  const [identityFilter, setIdentityFilter] = createSignal<"all" | "username" | "email">("all");3839  const allIdentities = createMemo(() => {40    const emailMap = new Map<string, number>();41    const usernameMap = new Map<string, number>();42    for (const entry of props.entries ?? []) {43      const uname = entry.username?.trim() ?? "";44      const em = (entry as VaultEntry & { email?: string }).email?.trim() ?? "";45      if (uname) usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);46      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);47    }48    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];49    const seen = new Set<string>();50    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {51      if (seen.has(v)) continue;52      seen.add(v);53      const ec = emailMap.get(v) ?? 0;54      const uc = usernameMap.get(v) ?? 0;55      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });56    }57    return result.sort((a, b) => b.count - a.count);58  });5960  const identitySuggestions = createMemo(() => {61    const field = activeIdentityField();62    if (!field) return [];63    const input = (field === "username" ? username() : email()).toLowerCase();64    const filter = identityFilter();65    return allIdentities()66      .filter(s => (filter === "all" || s.type === filter) && (!input || s.value.toLowerCase().includes(input)))67      .slice(0, 8);68  });6970  const isNoteEntry = () => props.entry?.type === "note";71  const isCardEntry = () => props.entry?.type === "card";72  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(73    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"74  );7576  // Card signals77  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");78  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");79  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");80  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");81  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");82  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");83  const [showCvc, setShowCvc] = createSignal(false);84  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);85  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);8687  function noteExec(cmd: string, val?: string) {88    document.execCommand(cmd, false, val);89    noteEditorEl()?.focus();90  }9192  function getCardNetwork(num: string): string {93    const n = num.replace(/\s/g, '');94    if (/^4/.test(n)) return 'Visa';95    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';96    if (/^3[47]/.test(n)) return 'Amex';97    if (/^6(?:011|5)/.test(n)) return 'Discover';98    return '';99  }100101  function formatCardNumberDisplay(raw: string, focused: boolean): string {102    const n = raw.replace(/\s/g, '');103    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);104    if (/^3[47]/.test(n)) return n.replace(/(\d{4})(\d{1,6})?(\d{1,5})?/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' '));105    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();106  }107108  const canSave = () => {109    if (!title().trim()) return false;110    if (selectedType() === "login" && !password().trim()) return false;111    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;112    return true;113  };114115  const allTags = createMemo(() => {116    const tagSet = new Set<string>();117    for (const entry of props.entries ?? []) {118      for (const tag of entry.tags ?? []) tagSet.add(tag);119    }120    return Array.from(tagSet).sort();121  });122123  const tagSuggestions = createMemo(() => {124    const input = tagInput().toLowerCase().trim();125    if (!input) return [];126    return allTags().filter(t =>127      t.toLowerCase().includes(input) && !tags().includes(t)128    ).slice(0, 5);129  });130131  function addTag(tag: string) {132    const n = tag.toLowerCase().trim();133    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;134    setTags([...tags(), n]);135  }136137  function removeTag(tag: string) {138    setTags(tags().filter(t => t !== tag));139  }140141  function handleTagKeyDown(e: KeyboardEvent) {142    if (e.key === "Enter" || e.key === ",") {143      e.preventDefault();144      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }145    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {146      removeTag(tags()[tags().length - 1]);147    }148  }149150  // Strength check151  createEffect(async () => {152    const pwd = password();153    if (pwd.length < 4) { setPasswordStrength(null); return; }154    try {155      if (!zxcvbnFn()) {156        const mod = await import("zxcvbn");157        setZxcvbnFn(() => mod.default);158      }159      const fn = zxcvbnFn();160      if (fn) setPasswordStrength(fn(pwd).score);161    } catch {162      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);163    }164  });165166  onMount(() => {167    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;168    if (el) el.focus();169    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {170      const tabUrl = tabs[0]?.url;171      if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl);172    });173    // Keep the vault alive while the form is open — the auto-lock alarm fires on174    // a timer and doesn't know the user is actively filling in a new entry.175    const keepAlive = setInterval(() => {176      chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});177    }, 30_000);178    onCleanup(() => clearInterval(keepAlive));179  });180181  function getNormalizedTotp(): string | undefined {182    const value = totp().trim();183    if (!value) return undefined;184    if (value.startsWith("otpauth://")) {185      const parsed = parseOtpauthURI(value);186      return parsed?.secret ?? value;187    }188    return value;189  }190191  async function handleSave() {192    if (!canSave()) {193      setError(194        selectedType() === "note"195          ? "title is required"196          : selectedType() === "card"197          ? "cardholder name and card number are required"198          : "title and password are required"199      );200      return;201    }202203    setSaving(true);204    setError("");205206    const payload: Record<string, unknown> = {207      type: selectedType(),208      title: title().trim(),209      notes: notes().trim() || undefined,210      tags: tags().length > 0 ? tags() : undefined,211    };212213    if (selectedType() === "login") {214      payload.username = username().trim();215      payload.email = email().trim() || undefined;216      payload.password = password();217      payload.url = url().trim();218      payload.totp = getNormalizedTotp();219    }220221    if (selectedType() === "card") {222      const raw = cardNumber().replace(/\s/g, '');223      payload.cardholderName = cardholderName().trim();224      payload.cardNumber = raw;225      payload.expMonth = expMonth().trim();226      payload.expYear = expYear().trim();227      payload.cvc = cvc().trim();228      payload.cardNickname = cardNickname().trim() || undefined;229      const n = raw;230      payload.network = /^4/.test(n) ? 'visa' : /^5[1-5]|^2[2-7]/.test(n) ? 'mastercard' : /^3[47]/.test(n) ? 'amex' : /^6(?:011|5)/.test(n) ? 'discover' : 'unknown';231      payload.last4 = raw.slice(-4);232    }233234    if (props.entry) {235      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });236      const result = res as { ok: boolean; error?: string };237      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }238      await props.onSave({ ...props.entry, ...payload } as VaultEntry);239      return;240    }241242    const res = await sendMessage({ type: "ADD_ENTRY", payload });243    const result = res as { ok: boolean; data?: VaultEntry; error?: string };244    if (!result.ok) {245      if (result.error === "vault locked") {246        setError("vault is locked — tap the lock icon to re-enter your master password");247      } else {248        setError(result.error ?? "could not save entry");249      }250      setSaving(false);251      return;252    }253    await props.onSave(result.data);254  }255256  const strengthLabel = () => {257    const s = passwordStrength();258    if (s === null) return null;259    return ["weak", "weak", "fair", "good", "strong"][s] as string;260  };261262  return (263    <div class="peach-overlay-view ov-enter-right" style="display:flex">264      <div class="peach-overlay-header">265        <button266          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"267          onClick={props.onBack}268        >269          cancel270        </button>271        <span class="peach-overlay-title">272          {props.entry ? "edit entry" : "new entry"}273        </span>274        <button275          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"276          onClick={() => void handleSave()}277          disabled={saving() || !canSave()}278        >279          {saving() ? "saving…" : props.entry ? "save" : "create"}280        </button>281      </div>282283      {/* ── Body — mirrors Entry.tsx field-card structure ── */}284      <div class="peach-entry-body peach-entry-body--new">285286        {/* Type toggle */}287        <Show when={!props.entry}>288          <div class="peach-new-type-row">289            <button290              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}291              onClick={() => setSelectedType("login")}292            >login</button>293            <button294              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}295              onClick={() => setSelectedType("note")}296            >note</button>297            <button298              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}299              onClick={() => setSelectedType("card")}300            >card</button>301          </div>302        </Show>303304        {/* Note formatting toolbar — own row so it never overflows */}305        <Show when={selectedType() === "note"}>306          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />307        </Show>308309        {/* Title */}310        <div class="peach-new-title-wrap">311          <input312            class="peach-new-title-input"313            placeholder={selectedType() === "note" ? "note title" : "entry name"}314            autocomplete="off"315            value={title()}316            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}317            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}318          />319        </div>320321        {/* ── Login fields ── */}322        <Show when={selectedType() === "login"}>323          <div class="peach-domain">324            <div class="peach-domain-edit-row">325              <input326                class="peach-domain-value peach-domain-value--edit"327                placeholder="domain.com"328                autocomplete="off"329                value={url()}330                onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}331              />332              <Show when={currentTabUrl()}>333                <button334                  class="peach-domain-url-btn"335                  type="button"336                  onClick={() => setUrl(currentTabUrl())}337                  title="Insert current page URL"338                >339                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">340                    <path d="M4.5 7.5a3 3 0 0 0 4.243 0L10.5 5.75a3 3 0 0 0-4.243-4.243L5.5 2.26" />341                    <path d="M7.5 4.5a3 3 0 0 0-4.243 0L1.5 6.25a3 3 0 0 0 4.243 4.243L6.5 9.74" />342                  </svg>343                </button>344              </Show>345            </div>346          </div>347348          <section class="peach-fields">349            {/* Username */}350            <div class="peach-field-card">351              <span class="peach-label">username</span>352              <div class="peach-field-value-row">353                <input354                  class="peach-field-value peach-field-value--edit"355                  placeholder="johndoe"356                  autocomplete="off"357                  value={username()}358                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}359                  onFocus={() => setActiveIdentityField("username")}360                  onBlur={() => setTimeout(() => setActiveIdentityField(null), 150)}361                />362              </div>363              <Show when={activeIdentityField() === "username" && allIdentities().length > 0}>364                <IdentitySuggestList365                  suggestions={identitySuggestions()}366                  filter={identityFilter()}367                  onFilter={setIdentityFilter}368                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); }}369                />370              </Show>371            </div>372373            {/* Email */}374            <div class="peach-field-card">375              <span class="peach-label">email</span>376              <div class="peach-field-value-row">377                <input378                  class="peach-field-value peach-field-value--edit"379                  type="email"380                  placeholder="[email protected]"381                  autocomplete="off"382                  value={email()}383                  onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}384                  onFocus={() => setActiveIdentityField("email")}385                  onBlur={() => setTimeout(() => setActiveIdentityField(null), 150)}386                />387              </div>388              <Show when={activeIdentityField() === "email" && allIdentities().length > 0}>389                <IdentitySuggestList390                  suggestions={identitySuggestions()}391                  filter={identityFilter()}392                  onFilter={setIdentityFilter}393                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); }}394                />395              </Show>396            </div>397398            {/* Password */}399            <div class="peach-field-card">400              <span class="peach-label">password</span>401              <div class="peach-password-edit-row">402                <input403                  class="peach-field-value peach-field-value--edit"404                  type={showPassword() ? "text" : "password"}405                  placeholder="password"406                  autocomplete="new-password"407                  value={password()}408                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}409                />410                <button411                  class="peach-inline-action peach-password-toggle-btn"412                  type="button"413                  onClick={() => setShowPassword(!showPassword())}414                  aria-label={showPassword() ? "Hide password" : "Show password"}415                >416                  <Show417                    when={showPassword()}418                    fallback={419                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">420                        <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />421                        <circle cx="7" cy="5" r="1.75" />422                      </svg>423                    }424                  >425                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">426                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />427                      <path d="M3.5 5.5L2.5 7.5" />428                      <path d="M7 6.5V8.5" />429                      <path d="M10.5 5.5L11.5 7.5" />430                    </svg>431                  </Show>432                </button>433              </div>434435              {/* Strength */}436              <Show when={strengthLabel() !== null}>437                <div class="peach-strength-hairline">438                  <div class="peach-strength-hairline-track">439                    <div440                      class="peach-strength-hairline-fill"441                      style={{442                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],443                        background: ["rgba(220,80,60,0.65)","rgba(220,80,60,0.65)","rgba(200,140,60,0.6)","rgba(180,162,100,0.55)","rgba(150,175,110,0.6)"][passwordStrength()!],444                      }}445                    />446                  </div>447                  <span class="peach-strength-label">{strengthLabel()}</span>448                </div>449              </Show>450451              {/* Generator toggle */}452              <button453                class="peach-generate-toggle-link"454                type="button"455                onClick={() => { setShowGenerator(!showGenerator()); }}456              >457                {showGenerator() ? "☰ hide generator" : "⚙ generate"}458              </button>459              <GeneratorDrawer460                compact461                open={showGenerator()}462                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}463              />464            </div>465466            {/* 2FA — optional */}467            <Show when={showTotp() || totp()}>468              <div class="peach-field-card">469                <span class="peach-label">2fa secret</span>470                <div class="peach-field-value-row">471                  <input472                    class="peach-field-value peach-field-value--edit"473                    placeholder="BASE32 or otpauth:// URI"474                    autocomplete="off"475                    value={totp()}476                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}477                  />478                </div>479              </div>480            </Show>481          </section>482        </Show>483484        {/* ── Card fields ── */}485        <Show when={selectedType() === "card"}>486          <section class="peach-fields">487            {/* Nickname */}488            <div class="peach-field-card">489              <span class="peach-label">nickname</span>490              <div class="peach-field-value-row">491                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"492                  autocomplete="off" value={cardNickname()}493                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />494              </div>495            </div>496            {/* Cardholder name */}497            <div class="peach-field-card">498              <span class="peach-label">cardholder name</span>499              <div class="peach-field-value-row">500                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"501                  autocomplete="cc-name" value={cardholderName()}502                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />503              </div>504            </div>505            {/* Card number */}506            <div class="peach-field-card">507              <span class="peach-label">508                card number509                <Show when={getCardNetwork(cardNumber())}>510                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>511                </Show>512              </span>513              <div class="peach-field-value-row">514                <input class="peach-field-value peach-field-value--edit peach-card-number"515                  placeholder="•••• •••• •••• ••••"516                  autocomplete="cc-number" inputmode="numeric" maxLength={19}517                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}518                  onFocus={() => setCardNumberFocused(true)}519                  onBlur={() => setCardNumberFocused(false)}520                  onInput={(e) => {521                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');522                    setCardNumber(raw);523                  }} />524              </div>525            </div>526            {/* Expiry */}527            <div class="peach-field-card">528              <span class="peach-label">expiry</span>529              <div class="peach-card-expiry-row">530                <input class="peach-field-value peach-field-value--edit" placeholder="MM"531                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"532                  value={expMonth()}533                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />534                <span class="peach-card-expiry-sep">/</span>535                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"536                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"537                  value={expYear()}538                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />539              </div>540            </div>541            {/* CVC */}542            <div class="peach-field-card">543              <span class="peach-label">CVC</span>544              <div class="peach-field-value-row">545                <input class="peach-field-value peach-field-value--edit"546                  type={showCvc() ? "text" : "password"} placeholder="•••"547                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}548                  value={cvc()}549                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />550                <button class="peach-inline-action peach-password-toggle-btn" type="button"551                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>552                  <Show when={showCvc()} fallback={553                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">554                      <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />555                      <circle cx="7" cy="5" r="1.75" />556                    </svg>557                  }>558                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">559                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />560                    </svg>561                  </Show>562                </button>563              </div>564            </div>565          </section>566        </Show>567568        {/* ── Note content ── */}569        <Show when={selectedType() === "note"}>570          <div class="peach-note-body">571            <NoteEditorInline572              initialContent={notes()}573              onContentChange={setNotes}574              onReady={(el) => setNoteEditorEl(el)}575            />576          </div>577        </Show>578579        {/* ── Login notes ── */}580        <Show when={selectedType() === "login" && (showNotes() || notes())}>581          <div class="peach-field-card" style="margin:0 12px">582            <span class="peach-label">notes</span>583            <textarea584              class="peach-new-textarea"585              placeholder="anything worth remembering"586              value={notes()}587              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}588            />589          </div>590        </Show>591592        {/* ── "+ add" optional field links ── */}593        <div class="peach-entry-add-links">594          <Show when={selectedType() === "login" && !showTotp() && !totp()}>595            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>596              + add 2FA597            </button>598          </Show>599          <Show when={selectedType() === "login" && !showNotes() && !notes()}>600            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>601              + add notes602            </button>603          </Show>604        </div>605606        {/* ── Tags ── */}607        <div class="peach-field-card" style="margin:4px 12px 12px">608          <span class="peach-label">tags</span>609          <div class="peach-tag-input-wrap">610            <For each={tags()}>611              {(tag) => (612                <span class="peach-tag-chip">613                  {tag}614                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>615                </span>616              )}617            </For>618            <input619              class="peach-tag-input"620              placeholder={tags().length === 0 ? "add tags…" : ""}621              value={tagInput()}622              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}623              onKeyDown={handleTagKeyDown}624              onFocus={() => setShowTagSuggestions(true)}625              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}626              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"627            />628          </div>629          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>630            <div class="peach-tag-suggestions" style="margin-top:4px;">631              <For each={tagSuggestions()}>632                {(s) => (633                  <button634                    class="peach-tag-suggestion"635                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}636                  >{s}</button>637                )}638              </For>639            </div>640          </Show>641        </div>642643        <Show when={error()}>644          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>645        </Show>646647      </div>648    </div>649  );650}651652interface IdentitySuggestListProps {653  suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;654  filter: "all" | "username" | "email";655  onFilter: (f: "all" | "username" | "email") => void;656  onPick: (value: string) => void;657}658659function IdentitySuggestList(props: IdentitySuggestListProps) {660  const [collapsed, setCollapsed] = createSignal(false);661  const ChevronIcon = () => (662    <svg width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">663      <path d="M1 1L5 5L9 1" />664    </svg>665  );666  return (667    <div class="peach-identity-suggestions">668      <div class="peach-identity-filter-row">669        <button class={`peach-identity-filter-pill ${props.filter === "all" ? "is-on" : ""}`} type="button" onMouseDown={(e) => { e.preventDefault(); props.onFilter("all"); }}>all</button>670        <button class={`peach-identity-filter-pill ${props.filter === "username" ? "is-on" : ""}`} type="button" onMouseDown={(e) => { e.preventDefault(); props.onFilter("username"); }}>user</button>671        <button class={`peach-identity-filter-pill ${props.filter === "email" ? "is-on" : ""}`} type="button" onMouseDown={(e) => { e.preventDefault(); props.onFilter("email"); }}>email</button>672        <button673          class={`peach-identity-collapse-btn${collapsed() ? " is-collapsed" : ""}`}674          type="button"675          onMouseDown={(e) => { e.preventDefault(); setCollapsed(c => !c); }}676          aria-label={collapsed() ? "Show suggestions" : "Hide suggestions"}677        >678          <ChevronIcon />679        </button>680      </div>681      <div class={`peach-identity-rows${collapsed() ? " is-collapsed" : ""}`}>682        <div class="peach-identity-rows-inner">683          <For each={props.suggestions}>684            {(s) => (685              <button class="peach-identity-suggestion" type="button" onMouseDown={() => props.onPick(s.value)}>686                <span class="peach-identity-suggestion-value">{s.value}</span>687                <span class={`peach-identity-suggestion-type peach-identity-suggestion-type--${s.type}`}>{s.type === "email" ? "@" : "u"}</span>688                <span class="peach-identity-suggestion-meta">{s.count}×</span>689              </button>690            )}691          </For>692        </div>693      </div>694    </div>695  );696}