Add.tsx

24.3 KB · raw

1import { createSignal, createMemo, createEffect, For, Show, onMount } 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 [showIdentitySuggestions, setShowIdentitySuggestions] = createSignal(false);3536  const identitySuggestions = createMemo(() => {37    const input = username().toLowerCase();38    const counts = new Map<string, number>();39    for (const entry of props.entries ?? []) {40      const uname = entry.username?.trim() ?? "";41      const email = (entry as VaultEntry & { email?: string }).email?.trim() ?? "";42      if (uname) counts.set(uname, (counts.get(uname) ?? 0) + 1);43      if (email && email !== uname) counts.set(email, (counts.get(email) ?? 0) + 1);44    }45    return Array.from(counts.entries())46      .filter(([val]) => !input || val.toLowerCase().includes(input))47      .sort((a, b) => b[1] - a[1])48      .slice(0, 6)49      .map(([value, count]) => ({ value, count }));50  });5152  const isNoteEntry = () => props.entry?.type === "note";53  const isCardEntry = () => props.entry?.type === "card";54  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(55    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"56  );5758  // Card signals59  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");60  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");61  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");62  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");63  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");64  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");65  const [showCvc, setShowCvc] = createSignal(false);66  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);67  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);6869  function noteExec(cmd: string, val?: string) {70    document.execCommand(cmd, false, val);71    noteEditorEl()?.focus();72  }7374  function getCardNetwork(num: string): string {75    const n = num.replace(/\s/g, '');76    if (/^4/.test(n)) return 'Visa';77    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';78    if (/^3[47]/.test(n)) return 'Amex';79    if (/^6(?:011|5)/.test(n)) return 'Discover';80    return '';81  }8283  function formatCardNumberDisplay(raw: string, focused: boolean): string {84    const n = raw.replace(/\s/g, '');85    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);86    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(' '));87    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();88  }8990  const canSave = () => {91    if (!title().trim()) return false;92    if (selectedType() === "login" && !password().trim()) return false;93    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;94    return true;95  };9697  const allTags = createMemo(() => {98    const tagSet = new Set<string>();99    for (const entry of props.entries ?? []) {100      for (const tag of entry.tags ?? []) tagSet.add(tag);101    }102    return Array.from(tagSet).sort();103  });104105  const tagSuggestions = createMemo(() => {106    const input = tagInput().toLowerCase().trim();107    if (!input) return [];108    return allTags().filter(t =>109      t.toLowerCase().includes(input) && !tags().includes(t)110    ).slice(0, 5);111  });112113  function addTag(tag: string) {114    const n = tag.toLowerCase().trim();115    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;116    setTags([...tags(), n]);117  }118119  function removeTag(tag: string) {120    setTags(tags().filter(t => t !== tag));121  }122123  function handleTagKeyDown(e: KeyboardEvent) {124    if (e.key === "Enter" || e.key === ",") {125      e.preventDefault();126      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }127    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {128      removeTag(tags()[tags().length - 1]);129    }130  }131132  // Strength check133  createEffect(async () => {134    const pwd = password();135    if (pwd.length < 4) { setPasswordStrength(null); return; }136    try {137      if (!zxcvbnFn()) {138        const mod = await import("zxcvbn");139        setZxcvbnFn(() => mod.default);140      }141      const fn = zxcvbnFn();142      if (fn) setPasswordStrength(fn(pwd).score);143    } catch {144      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);145    }146  });147148  onMount(() => {149    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;150    if (el) el.focus();151  });152153  function getNormalizedTotp(): string | undefined {154    const value = totp().trim();155    if (!value) return undefined;156    if (value.startsWith("otpauth://")) {157      const parsed = parseOtpauthURI(value);158      return parsed?.secret ?? value;159    }160    return value;161  }162163  async function handleSave() {164    if (!canSave()) {165      setError(166        selectedType() === "note"167          ? "title is required"168          : selectedType() === "card"169          ? "cardholder name and card number are required"170          : "title and password are required"171      );172      return;173    }174175    setSaving(true);176    setError("");177178    const payload: Record<string, unknown> = {179      type: selectedType(),180      title: title().trim(),181      notes: notes().trim() || undefined,182      tags: tags().length > 0 ? tags() : undefined,183    };184185    if (selectedType() === "login") {186      payload.username = username().trim();187      payload.password = password();188      payload.url = url().trim();189      payload.totp = getNormalizedTotp();190    }191192    if (selectedType() === "card") {193      const raw = cardNumber().replace(/\s/g, '');194      payload.cardholderName = cardholderName().trim();195      payload.cardNumber = raw;196      payload.expMonth = expMonth().trim();197      payload.expYear = expYear().trim();198      payload.cvc = cvc().trim();199      payload.cardNickname = cardNickname().trim() || undefined;200      const n = raw;201      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';202      payload.last4 = raw.slice(-4);203    }204205    if (props.entry) {206      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });207      const result = res as { ok: boolean; error?: string };208      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }209      await props.onSave({ ...props.entry, ...payload } as VaultEntry);210      return;211    }212213    const res = await sendMessage({ type: "ADD_ENTRY", payload });214    const result = res as { ok: boolean; data?: VaultEntry; error?: string };215    if (!result.ok) {216      if (result.error === "vault locked") {217        setError("vault is locked — tap the lock icon to re-enter your master password");218      } else {219        setError(result.error ?? "could not save entry");220      }221      setSaving(false);222      return;223    }224    await props.onSave(result.data);225  }226227  const strengthLabel = () => {228    const s = passwordStrength();229    if (s === null) return null;230    return ["weak", "weak", "fair", "good", "strong"][s] as string;231  };232233  return (234    <div class="peach-overlay-view ov-enter-right" style="display:flex">235      <div class="peach-overlay-header">236        <button237          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"238          onClick={props.onBack}239        >240          cancel241        </button>242        <span class="peach-overlay-title">243          {props.entry ? "edit entry" : "new entry"}244        </span>245        <button246          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"247          onClick={() => void handleSave()}248          disabled={saving() || !canSave()}249        >250          {saving() ? "saving…" : props.entry ? "save" : "create"}251        </button>252      </div>253254      {/* ── Body — mirrors Entry.tsx field-card structure ── */}255      <div class="peach-entry-body peach-entry-body--new">256257        {/* Type toggle */}258        <Show when={!props.entry}>259          <div class="peach-new-type-row">260            <button261              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}262              onClick={() => setSelectedType("login")}263            >login</button>264            <button265              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}266              onClick={() => setSelectedType("note")}267            >note</button>268            <button269              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}270              onClick={() => setSelectedType("card")}271            >card</button>272          </div>273        </Show>274275        {/* Note formatting toolbar — own row so it never overflows */}276        <Show when={selectedType() === "note"}>277          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />278        </Show>279280        {/* Title */}281        <div class="peach-new-title-wrap">282          <input283            class="peach-new-title-input"284            placeholder={selectedType() === "note" ? "note title" : "entry name"}285            autocomplete="off"286            value={title()}287            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}288            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}289          />290        </div>291292        {/* ── Login fields ── */}293        <Show when={selectedType() === "login"}>294          <div class="peach-domain">295            <input296              class="peach-domain-value peach-domain-value--edit"297              placeholder="domain.com"298              autocomplete="off"299              value={url()}300              onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}301            />302          </div>303304          <section class="peach-fields">305            {/* Username */}306            <div class="peach-field-card">307              <span class="peach-label">username</span>308              <div class="peach-field-value-row">309                <input310                  class="peach-field-value peach-field-value--edit"311                  placeholder="username or email"312                  autocomplete="off"313                  value={username()}314                  onInput={(e) => { setUsername((e.currentTarget as HTMLInputElement).value); setShowIdentitySuggestions(true); }}315                  onFocus={() => setShowIdentitySuggestions(true)}316                  onBlur={() => setTimeout(() => setShowIdentitySuggestions(false), 150)}317                />318              </div>319              <Show when={showIdentitySuggestions() && identitySuggestions().length > 0}>320                <div class="peach-identity-suggestions">321                  <For each={identitySuggestions()}>322                    {(s) => (323                      <button324                        class="peach-identity-suggestion"325                        type="button"326                        onMouseDown={() => { setUsername(s.value); setShowIdentitySuggestions(false); }}327                      >328                        <span class="peach-identity-suggestion-value">{s.value}</span>329                        <span class="peach-identity-suggestion-meta">{s.count}×</span>330                      </button>331                    )}332                  </For>333                </div>334              </Show>335            </div>336337            {/* Password */}338            <div class="peach-field-card">339              <span class="peach-label">password</span>340              <div class="peach-password-edit-row">341                <input342                  class="peach-field-value peach-field-value--edit"343                  type={showPassword() ? "text" : "password"}344                  placeholder="password"345                  autocomplete="new-password"346                  value={password()}347                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}348                />349                <button350                  class="peach-inline-action peach-password-toggle-btn"351                  type="button"352                  onClick={() => setShowPassword(!showPassword())}353                  aria-label={showPassword() ? "Hide password" : "Show password"}354                >355                  <Show356                    when={showPassword()}357                    fallback={358                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">359                        <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" />360                        <circle cx="7" cy="5" r="1.75" />361                      </svg>362                    }363                  >364                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">365                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />366                      <path d="M3.5 5.5L2.5 7.5" />367                      <path d="M7 6.5V8.5" />368                      <path d="M10.5 5.5L11.5 7.5" />369                    </svg>370                  </Show>371                </button>372              </div>373374              {/* Strength */}375              <Show when={strengthLabel() !== null}>376                <div class="peach-strength-hairline">377                  <div class="peach-strength-hairline-track">378                    <div379                      class="peach-strength-hairline-fill"380                      style={{381                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],382                        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()!],383                      }}384                    />385                  </div>386                  <span class="peach-strength-label">{strengthLabel()}</span>387                </div>388              </Show>389390              {/* Generator toggle */}391              <button392                class="peach-generate-toggle-link"393                type="button"394                onClick={() => { setShowGenerator(!showGenerator()); }}395              >396                {showGenerator() ? "☰ hide generator" : "⚙ generate"}397              </button>398              <GeneratorDrawer399                compact400                open={showGenerator()}401                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}402              />403            </div>404405            {/* 2FA — optional */}406            <Show when={showTotp() || totp()}>407              <div class="peach-field-card">408                <span class="peach-label">2fa secret</span>409                <div class="peach-field-value-row">410                  <input411                    class="peach-field-value peach-field-value--edit"412                    placeholder="BASE32 or otpauth:// URI"413                    autocomplete="off"414                    value={totp()}415                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}416                  />417                </div>418              </div>419            </Show>420          </section>421        </Show>422423        {/* ── Card fields ── */}424        <Show when={selectedType() === "card"}>425          <section class="peach-fields">426            {/* Nickname */}427            <div class="peach-field-card">428              <span class="peach-label">nickname</span>429              <div class="peach-field-value-row">430                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"431                  autocomplete="off" value={cardNickname()}432                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />433              </div>434            </div>435            {/* Cardholder name */}436            <div class="peach-field-card">437              <span class="peach-label">cardholder name</span>438              <div class="peach-field-value-row">439                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"440                  autocomplete="cc-name" value={cardholderName()}441                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />442              </div>443            </div>444            {/* Card number */}445            <div class="peach-field-card">446              <span class="peach-label">447                card number448                <Show when={getCardNetwork(cardNumber())}>449                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>450                </Show>451              </span>452              <div class="peach-field-value-row">453                <input class="peach-field-value peach-field-value--edit peach-card-number"454                  placeholder="•••• •••• •••• ••••"455                  autocomplete="cc-number" inputmode="numeric" maxLength={19}456                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}457                  onFocus={() => setCardNumberFocused(true)}458                  onBlur={() => setCardNumberFocused(false)}459                  onInput={(e) => {460                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');461                    setCardNumber(raw);462                  }} />463              </div>464            </div>465            {/* Expiry */}466            <div class="peach-field-card">467              <span class="peach-label">expiry</span>468              <div class="peach-card-expiry-row">469                <input class="peach-field-value peach-field-value--edit" placeholder="MM"470                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"471                  value={expMonth()}472                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />473                <span class="peach-card-expiry-sep">/</span>474                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"475                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"476                  value={expYear()}477                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />478              </div>479            </div>480            {/* CVC */}481            <div class="peach-field-card">482              <span class="peach-label">CVC</span>483              <div class="peach-field-value-row">484                <input class="peach-field-value peach-field-value--edit"485                  type={showCvc() ? "text" : "password"} placeholder="•••"486                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}487                  value={cvc()}488                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />489                <button class="peach-inline-action peach-password-toggle-btn" type="button"490                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>491                  <Show when={showCvc()} fallback={492                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">493                      <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" />494                      <circle cx="7" cy="5" r="1.75" />495                    </svg>496                  }>497                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">498                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />499                    </svg>500                  </Show>501                </button>502              </div>503            </div>504          </section>505        </Show>506507        {/* ── Note content ── */}508        <Show when={selectedType() === "note"}>509          <div class="peach-note-body">510            <NoteEditorInline511              initialContent={notes()}512              onContentChange={setNotes}513              onReady={(el) => setNoteEditorEl(el)}514            />515          </div>516        </Show>517518        {/* ── Login notes ── */}519        <Show when={selectedType() === "login" && (showNotes() || notes())}>520          <div class="peach-field-card" style="margin:0 12px">521            <span class="peach-label">notes</span>522            <textarea523              class="peach-new-textarea"524              placeholder="anything worth remembering"525              value={notes()}526              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}527            />528          </div>529        </Show>530531        {/* ── "+ add" optional field links ── */}532        <div class="peach-entry-add-links">533          <Show when={selectedType() === "login" && !showTotp() && !totp()}>534            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>535              + add 2FA536            </button>537          </Show>538          <Show when={selectedType() === "login" && !showNotes() && !notes()}>539            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>540              + add notes541            </button>542          </Show>543        </div>544545        {/* ── Tags ── */}546        <div class="peach-field-card" style="margin:4px 12px 12px">547          <span class="peach-label">tags</span>548          <div class="peach-tag-input-wrap">549            <For each={tags()}>550              {(tag) => (551                <span class="peach-tag-chip">552                  {tag}553                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>554                </span>555              )}556            </For>557            <input558              class="peach-tag-input"559              placeholder={tags().length === 0 ? "add tags…" : ""}560              value={tagInput()}561              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}562              onKeyDown={handleTagKeyDown}563              onFocus={() => setShowTagSuggestions(true)}564              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}565              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"566            />567          </div>568          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>569            <div class="peach-tag-suggestions" style="margin-top:4px;">570              <For each={tagSuggestions()}>571                {(s) => (572                  <button573                    class="peach-tag-suggestion"574                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}575                  >{s}</button>576                )}577              </For>578            </div>579          </Show>580        </div>581582        <Show when={error()}>583          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>584        </Show>585586      </div>587    </div>588  );589}