Add.tsx

15.5 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 [selectedType, setSelectedType] = createSignal<"login" | "note">(isNoteEntry() ? "note" : "login");37  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);3839  function noteExec(cmd: string, val?: string) {40    document.execCommand(cmd, false, val);41    noteEditorEl()?.focus();42  }4344  const canSave = () => {45    if (!title().trim()) return false;46    if (selectedType() === "login" && !password().trim()) return false;47    return true;48  };4950  const allTags = createMemo(() => {51    const tagSet = new Set<string>();52    for (const entry of props.entries ?? []) {53      for (const tag of entry.tags ?? []) tagSet.add(tag);54    }55    return Array.from(tagSet).sort();56  });5758  const tagSuggestions = createMemo(() => {59    const input = tagInput().toLowerCase().trim();60    if (!input) return [];61    return allTags().filter(t =>62      t.toLowerCase().includes(input) && !tags().includes(t)63    ).slice(0, 5);64  });6566  function addTag(tag: string) {67    const n = tag.toLowerCase().trim();68    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;69    setTags([...tags(), n]);70  }7172  function removeTag(tag: string) {73    setTags(tags().filter(t => t !== tag));74  }7576  function handleTagKeyDown(e: KeyboardEvent) {77    if (e.key === "Enter" || e.key === ",") {78      e.preventDefault();79      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }80    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {81      removeTag(tags()[tags().length - 1]);82    }83  }8485  // Strength check86  createEffect(async () => {87    const pwd = password();88    if (pwd.length < 4) { setPasswordStrength(null); return; }89    try {90      if (!zxcvbnFn()) {91        const mod = await import("zxcvbn");92        setZxcvbnFn(() => mod.default);93      }94      const fn = zxcvbnFn();95      if (fn) setPasswordStrength(fn(pwd).score);96    } catch {97      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);98    }99  });100101  onMount(() => {102    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;103    if (el) el.focus();104  });105106  function getNormalizedTotp(): string | undefined {107    const value = totp().trim();108    if (!value) return undefined;109    if (value.startsWith("otpauth://")) {110      const parsed = parseOtpauthURI(value);111      return parsed?.secret ?? value;112    }113    return value;114  }115116  async function handleSave() {117    if (!canSave()) {118      setError(selectedType() === "note" ? "title is required" : "title and password are required");119      return;120    }121122    setSaving(true);123    setError("");124125    const payload: Record<string, unknown> = {126      type: selectedType(),127      title: title().trim(),128      notes: notes().trim() || undefined,129      tags: tags().length > 0 ? tags() : undefined,130    };131132    if (selectedType() === "login") {133      payload.username = username().trim();134      payload.password = password();135      payload.url = url().trim();136      payload.totp = getNormalizedTotp();137    }138139    if (props.entry) {140      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });141      const result = res as { ok: boolean; error?: string };142      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }143      await props.onSave({ ...props.entry, ...payload } as VaultEntry);144      return;145    }146147    const res = await sendMessage({ type: "ADD_ENTRY", payload });148    const result = res as { ok: boolean; data?: VaultEntry; error?: string };149    if (!result.ok) { setError(result.error ?? "could not save entry"); setSaving(false); return; }150    await props.onSave(result.data);151  }152153  const strengthLabel = () => {154    const s = passwordStrength();155    if (s === null) return null;156    return ["weak", "weak", "fair", "good", "strong"][s] as string;157  };158159  return (160    <div class="peach-overlay-view ov-enter-right" style="display:flex">161      <div class="peach-overlay-header">162        <button163          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"164          onClick={props.onBack}165        >166          cancel167        </button>168        <span class="peach-overlay-title">169          {props.entry ? "edit entry" : "new entry"}170        </span>171        <button172          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"173          onClick={() => void handleSave()}174          disabled={saving() || !canSave()}175        >176          {saving() ? "saving…" : props.entry ? "save" : "create"}177        </button>178      </div>179180      {/* ── Body — mirrors Entry.tsx field-card structure ── */}181      <div class="peach-entry-body peach-entry-body--new">182183        {/* Type toggle */}184        <Show when={!props.entry}>185          <div class="peach-new-type-row">186            <button187              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}188              onClick={() => setSelectedType("login")}189            >login</button>190            <button191              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}192              onClick={() => setSelectedType("note")}193            >note</button>194          </div>195        </Show>196197        {/* Note formatting toolbar — own row so it never overflows */}198        <Show when={selectedType() === "note"}>199          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />200        </Show>201202        {/* Title */}203        <div class="peach-new-title-wrap">204          <input205            class="peach-new-title-input"206            placeholder={selectedType() === "note" ? "note title" : "entry name"}207            autocomplete="off"208            value={title()}209            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}210            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}211          />212        </div>213214        {/* ── Login fields ── */}215        <Show when={selectedType() === "login"}>216          <div class="peach-domain">217            <input218              class="peach-domain-value peach-domain-value--edit"219              placeholder="domain.com"220              autocomplete="off"221              value={url()}222              onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}223            />224          </div>225226          <section class="peach-fields">227            {/* Username */}228            <div class="peach-field-card">229              <span class="peach-label">username</span>230              <div class="peach-field-value-row">231                <input232                  class="peach-field-value peach-field-value--edit"233                  placeholder="username or email"234                  autocomplete="off"235                  value={username()}236                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}237                />238              </div>239            </div>240241            {/* Password */}242            <div class="peach-field-card">243              <span class="peach-label">password</span>244              <div class="peach-password-edit-row">245                <input246                  class="peach-field-value peach-field-value--edit"247                  type={showPassword() ? "text" : "password"}248                  placeholder="password"249                  autocomplete="new-password"250                  value={password()}251                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}252                />253                <button254                  class="peach-inline-action peach-password-toggle-btn"255                  type="button"256                  onClick={() => setShowPassword(!showPassword())}257                  aria-label={showPassword() ? "Hide password" : "Show password"}258                >259                  <Show260                    when={showPassword()}261                    fallback={262                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">263                        <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" />264                        <circle cx="7" cy="5" r="1.75" />265                      </svg>266                    }267                  >268                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">269                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />270                      <path d="M3.5 5.5L2.5 7.5" />271                      <path d="M7 6.5V8.5" />272                      <path d="M10.5 5.5L11.5 7.5" />273                    </svg>274                  </Show>275                </button>276              </div>277278              {/* Strength */}279              <Show when={strengthLabel() !== null}>280                <div class="peach-strength-hairline">281                  <div class="peach-strength-hairline-track">282                    <div283                      class="peach-strength-hairline-fill"284                      style={{285                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],286                        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()!],287                      }}288                    />289                  </div>290                  <span class="peach-strength-label">{strengthLabel()}</span>291                </div>292              </Show>293294              {/* Generator toggle */}295              <button296                class="peach-generate-toggle-link"297                type="button"298                onClick={() => { setShowGenerator(!showGenerator()); }}299              >300                {showGenerator() ? "☰ hide generator" : "⚙ generate"}301              </button>302              <GeneratorDrawer303                compact304                open={showGenerator()}305                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}306              />307            </div>308309            {/* 2FA — optional */}310            <Show when={showTotp() || totp()}>311              <div class="peach-field-card">312                <span class="peach-label">2fa secret</span>313                <div class="peach-field-value-row">314                  <input315                    class="peach-field-value peach-field-value--edit"316                    placeholder="BASE32 or otpauth:// URI"317                    autocomplete="off"318                    value={totp()}319                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}320                  />321                </div>322              </div>323            </Show>324          </section>325        </Show>326327        {/* ── Note content ── */}328        <Show when={selectedType() === "note"}>329          <div class="peach-note-body">330            <NoteEditorInline331              initialContent={notes()}332              onContentChange={setNotes}333              onReady={(el) => setNoteEditorEl(el)}334            />335          </div>336        </Show>337338        {/* ── Login notes ── */}339        <Show when={selectedType() === "login" && (showNotes() || notes())}>340          <div class="peach-field-card" style="margin:0 12px">341            <span class="peach-label">notes</span>342            <textarea343              class="peach-new-textarea"344              placeholder="anything worth remembering"345              value={notes()}346              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}347            />348          </div>349        </Show>350351        {/* ── "+ add" optional field links ── */}352        <div class="peach-entry-add-links">353          <Show when={selectedType() === "login" && !showTotp() && !totp()}>354            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>355              + add 2FA356            </button>357          </Show>358          <Show when={selectedType() === "login" && !showNotes() && !notes()}>359            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>360              + add notes361            </button>362          </Show>363        </div>364365        {/* ── Tags ── */}366        <div class="peach-field-card" style="margin:4px 12px 12px">367          <span class="peach-label">tags</span>368          <div class="peach-tag-input-wrap">369            <For each={tags()}>370              {(tag) => (371                <span class="peach-tag-chip">372                  {tag}373                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>374                </span>375              )}376            </For>377            <input378              class="peach-tag-input"379              placeholder={tags().length === 0 ? "add tags…" : ""}380              value={tagInput()}381              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}382              onKeyDown={handleTagKeyDown}383              onFocus={() => setShowTagSuggestions(true)}384              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}385              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"386            />387          </div>388          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>389            <div class="peach-tag-suggestions" style="margin-top:4px;">390              <For each={tagSuggestions()}>391                {(s) => (392                  <button393                    class="peach-tag-suggestion"394                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}395                  >{s}</button>396                )}397              </For>398            </div>399          </Show>400        </div>401402        <Show when={error()}>403          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>404        </Show>405406      </div>407    </div>408  );409}