Add.tsx

22.6 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));3435  const isNoteEntry = () => props.entry?.type === "note";36  const isCardEntry = () => props.entry?.type === "card";37  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(38    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"39  );4041  // Card signals42  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");43  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");44  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");45  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");46  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");47  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");48  const [showCvc, setShowCvc] = createSignal(false);49  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);50  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);5152  function noteExec(cmd: string, val?: string) {53    document.execCommand(cmd, false, val);54    noteEditorEl()?.focus();55  }5657  function getCardNetwork(num: string): string {58    const n = num.replace(/\s/g, '');59    if (/^4/.test(n)) return 'Visa';60    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';61    if (/^3[47]/.test(n)) return 'Amex';62    if (/^6(?:011|5)/.test(n)) return 'Discover';63    return '';64  }6566  function formatCardNumberDisplay(raw: string, focused: boolean): string {67    const n = raw.replace(/\s/g, '');68    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);69    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(' '));70    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();71  }7273  const canSave = () => {74    if (!title().trim()) return false;75    if (selectedType() === "login" && !password().trim()) return false;76    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;77    return true;78  };7980  const allTags = createMemo(() => {81    const tagSet = new Set<string>();82    for (const entry of props.entries ?? []) {83      for (const tag of entry.tags ?? []) tagSet.add(tag);84    }85    return Array.from(tagSet).sort();86  });8788  const tagSuggestions = createMemo(() => {89    const input = tagInput().toLowerCase().trim();90    if (!input) return [];91    return allTags().filter(t =>92      t.toLowerCase().includes(input) && !tags().includes(t)93    ).slice(0, 5);94  });9596  function addTag(tag: string) {97    const n = tag.toLowerCase().trim();98    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;99    setTags([...tags(), n]);100  }101102  function removeTag(tag: string) {103    setTags(tags().filter(t => t !== tag));104  }105106  function handleTagKeyDown(e: KeyboardEvent) {107    if (e.key === "Enter" || e.key === ",") {108      e.preventDefault();109      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }110    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {111      removeTag(tags()[tags().length - 1]);112    }113  }114115  // Strength check116  createEffect(async () => {117    const pwd = password();118    if (pwd.length < 4) { setPasswordStrength(null); return; }119    try {120      if (!zxcvbnFn()) {121        const mod = await import("zxcvbn");122        setZxcvbnFn(() => mod.default);123      }124      const fn = zxcvbnFn();125      if (fn) setPasswordStrength(fn(pwd).score);126    } catch {127      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);128    }129  });130131  onMount(() => {132    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;133    if (el) el.focus();134  });135136  function getNormalizedTotp(): string | undefined {137    const value = totp().trim();138    if (!value) return undefined;139    if (value.startsWith("otpauth://")) {140      const parsed = parseOtpauthURI(value);141      return parsed?.secret ?? value;142    }143    return value;144  }145146  async function handleSave() {147    if (!canSave()) {148      setError(149        selectedType() === "note"150          ? "title is required"151          : selectedType() === "card"152          ? "cardholder name and card number are required"153          : "title and password are required"154      );155      return;156    }157158    setSaving(true);159    setError("");160161    const payload: Record<string, unknown> = {162      type: selectedType(),163      title: title().trim(),164      notes: notes().trim() || undefined,165      tags: tags().length > 0 ? tags() : undefined,166    };167168    if (selectedType() === "login") {169      payload.username = username().trim();170      payload.password = password();171      payload.url = url().trim();172      payload.totp = getNormalizedTotp();173    }174175    if (selectedType() === "card") {176      const raw = cardNumber().replace(/\s/g, '');177      payload.cardholderName = cardholderName().trim();178      payload.cardNumber = raw;179      payload.expMonth = expMonth().trim();180      payload.expYear = expYear().trim();181      payload.cvc = cvc().trim();182      payload.cardNickname = cardNickname().trim() || undefined;183      const n = raw;184      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';185      payload.last4 = raw.slice(-4);186    }187188    if (props.entry) {189      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });190      const result = res as { ok: boolean; error?: string };191      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }192      await props.onSave({ ...props.entry, ...payload } as VaultEntry);193      return;194    }195196    const res = await sendMessage({ type: "ADD_ENTRY", payload });197    const result = res as { ok: boolean; data?: VaultEntry; error?: string };198    if (!result.ok) {199      if (result.error === "vault locked") {200        setError("vault is locked — tap the lock icon to re-enter your master password");201      } else {202        setError(result.error ?? "could not save entry");203      }204      setSaving(false);205      return;206    }207    await props.onSave(result.data);208  }209210  const strengthLabel = () => {211    const s = passwordStrength();212    if (s === null) return null;213    return ["weak", "weak", "fair", "good", "strong"][s] as string;214  };215216  return (217    <div class="peach-overlay-view ov-enter-right" style="display:flex">218      <div class="peach-overlay-header">219        <button220          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"221          onClick={props.onBack}222        >223          cancel224        </button>225        <span class="peach-overlay-title">226          {props.entry ? "edit entry" : "new entry"}227        </span>228        <button229          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"230          onClick={() => void handleSave()}231          disabled={saving() || !canSave()}232        >233          {saving() ? "saving…" : props.entry ? "save" : "create"}234        </button>235      </div>236237      {/* ── Body — mirrors Entry.tsx field-card structure ── */}238      <div class="peach-entry-body peach-entry-body--new">239240        {/* Type toggle */}241        <Show when={!props.entry}>242          <div class="peach-new-type-row">243            <button244              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}245              onClick={() => setSelectedType("login")}246            >login</button>247            <button248              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}249              onClick={() => setSelectedType("note")}250            >note</button>251            <button252              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}253              onClick={() => setSelectedType("card")}254            >card</button>255          </div>256        </Show>257258        {/* Note formatting toolbar — own row so it never overflows */}259        <Show when={selectedType() === "note"}>260          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />261        </Show>262263        {/* Title */}264        <div class="peach-new-title-wrap">265          <input266            class="peach-new-title-input"267            placeholder={selectedType() === "note" ? "note title" : "entry name"}268            autocomplete="off"269            value={title()}270            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}271            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}272          />273        </div>274275        {/* ── Login fields ── */}276        <Show when={selectedType() === "login"}>277          <div class="peach-domain">278            <input279              class="peach-domain-value peach-domain-value--edit"280              placeholder="domain.com"281              autocomplete="off"282              value={url()}283              onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}284            />285          </div>286287          <section class="peach-fields">288            {/* Username */}289            <div class="peach-field-card">290              <span class="peach-label">username</span>291              <div class="peach-field-value-row">292                <input293                  class="peach-field-value peach-field-value--edit"294                  placeholder="username or email"295                  autocomplete="off"296                  value={username()}297                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}298                />299              </div>300            </div>301302            {/* Password */}303            <div class="peach-field-card">304              <span class="peach-label">password</span>305              <div class="peach-password-edit-row">306                <input307                  class="peach-field-value peach-field-value--edit"308                  type={showPassword() ? "text" : "password"}309                  placeholder="password"310                  autocomplete="new-password"311                  value={password()}312                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}313                />314                <button315                  class="peach-inline-action peach-password-toggle-btn"316                  type="button"317                  onClick={() => setShowPassword(!showPassword())}318                  aria-label={showPassword() ? "Hide password" : "Show password"}319                >320                  <Show321                    when={showPassword()}322                    fallback={323                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">324                        <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" />325                        <circle cx="7" cy="5" r="1.75" />326                      </svg>327                    }328                  >329                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">330                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />331                      <path d="M3.5 5.5L2.5 7.5" />332                      <path d="M7 6.5V8.5" />333                      <path d="M10.5 5.5L11.5 7.5" />334                    </svg>335                  </Show>336                </button>337              </div>338339              {/* Strength */}340              <Show when={strengthLabel() !== null}>341                <div class="peach-strength-hairline">342                  <div class="peach-strength-hairline-track">343                    <div344                      class="peach-strength-hairline-fill"345                      style={{346                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],347                        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()!],348                      }}349                    />350                  </div>351                  <span class="peach-strength-label">{strengthLabel()}</span>352                </div>353              </Show>354355              {/* Generator toggle */}356              <button357                class="peach-generate-toggle-link"358                type="button"359                onClick={() => { setShowGenerator(!showGenerator()); }}360              >361                {showGenerator() ? "☰ hide generator" : "⚙ generate"}362              </button>363              <GeneratorDrawer364                compact365                open={showGenerator()}366                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}367              />368            </div>369370            {/* 2FA — optional */}371            <Show when={showTotp() || totp()}>372              <div class="peach-field-card">373                <span class="peach-label">2fa secret</span>374                <div class="peach-field-value-row">375                  <input376                    class="peach-field-value peach-field-value--edit"377                    placeholder="BASE32 or otpauth:// URI"378                    autocomplete="off"379                    value={totp()}380                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}381                  />382                </div>383              </div>384            </Show>385          </section>386        </Show>387388        {/* ── Card fields ── */}389        <Show when={selectedType() === "card"}>390          <section class="peach-fields">391            {/* Nickname */}392            <div class="peach-field-card">393              <span class="peach-label">nickname</span>394              <div class="peach-field-value-row">395                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"396                  autocomplete="off" value={cardNickname()}397                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />398              </div>399            </div>400            {/* Cardholder name */}401            <div class="peach-field-card">402              <span class="peach-label">cardholder name</span>403              <div class="peach-field-value-row">404                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"405                  autocomplete="cc-name" value={cardholderName()}406                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />407              </div>408            </div>409            {/* Card number */}410            <div class="peach-field-card">411              <span class="peach-label">412                card number413                <Show when={getCardNetwork(cardNumber())}>414                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>415                </Show>416              </span>417              <div class="peach-field-value-row">418                <input class="peach-field-value peach-field-value--edit peach-card-number"419                  placeholder="•••• •••• •••• ••••"420                  autocomplete="cc-number" inputmode="numeric" maxLength={19}421                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}422                  onFocus={() => setCardNumberFocused(true)}423                  onBlur={() => setCardNumberFocused(false)}424                  onInput={(e) => {425                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');426                    setCardNumber(raw);427                  }} />428              </div>429            </div>430            {/* Expiry */}431            <div class="peach-field-card">432              <span class="peach-label">expiry</span>433              <div class="peach-card-expiry-row">434                <input class="peach-field-value peach-field-value--edit" placeholder="MM"435                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"436                  value={expMonth()}437                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />438                <span class="peach-card-expiry-sep">/</span>439                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"440                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"441                  value={expYear()}442                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />443              </div>444            </div>445            {/* CVC */}446            <div class="peach-field-card">447              <span class="peach-label">CVC</span>448              <div class="peach-field-value-row">449                <input class="peach-field-value peach-field-value--edit"450                  type={showCvc() ? "text" : "password"} placeholder="•••"451                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}452                  value={cvc()}453                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />454                <button class="peach-inline-action peach-password-toggle-btn" type="button"455                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>456                  <Show when={showCvc()} fallback={457                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">458                      <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" />459                      <circle cx="7" cy="5" r="1.75" />460                    </svg>461                  }>462                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">463                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />464                    </svg>465                  </Show>466                </button>467              </div>468            </div>469          </section>470        </Show>471472        {/* ── Note content ── */}473        <Show when={selectedType() === "note"}>474          <div class="peach-note-body">475            <NoteEditorInline476              initialContent={notes()}477              onContentChange={setNotes}478              onReady={(el) => setNoteEditorEl(el)}479            />480          </div>481        </Show>482483        {/* ── Login notes ── */}484        <Show when={selectedType() === "login" && (showNotes() || notes())}>485          <div class="peach-field-card" style="margin:0 12px">486            <span class="peach-label">notes</span>487            <textarea488              class="peach-new-textarea"489              placeholder="anything worth remembering"490              value={notes()}491              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}492            />493          </div>494        </Show>495496        {/* ── "+ add" optional field links ── */}497        <div class="peach-entry-add-links">498          <Show when={selectedType() === "login" && !showTotp() && !totp()}>499            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>500              + add 2FA501            </button>502          </Show>503          <Show when={selectedType() === "login" && !showNotes() && !notes()}>504            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>505              + add notes506            </button>507          </Show>508        </div>509510        {/* ── Tags ── */}511        <div class="peach-field-card" style="margin:4px 12px 12px">512          <span class="peach-label">tags</span>513          <div class="peach-tag-input-wrap">514            <For each={tags()}>515              {(tag) => (516                <span class="peach-tag-chip">517                  {tag}518                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>519                </span>520              )}521            </For>522            <input523              class="peach-tag-input"524              placeholder={tags().length === 0 ? "add tags…" : ""}525              value={tagInput()}526              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}527              onKeyDown={handleTagKeyDown}528              onFocus={() => setShowTagSuggestions(true)}529              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}530              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"531            />532          </div>533          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>534            <div class="peach-tag-suggestions" style="margin-top:4px;">535              <For each={tagSuggestions()}>536                {(s) => (537                  <button538                    class="peach-tag-suggestion"539                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}540                  >{s}</button>541                )}542              </For>543            </div>544          </Show>545        </div>546547        <Show when={error()}>548          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>549        </Show>550551      </div>552    </div>553  );554}