Add.tsx

15.1 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 } 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");3738  const canSave = () => {39    if (!title().trim()) return false;40    if (selectedType() === "login" && !password().trim()) return false;41    return true;42  };4344  const allTags = createMemo(() => {45    const tagSet = new Set<string>();46    for (const entry of props.entries ?? []) {47      for (const tag of entry.tags ?? []) tagSet.add(tag);48    }49    return Array.from(tagSet).sort();50  });5152  const tagSuggestions = createMemo(() => {53    const input = tagInput().toLowerCase().trim();54    if (!input) return [];55    return allTags().filter(t =>56      t.toLowerCase().includes(input) && !tags().includes(t)57    ).slice(0, 5);58  });5960  function addTag(tag: string) {61    const n = tag.toLowerCase().trim();62    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;63    setTags([...tags(), n]);64  }6566  function removeTag(tag: string) {67    setTags(tags().filter(t => t !== tag));68  }6970  function handleTagKeyDown(e: KeyboardEvent) {71    if (e.key === "Enter" || e.key === ",") {72      e.preventDefault();73      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }74    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {75      removeTag(tags()[tags().length - 1]);76    }77  }7879  // Strength check80  createEffect(async () => {81    const pwd = password();82    if (pwd.length < 4) { setPasswordStrength(null); return; }83    try {84      if (!zxcvbnFn()) {85        const mod = await import("zxcvbn");86        setZxcvbnFn(() => mod.default);87      }88      const fn = zxcvbnFn();89      if (fn) setPasswordStrength(fn(pwd).score);90    } catch {91      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);92    }93  });9495  onMount(() => {96    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;97    if (el) el.focus();98  });99100  function getNormalizedTotp(): string | undefined {101    const value = totp().trim();102    if (!value) return undefined;103    if (value.startsWith("otpauth://")) {104      const parsed = parseOtpauthURI(value);105      return parsed?.secret ?? value;106    }107    return value;108  }109110  async function handleSave() {111    if (!canSave()) {112      setError(selectedType() === "note" ? "title is required" : "title and password are required");113      return;114    }115116    setSaving(true);117    setError("");118119    const payload: Record<string, unknown> = {120      type: selectedType(),121      title: title().trim(),122      notes: notes().trim() || undefined,123      tags: tags().length > 0 ? tags() : undefined,124    };125126    if (selectedType() === "login") {127      payload.username = username().trim();128      payload.password = password();129      payload.url = url().trim();130      payload.totp = getNormalizedTotp();131    }132133    if (props.entry) {134      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });135      const result = res as { ok: boolean; error?: string };136      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }137      await props.onSave({ ...props.entry, ...payload } as VaultEntry);138      return;139    }140141    const res = await sendMessage({ type: "ADD_ENTRY", payload });142    const result = res as { ok: boolean; data?: VaultEntry; error?: string };143    if (!result.ok) { setError(result.error ?? "could not save entry"); setSaving(false); return; }144    await props.onSave(result.data);145  }146147  const strengthLabel = () => {148    const s = passwordStrength();149    if (s === null) return null;150    return ["weak", "weak", "fair", "good", "strong"][s] as string;151  };152153  return (154    <div class="peach-overlay-view ov-enter-right" style="display:flex">155      <div class="peach-overlay-header">156        <button157          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"158          onClick={props.onBack}159        >160          cancel161        </button>162        <span class="peach-overlay-title">163          {props.entry ? "edit entry" : "new entry"}164        </span>165        <button166          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"167          onClick={() => void handleSave()}168          disabled={saving() || !canSave()}169        >170          {saving() ? "saving…" : props.entry ? "save" : "create"}171        </button>172      </div>173174      {/* ── Body — mirrors Entry.tsx field-card structure ── */}175      <div class="peach-entry-body peach-entry-body--new">176177        {/* Type toggle — only for new entries without a fixed type */}178        <Show when={!props.entry}>179          <div class="peach-new-type-row">180            <button181              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}182              onClick={() => setSelectedType("login")}183            >login</button>184            <button185              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}186              onClick={() => setSelectedType("note")}187            >note</button>188          </div>189        </Show>190191        {/* Title */}192        <div class="peach-new-title-wrap">193          <input194            class="peach-new-title-input"195            placeholder={selectedType() === "note" ? "note title" : "entry name"}196            autocomplete="off"197            value={title()}198            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}199            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}200          />201        </div>202203        {/* ── Login fields ── */}204        <Show when={selectedType() === "login"}>205          <div class="peach-domain">206            <input207              class="peach-domain-value peach-domain-value--edit"208              placeholder="domain.com"209              autocomplete="off"210              value={url()}211              onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}212            />213          </div>214215          <section class="peach-fields">216            {/* Username */}217            <div class="peach-field-card">218              <span class="peach-label">username</span>219              <div class="peach-field-value-row">220                <input221                  class="peach-field-value peach-field-value--edit"222                  placeholder="username or email"223                  autocomplete="off"224                  value={username()}225                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}226                />227              </div>228            </div>229230            {/* Password */}231            <div class="peach-field-card">232              <span class="peach-label">password</span>233              <div class="peach-password-edit-row">234                <input235                  class="peach-field-value peach-field-value--edit"236                  type={showPassword() ? "text" : "password"}237                  placeholder="password"238                  autocomplete="new-password"239                  value={password()}240                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}241                />242                <button243                  class="peach-inline-action peach-password-toggle-btn"244                  type="button"245                  onClick={() => setShowPassword(!showPassword())}246                  aria-label={showPassword() ? "Hide password" : "Show password"}247                >248                  <Show249                    when={showPassword()}250                    fallback={251                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">252                        <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" />253                        <circle cx="7" cy="5" r="1.75" />254                      </svg>255                    }256                  >257                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">258                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />259                      <path d="M3.5 5.5L2.5 7.5" />260                      <path d="M7 6.5V8.5" />261                      <path d="M10.5 5.5L11.5 7.5" />262                    </svg>263                  </Show>264                </button>265              </div>266267              {/* Strength */}268              <Show when={strengthLabel() !== null}>269                <div class="peach-strength-hairline">270                  <div class="peach-strength-hairline-track">271                    <div272                      class="peach-strength-hairline-fill"273                      style={{274                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],275                        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()!],276                      }}277                    />278                  </div>279                  <span class="peach-strength-label">{strengthLabel()}</span>280                </div>281              </Show>282283              {/* Generator toggle */}284              <button285                class="peach-generate-toggle-link"286                type="button"287                onClick={() => { setShowGenerator(!showGenerator()); }}288              >289                {showGenerator() ? "☰ hide generator" : "⚙ generate"}290              </button>291              <GeneratorDrawer292                compact293                open={showGenerator()}294                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}295              />296            </div>297298            {/* 2FA — optional */}299            <Show when={showTotp() || totp()}>300              <div class="peach-field-card">301                <span class="peach-label">2fa secret</span>302                <div class="peach-field-value-row">303                  <input304                    class="peach-field-value peach-field-value--edit"305                    placeholder="BASE32 or otpauth:// URI"306                    autocomplete="off"307                    value={totp()}308                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}309                  />310                </div>311              </div>312            </Show>313          </section>314        </Show>315316        {/* ── Note content ── */}317        <Show when={selectedType() === "note"}>318          <div style="margin:4px 12px 0">319            <NoteEditorInline320              initialContent={notes()}321              onContentChange={setNotes}322            />323          </div>324        </Show>325326        {/* ── Login notes ── */}327        <Show when={selectedType() === "login" && (showNotes() || notes())}>328          <div class="peach-field-card" style="margin:0 12px">329            <span class="peach-label">notes</span>330            <textarea331              class="peach-new-textarea"332              placeholder="anything worth remembering"333              value={notes()}334              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}335            />336          </div>337        </Show>338339        {/* ── "+ add" optional field links ── */}340        <div class="peach-entry-add-links">341          <Show when={selectedType() === "login" && !showTotp() && !totp()}>342            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>343              + add 2FA344            </button>345          </Show>346          <Show when={selectedType() === "login" && !showNotes() && !notes()}>347            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>348              + add notes349            </button>350          </Show>351        </div>352353        {/* ── Tags ── */}354        <div class="peach-field-card" style="margin:4px 12px 12px">355          <span class="peach-label">tags</span>356          <div class="peach-tag-input-wrap">357            <For each={tags()}>358              {(tag) => (359                <span class="peach-tag-chip">360                  {tag}361                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>362                </span>363              )}364            </For>365            <input366              class="peach-tag-input"367              placeholder={tags().length === 0 ? "add tags…" : ""}368              value={tagInput()}369              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}370              onKeyDown={handleTagKeyDown}371              onFocus={() => setShowTagSuggestions(true)}372              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}373              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"374            />375          </div>376          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>377            <div class="peach-tag-suggestions" style="margin-top:4px;">378              <For each={tagSuggestions()}>379                {(s) => (380                  <button381                    class="peach-tag-suggestion"382                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}383                  >{s}</button>384                )}385              </For>386            </div>387          </Show>388        </div>389390        <Show when={error()}>391          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>392        </Show>393394      </div>395    </div>396  );397}