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 59 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 133 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 {}255 <div class="peach-entry-body peach-entry-body--new">256257 {}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 {}276 <Show when={selectedType() === "note"}>277 <Toolbar exec={noteExec} class="peach-note-toolbar-body" />278 </Show>279280 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}424 <Show when={selectedType() === "card"}>425 <section class="peach-fields">426 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}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}