Add.tsx

15.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 { GeneratorDrawer } from "../components/GeneratorDrawer";67interface AddProps {8  entry?: VaultEntry;9  entries?: VaultEntry[];10  autoOpenGenerator?: boolean;11  onBack: () => void;12  onSave: (entry?: VaultEntry) => void | Promise<void>;13}1415export function AddView(props: AddProps) {16  const [title, setTitle] = createSignal(props.entry?.title ?? "");17  const [username, setUsername] = createSignal(props.entry?.username ?? "");18  const [password, setPassword] = createSignal(props.entry?.password ?? "");19  const [url, setUrl] = createSignal(props.entry?.url ?? "");20  const [totp, setTotp] = createSignal(props.entry?.totp ?? "");21  const [notes, setNotes] = createSignal(props.entry?.notes ?? "");22  const [saving, setSaving] = createSignal(false);23  const [error, setError] = createSignal("");24  const [passwordStrength, setPasswordStrength] = createSignal<number | null>(null);25  const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null);26  const [tags, setTags] = createSignal<string[]>(props.entry?.tags ?? []);27  const [tagInput, setTagInput] = createSignal("");28  const [showTagSuggestions, setShowTagSuggestions] = createSignal(false);29  const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false);30  const [showPassword, setShowPassword] = createSignal(false);31  const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp));32  const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));3334  const isNoteEntry = () => props.entry?.type === "note";35  const [selectedType, setSelectedType] = createSignal<"login" | "note">(isNoteEntry() ? "note" : "login");3637  const canSave = () => {38    if (!title().trim()) return false;39    if (selectedType() === "login" && !password().trim()) return false;40    return true;41  };4243  const allTags = createMemo(() => {44    const tagSet = new Set<string>();45    for (const entry of props.entries ?? []) {46      for (const tag of entry.tags ?? []) tagSet.add(tag);47    }48    return Array.from(tagSet).sort();49  });5051  const tagSuggestions = createMemo(() => {52    const input = tagInput().toLowerCase().trim();53    if (!input) return [];54    return allTags().filter(t =>55      t.toLowerCase().includes(input) && !tags().includes(t)56    ).slice(0, 5);57  });5859  function addTag(tag: string) {60    const n = tag.toLowerCase().trim();61    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;62    setTags([...tags(), n]);63  }6465  function removeTag(tag: string) {66    setTags(tags().filter(t => t !== tag));67  }6869  function handleTagKeyDown(e: KeyboardEvent) {70    if (e.key === "Enter" || e.key === ",") {71      e.preventDefault();72      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }73    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {74      removeTag(tags()[tags().length - 1]);75    }76  }7778  // Strength check79  createEffect(async () => {80    const pwd = password();81    if (pwd.length < 4) { setPasswordStrength(null); return; }82    try {83      if (!zxcvbnFn()) {84        const mod = await import("zxcvbn");85        setZxcvbnFn(() => mod.default);86      }87      const fn = zxcvbnFn();88      if (fn) setPasswordStrength(fn(pwd).score);89    } catch {90      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);91    }92  });9394  onMount(() => {95    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;96    if (el) el.focus();97  });9899  function getNormalizedTotp(): string | undefined {100    const value = totp().trim();101    if (!value) return undefined;102    if (value.startsWith("otpauth://")) {103      const parsed = parseOtpauthURI(value);104      return parsed?.secret ?? value;105    }106    return value;107  }108109  async function handleSave() {110    if (!canSave()) {111      setError(selectedType() === "note" ? "title is required" : "title and password are required");112      return;113    }114115    setSaving(true);116    setError("");117118    const payload: Record<string, unknown> = {119      type: selectedType(),120      title: title().trim(),121      notes: notes().trim() || undefined,122      tags: tags().length > 0 ? tags() : undefined,123    };124125    if (selectedType() === "login") {126      payload.username = username().trim();127      payload.password = password();128      payload.url = url().trim();129      payload.totp = getNormalizedTotp();130    }131132    if (props.entry) {133      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });134      const result = res as { ok: boolean; error?: string };135      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }136      await props.onSave({ ...props.entry, ...payload } as VaultEntry);137      return;138    }139140    const res = await sendMessage({ type: "ADD_ENTRY", payload });141    const result = res as { ok: boolean; data?: VaultEntry; error?: string };142    if (!result.ok) { setError(result.error ?? "could not save entry"); setSaving(false); return; }143    await props.onSave(result.data);144  }145146  const strengthLabel = () => {147    const s = passwordStrength();148    if (s === null) return null;149    return ["weak", "weak", "fair", "good", "strong"][s] as string;150  };151152  return (153    <div class="peach-overlay-view ov-enter-right" style="display:flex">154      <div class="peach-overlay-header">155        <button156          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"157          onClick={props.onBack}158        >159          cancel160        </button>161        <span class="peach-overlay-title">162          {props.entry ? "edit entry" : "new entry"}163        </span>164        <button165          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"166          onClick={() => void handleSave()}167          disabled={saving() || !canSave()}168        >169          {saving() ? "saving…" : props.entry ? "save" : "create"}170        </button>171      </div>172173      {/* ── Body — mirrors Entry.tsx field-card structure ── */}174      <div class="peach-entry-body peach-entry-body--new">175176        {/* Type toggle — only for new entries without a fixed type */}177        <Show when={!props.entry}>178          <div class="peach-new-type-row">179            <button180              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}181              onClick={() => setSelectedType("login")}182            >login</button>183            <button184              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}185              onClick={() => setSelectedType("note")}186            >note</button>187          </div>188        </Show>189190        {/* Title */}191        <div class="peach-new-title-wrap">192          <input193            class="peach-new-title-input"194            placeholder={selectedType() === "note" ? "note title" : "entry name"}195            autocomplete="off"196            value={title()}197            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}198            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}199          />200        </div>201202        {/* ── Login fields ── */}203        <Show when={selectedType() === "login"}>204          <div class="peach-domain">205            <input206              class="peach-domain-value peach-domain-value--edit"207              placeholder="domain.com"208              autocomplete="off"209              value={url()}210              onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}211            />212          </div>213214          <section class="peach-fields">215            {/* Username */}216            <div class="peach-field-card">217              <span class="peach-label">username</span>218              <div class="peach-field-value-row">219                <input220                  class="peach-field-value peach-field-value--edit"221                  placeholder="username or email"222                  autocomplete="off"223                  value={username()}224                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}225                />226              </div>227            </div>228229            {/* Password */}230            <div class="peach-field-card">231              <span class="peach-label">password</span>232              <div class="peach-password-edit-row">233                <input234                  class="peach-field-value peach-field-value--edit"235                  type={showPassword() ? "text" : "password"}236                  placeholder="password"237                  autocomplete="new-password"238                  value={password()}239                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}240                />241                <button242                  class="peach-inline-action peach-password-toggle-btn"243                  type="button"244                  onClick={() => setShowPassword(!showPassword())}245                  aria-label={showPassword() ? "Hide password" : "Show password"}246                >247                  <Show248                    when={showPassword()}249                    fallback={250                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">251                        <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" />252                        <circle cx="7" cy="5" r="1.75" />253                      </svg>254                    }255                  >256                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">257                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />258                      <path d="M3.5 5.5L2.5 7.5" />259                      <path d="M7 6.5V8.5" />260                      <path d="M10.5 5.5L11.5 7.5" />261                    </svg>262                  </Show>263                </button>264              </div>265266              {/* Strength */}267              <Show when={strengthLabel() !== null}>268                <div class="peach-strength-hairline">269                  <div class="peach-strength-hairline-track">270                    <div271                      class="peach-strength-hairline-fill"272                      style={{273                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],274                        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()!],275                      }}276                    />277                  </div>278                  <span class="peach-strength-label">{strengthLabel()}</span>279                </div>280              </Show>281282              {/* Generator toggle */}283              <button284                class="peach-generate-toggle-link"285                type="button"286                onClick={() => { setShowGenerator(!showGenerator()); }}287              >288                {showGenerator() ? "☰ hide generator" : "⚙ generate"}289              </button>290              <GeneratorDrawer291                compact292                open={showGenerator()}293                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}294              />295            </div>296297            {/* 2FA — optional */}298            <Show when={showTotp() || totp()}>299              <div class="peach-field-card">300                <span class="peach-label">2fa secret</span>301                <div class="peach-field-value-row">302                  <input303                    class="peach-field-value peach-field-value--edit"304                    placeholder="BASE32 or otpauth:// URI"305                    autocomplete="off"306                    value={totp()}307                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}308                  />309                </div>310              </div>311            </Show>312          </section>313        </Show>314315        {/* ── Note content ── */}316        <Show when={selectedType() === "note"}>317          <div class="peach-field-card" style="margin:4px 12px 0">318            <span class="peach-label">content</span>319            <textarea320              class="peach-new-textarea"321              placeholder="write your note here…"322              value={notes()}323              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}324            />325          </div>326        </Show>327328        {/* ── Login notes ── */}329        <Show when={selectedType() === "login" && (showNotes() || notes())}>330          <div class="peach-field-card" style="margin:0 12px">331            <span class="peach-label">notes</span>332            <textarea333              class="peach-new-textarea"334              placeholder="anything worth remembering"335              value={notes()}336              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}337            />338          </div>339        </Show>340341        {/* ── "+ add" optional field links ── */}342        <div class="peach-entry-add-links">343          <Show when={selectedType() === "login" && !showTotp() && !totp()}>344            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>345              + add 2FA346            </button>347          </Show>348          <Show when={selectedType() === "login" && !showNotes() && !notes()}>349            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>350              + add notes351            </button>352          </Show>353        </div>354355        {/* ── Tags ── */}356        <div class="peach-field-card" style="margin:4px 12px 12px">357          <span class="peach-label">tags</span>358          <div class="peach-tag-input-wrap">359            <For each={tags()}>360              {(tag) => (361                <span class="peach-tag-chip">362                  {tag}363                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>364                </span>365              )}366            </For>367            <input368              class="peach-tag-input"369              placeholder={tags().length === 0 ? "add tags…" : ""}370              value={tagInput()}371              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}372              onKeyDown={handleTagKeyDown}373              onFocus={() => setShowTagSuggestions(true)}374              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}375              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"376            />377          </div>378          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>379            <div class="peach-tag-suggestions" style="margin-top:4px;">380              <For each={tagSuggestions()}>381                {(s) => (382                  <button383                    class="peach-tag-suggestion"384                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}385                  >{s}</button>386                )}387              </For>388            </div>389          </Show>390        </div>391392        <Show when={error()}>393          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>394        </Show>395396      </div>397    </div>398  );399}