import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; import type { VaultEntry } from "../../shared/types"; import { sendMessage } from "../App"; import { parseOtpauthURI } from "../../crypto/totp"; import { GeneratorDrawer } from "../components/GeneratorDrawer"; interface AddProps { entry?: VaultEntry; entries?: VaultEntry[]; autoOpenGenerator?: boolean; onBack: () => void; onSave: (entry?: VaultEntry) => void | Promise; } export function AddView(props: AddProps) { const [title, setTitle] = createSignal(props.entry?.title ?? ""); const [username, setUsername] = createSignal(props.entry?.username ?? ""); const [password, setPassword] = createSignal(props.entry?.password ?? ""); const [url, setUrl] = createSignal(props.entry?.url ?? ""); const [totp, setTotp] = createSignal(props.entry?.totp ?? ""); const [notes, setNotes] = createSignal(props.entry?.notes ?? ""); const [saving, setSaving] = createSignal(false); const [error, setError] = createSignal(""); const [passwordStrength, setPasswordStrength] = createSignal(null); const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null); const [tags, setTags] = createSignal(props.entry?.tags ?? []); const [tagInput, setTagInput] = createSignal(""); const [showTagSuggestions, setShowTagSuggestions] = createSignal(false); const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false); const [showPassword, setShowPassword] = createSignal(false); const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp)); const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes)); const [email, setEmail] = createSignal((props.entry as VaultEntry & { email?: string })?.email ?? ""); const [activeIdentityField, setActiveIdentityField] = createSignal<"username" | "email" | null>(null); const [currentTabUrl, setCurrentTabUrl] = createSignal(""); // Mutable refs for keyboard navigation between inputs and suggestion lists. // Not signals — focus state doesn't need to be reactive. let usernameInputEl: HTMLInputElement | undefined; let emailInputEl: HTMLInputElement | undefined; let usernameSuggestEl: HTMLDivElement | undefined; let emailSuggestEl: HTMLDivElement | undefined; let suppressIdentityOpen = false; function openIdentityField(field: "username" | "email") { if (suppressIdentityOpen) { suppressIdentityOpen = false; return; } setActiveIdentityField(field); } const allIdentities = createMemo(() => { const emailMap = new Map(); const usernameMap = new Map(); for (const entry of props.entries ?? []) { const uname = entry.username?.trim() ?? ""; const em = entry.email?.trim() ?? ""; if (uname) { if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1); else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1); } if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1); } const result: Array<{ value: string; count: number; type: "email" | "username" }> = []; const seen = new Set(); for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) { if (seen.has(v)) continue; seen.add(v); const ec = emailMap.get(v) ?? 0; const uc = usernameMap.get(v) ?? 0; result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" }); } return result.sort((a, b) => b.count - a.count); }); const identitySuggestions = createMemo(() => { const field = activeIdentityField(); if (!field) return []; const input = (field === "username" ? username() : email()).toLowerCase(); return allIdentities() .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input))) .slice(0, 8); }); const isCardEntry = () => props.entry?.type === "card"; const [selectedType, setSelectedType] = createSignal<"login" | "card">( isCardEntry() ? "card" : "login" ); // Card signals const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? ""); const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? ""); const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? ""); const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? ""); const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? ""); const [cvc, setCvc] = createSignal(props.entry?.cvc ?? ""); const [showCvc, setShowCvc] = createSignal(false); const [cardNumberFocused, setCardNumberFocused] = createSignal(false); function getCardNetwork(num: string): string { const n = num.replace(/\s/g, ''); if (/^4/.test(n)) return 'Visa'; if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard'; if (/^3[47]/.test(n)) return 'Amex'; if (/^6(?:011|5)/.test(n)) return 'Discover'; return ''; } function formatCardNumberDisplay(raw: string, focused: boolean): string { const n = raw.replace(/\s/g, ''); if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4); 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(' ')); return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim(); } const canSave = () => { if (!title().trim()) return false; if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false; return true; }; const allTags = createMemo(() => { const tagSet = new Set(); for (const entry of props.entries ?? []) { for (const tag of entry.tags ?? []) tagSet.add(tag); } return Array.from(tagSet).sort(); }); const tagSuggestions = createMemo(() => { const input = tagInput().toLowerCase().trim(); if (!input) return []; return allTags().filter(t => t.toLowerCase().includes(input) && !tags().includes(t) ).slice(0, 5); }); function addTag(tag: string) { const n = tag.toLowerCase().trim(); if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return; setTags([...tags(), n]); } function removeTag(tag: string) { setTags(tags().filter(t => t !== tag)); } function handleTagKeyDown(e: KeyboardEvent) { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); } } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) { removeTag(tags()[tags().length - 1]); } } // Strength check createEffect(async () => { const pwd = password(); if (pwd.length < 4) { setPasswordStrength(null); return; } // zxcvbn does full dictionary + spatial + repeat analysis across every // character, so a 100+ char password blocks the main thread for hundreds // of ms per slider tick. Any 50+ char password is trivially score 4 — // skip the analysis and keep the slider responsive. if (pwd.length >= 50) { setPasswordStrength(4); return; } try { if (!zxcvbnFn()) { // Replaces the unmaintained `zxcvbn` package (LV-8; see Lock.tsx). const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([ import("@zxcvbn-ts/core"), import("@zxcvbn-ts/language-common"), import("@zxcvbn-ts/language-en"), ]); zxcvbnOptions.setOptions({ translations: en.translations, graphs: common.adjacencyGraphs, dictionary: { ...common.dictionary, ...en.dictionary }, }); setZxcvbnFn(() => (pwd: string) => zxcvbn(pwd)); } const fn = zxcvbnFn(); if (fn) setPasswordStrength(fn(pwd).score); } catch { setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1); } }); onMount(() => { const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null; if (el) el.focus(); chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { const tabUrl = tabs[0]?.url; if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl); }); // Keep the vault alive while the form is open — the auto-lock alarm fires on // a timer and doesn't know the user is actively filling in a new entry. const keepAlive = setInterval(() => { chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {}); }, 30_000); onCleanup(() => clearInterval(keepAlive)); }); function getNormalizedTotp(): string | undefined { const value = totp().trim(); if (!value) return undefined; if (value.startsWith("otpauth://")) { const parsed = parseOtpauthURI(value); return parsed?.secret ?? value; } return value; } async function handleSave() { if (!canSave()) { setError( selectedType() === "card" ? "cardholder name and card number are required" : "title is required" ); return; } setSaving(true); setError(""); const payload: Record = { type: selectedType(), title: title().trim(), notes: notes().trim() || undefined, tags: tags().length > 0 ? tags() : undefined, }; if (selectedType() === "login") { payload.username = username().trim(); payload.email = email().trim() || undefined; payload.password = password(); payload.url = url().trim(); payload.totp = getNormalizedTotp(); } if (selectedType() === "card") { const raw = cardNumber().replace(/\s/g, ''); payload.cardholderName = cardholderName().trim(); payload.cardNumber = raw; payload.expMonth = expMonth().trim(); payload.expYear = expYear().trim(); payload.cvc = cvc().trim(); payload.cardNickname = cardNickname().trim() || undefined; const n = raw; 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'; payload.last4 = raw.slice(-4); } if (props.entry) { const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } }); const result = res as { ok: boolean; error?: string }; if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; } await props.onSave({ ...props.entry, ...payload } as VaultEntry); return; } const res = await sendMessage({ type: "ADD_ENTRY", payload }); const result = res as { ok: boolean; data?: VaultEntry; error?: string }; if (!result.ok) { // Fixed strings only — raw background errors must not reach the UI. if (result.error === "vault_locked") { setError("vault is locked — tap the lock icon to re-enter your master password"); } else { // Fixed strings only — raw background errors must not reach the UI. const SAFE_ERRORS = new Set([ "title is required", "cardholder name and card number are required", "invalid passkey credential", "pro_required", ]); setError( result.error !== undefined && SAFE_ERRORS.has(result.error) ? result.error : "could not save entry", ); } setSaving(false); return; } await props.onSave(result.data); } const strengthLabel = () => { const s = passwordStrength(); if (s === null) return null; return ["weak", "weak", "fair", "good", "strong"][s] as string; }; return (
{props.entry ? "edit entry" : "new entry"}
{/* ── Body — mirrors Entry.tsx field-card structure ── */}
{/* Type toggle */}
{/* Title */}
setTitle((e.currentTarget as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }} />
{/* ── Login fields ── */}
setUrl((e.currentTarget as HTMLInputElement).value)} />
{/* Username */}
username
(usernameInputEl = el)} class="peach-field-value peach-field-value--edit" placeholder="johndoe" autocomplete="off" value={username()} onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)} onFocus={() => openIdentityField("username")} onBlur={(e) => { if (usernameSuggestEl?.contains(e.relatedTarget as Node)) return; setTimeout(() => { if (activeIdentityField() === "username") setActiveIdentityField(null); }, 150); }} onKeyDown={(e) => { if (activeIdentityField() !== "username") return; if (e.key === "ArrowDown") { e.preventDefault(); usernameSuggestEl?.querySelector(".peach-identity-suggestion")?.focus(); } else if (e.key === "Escape") { e.preventDefault(); setActiveIdentityField(null); } }} />
0}> (usernameSuggestEl = el)} inputRef={() => usernameInputEl} suggestions={identitySuggestions()} onPick={(v) => { setUsername(v); setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }} onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }} />
{/* Email */}
email
(emailInputEl = el)} class="peach-field-value peach-field-value--edit" type="email" placeholder="you@example.com" autocomplete="off" value={email()} onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)} onFocus={() => openIdentityField("email")} onBlur={(e) => { if (emailSuggestEl?.contains(e.relatedTarget as Node)) return; setTimeout(() => { if (activeIdentityField() === "email") setActiveIdentityField(null); }, 150); }} onKeyDown={(e) => { if (activeIdentityField() !== "email") return; if (e.key === "ArrowDown") { e.preventDefault(); emailSuggestEl?.querySelector(".peach-identity-suggestion")?.focus(); } else if (e.key === "Escape") { e.preventDefault(); setActiveIdentityField(null); } }} />
0}> (emailSuggestEl = el)} inputRef={() => emailInputEl} suggestions={identitySuggestions()} onPick={(v) => { setEmail(v); setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }} onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }} />
{/* Password */}
password
setPassword((e.currentTarget as HTMLInputElement).value)} />
{/* Strength */}
{strengthLabel()}
{/* Generator toggle */} setPassword(pw)} onUse={(pw) => { setPassword(pw); setShowGenerator(false); }} />
{/* 2FA — optional */}
2fa secret
setTotp((e.currentTarget as HTMLInputElement).value)} />
{/* ── Card fields ── */}
{/* Nickname */}
nickname
setCardNickname((e.currentTarget as HTMLInputElement).value)} />
{/* Cardholder name */}
cardholder name
setCardholderName((e.currentTarget as HTMLInputElement).value)} />
{/* Card number */}
card number {getCardNetwork(cardNumber())}
setCardNumberFocused(true)} onBlur={() => setCardNumberFocused(false)} onInput={(e) => { const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, ''); setCardNumber(raw); }} />
{/* Expiry */}
expiry
setExpMonth((e.currentTarget as HTMLInputElement).value)} /> / setExpYear((e.currentTarget as HTMLInputElement).value)} />
{/* CVC */}
CVC
setCvc((e.currentTarget as HTMLInputElement).value)} />
{/* ── Login notes ── */}
notes