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 isCardEntry = () => props.entry?.type === "card";37 const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(38 isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"39 );4041 42 const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");43 const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");44 const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");45 const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");46 const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");47 const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");48 const [showCvc, setShowCvc] = createSignal(false);49 const [cardNumberFocused, setCardNumberFocused] = createSignal(false);50 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);5152 function noteExec(cmd: string, val?: string) {53 document.execCommand(cmd, false, val);54 noteEditorEl()?.focus();55 }5657 function getCardNetwork(num: string): string {58 const n = num.replace(/\s/g, '');59 if (/^4/.test(n)) return 'Visa';60 if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';61 if (/^3[47]/.test(n)) return 'Amex';62 if (/^6(?:011|5)/.test(n)) return 'Discover';63 return '';64 }6566 function formatCardNumberDisplay(raw: string, focused: boolean): string {67 const n = raw.replace(/\s/g, '');68 if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);69 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(' '));70 return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();71 }7273 const canSave = () => {74 if (!title().trim()) return false;75 if (selectedType() === "login" && !password().trim()) return false;76 if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;77 return true;78 };7980 const allTags = createMemo(() => {81 const tagSet = new Set<string>();82 for (const entry of props.entries ?? []) {83 for (const tag of entry.tags ?? []) tagSet.add(tag);84 }85 return Array.from(tagSet).sort();86 });8788 const tagSuggestions = createMemo(() => {89 const input = tagInput().toLowerCase().trim();90 if (!input) return [];91 return allTags().filter(t =>92 t.toLowerCase().includes(input) && !tags().includes(t)93 ).slice(0, 5);94 });9596 function addTag(tag: string) {97 const n = tag.toLowerCase().trim();98 if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;99 setTags([...tags(), n]);100 }101102 function removeTag(tag: string) {103 setTags(tags().filter(t => t !== tag));104 }105106 function handleTagKeyDown(e: KeyboardEvent) {107 if (e.key === "Enter" || e.key === ",") {108 e.preventDefault();109 if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }110 } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {111 removeTag(tags()[tags().length - 1]);112 }113 }114115 116 createEffect(async () => {117 const pwd = password();118 if (pwd.length < 4) { setPasswordStrength(null); return; }119 try {120 if (!zxcvbnFn()) {121 const mod = await import("zxcvbn");122 setZxcvbnFn(() => mod.default);123 }124 const fn = zxcvbnFn();125 if (fn) setPasswordStrength(fn(pwd).score);126 } catch {127 setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);128 }129 });130131 onMount(() => {132 const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;133 if (el) el.focus();134 });135136 function getNormalizedTotp(): string | undefined {137 const value = totp().trim();138 if (!value) return undefined;139 if (value.startsWith("otpauth://")) {140 const parsed = parseOtpauthURI(value);141 return parsed?.secret ?? value;142 }143 return value;144 }145146 async function handleSave() {147 if (!canSave()) {148 setError(149 selectedType() === "note"150 ? "title is required"151 : selectedType() === "card"152 ? "cardholder name and card number are required"153 : "title and password are required"154 );155 return;156 }157158 setSaving(true);159 setError("");160161 const payload: Record<string, unknown> = {162 type: selectedType(),163 title: title().trim(),164 notes: notes().trim() || undefined,165 tags: tags().length > 0 ? tags() : undefined,166 };167168 if (selectedType() === "login") {169 payload.username = username().trim();170 payload.password = password();171 payload.url = url().trim();172 payload.totp = getNormalizedTotp();173 }174175 if (selectedType() === "card") {176 const raw = cardNumber().replace(/\s/g, '');177 payload.cardholderName = cardholderName().trim();178 payload.cardNumber = raw;179 payload.expMonth = expMonth().trim();180 payload.expYear = expYear().trim();181 payload.cvc = cvc().trim();182 payload.cardNickname = cardNickname().trim() || undefined;183 const n = raw;184 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';185 payload.last4 = raw.slice(-4);186 }187188 if (props.entry) {189 const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });190 const result = res as { ok: boolean; error?: string };191 if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }192 await props.onSave({ ...props.entry, ...payload } as VaultEntry);193 return;194 }195196 const res = await sendMessage({ type: "ADD_ENTRY", payload });197 const result = res as { ok: boolean; data?: VaultEntry; error?: string };198 if (!result.ok) {199 if (result.error === "vault locked") {200 setError("vault is locked — tap the lock icon to re-enter your master password");201 } else {202 setError(result.error ?? "could not save entry");203 }204 setSaving(false);205 return;206 }207 await props.onSave(result.data);208 }209210 const strengthLabel = () => {211 const s = passwordStrength();212 if (s === null) return null;213 return ["weak", "weak", "fair", "good", "strong"][s] as string;214 };215216 return (217 <div class="peach-overlay-view ov-enter-right" style="display:flex">218 <div class="peach-overlay-header">219 <button220 class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"221 onClick={props.onBack}222 >223 cancel224 </button>225 <span class="peach-overlay-title">226 {props.entry ? "edit entry" : "new entry"}227 </span>228 <button229 class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"230 onClick={() => void handleSave()}231 disabled={saving() || !canSave()}232 >233 {saving() ? "saving…" : props.entry ? "save" : "create"}234 </button>235 </div>236237 {}238 <div class="peach-entry-body peach-entry-body--new">239240 {}241 <Show when={!props.entry}>242 <div class="peach-new-type-row">243 <button244 class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}245 onClick={() => setSelectedType("login")}246 >login</button>247 <button248 class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}249 onClick={() => setSelectedType("note")}250 >note</button>251 <button252 class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}253 onClick={() => setSelectedType("card")}254 >card</button>255 </div>256 </Show>257258 {}259 <Show when={selectedType() === "note"}>260 <Toolbar exec={noteExec} class="peach-note-toolbar-body" />261 </Show>262263 {}264 <div class="peach-new-title-wrap">265 <input266 class="peach-new-title-input"267 placeholder={selectedType() === "note" ? "note title" : "entry name"}268 autocomplete="off"269 value={title()}270 onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}271 onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}272 />273 </div>274275 {}276 <Show when={selectedType() === "login"}>277 <div class="peach-domain">278 <input279 class="peach-domain-value peach-domain-value--edit"280 placeholder="domain.com"281 autocomplete="off"282 value={url()}283 onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}284 />285 </div>286287 <section class="peach-fields">288 {}289 <div class="peach-field-card">290 <span class="peach-label">username</span>291 <div class="peach-field-value-row">292 <input293 class="peach-field-value peach-field-value--edit"294 placeholder="username or email"295 autocomplete="off"296 value={username()}297 onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}298 />299 </div>300 </div>301302 {}303 <div class="peach-field-card">304 <span class="peach-label">password</span>305 <div class="peach-password-edit-row">306 <input307 class="peach-field-value peach-field-value--edit"308 type={showPassword() ? "text" : "password"}309 placeholder="password"310 autocomplete="new-password"311 value={password()}312 onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}313 />314 <button315 class="peach-inline-action peach-password-toggle-btn"316 type="button"317 onClick={() => setShowPassword(!showPassword())}318 aria-label={showPassword() ? "Hide password" : "Show password"}319 >320 <Show321 when={showPassword()}322 fallback={323 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">324 <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" />325 <circle cx="7" cy="5" r="1.75" />326 </svg>327 }328 >329 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">330 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />331 <path d="M3.5 5.5L2.5 7.5" />332 <path d="M7 6.5V8.5" />333 <path d="M10.5 5.5L11.5 7.5" />334 </svg>335 </Show>336 </button>337 </div>338339 {}340 <Show when={strengthLabel() !== null}>341 <div class="peach-strength-hairline">342 <div class="peach-strength-hairline-track">343 <div344 class="peach-strength-hairline-fill"345 style={{346 width: ["25%","40%","60%","80%","100%"][passwordStrength()!],347 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()!],348 }}349 />350 </div>351 <span class="peach-strength-label">{strengthLabel()}</span>352 </div>353 </Show>354355 {}356 <button357 class="peach-generate-toggle-link"358 type="button"359 onClick={() => { setShowGenerator(!showGenerator()); }}360 >361 {showGenerator() ? "☰ hide generator" : "⚙ generate"}362 </button>363 <GeneratorDrawer364 compact365 open={showGenerator()}366 onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}367 />368 </div>369370 {}371 <Show when={showTotp() || totp()}>372 <div class="peach-field-card">373 <span class="peach-label">2fa secret</span>374 <div class="peach-field-value-row">375 <input376 class="peach-field-value peach-field-value--edit"377 placeholder="BASE32 or otpauth:// URI"378 autocomplete="off"379 value={totp()}380 onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}381 />382 </div>383 </div>384 </Show>385 </section>386 </Show>387388 {}389 <Show when={selectedType() === "card"}>390 <section class="peach-fields">391 {}392 <div class="peach-field-card">393 <span class="peach-label">nickname</span>394 <div class="peach-field-value-row">395 <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"396 autocomplete="off" value={cardNickname()}397 onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />398 </div>399 </div>400 {}401 <div class="peach-field-card">402 <span class="peach-label">cardholder name</span>403 <div class="peach-field-value-row">404 <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"405 autocomplete="cc-name" value={cardholderName()}406 onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />407 </div>408 </div>409 {}410 <div class="peach-field-card">411 <span class="peach-label">412 card number413 <Show when={getCardNetwork(cardNumber())}>414 <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>415 </Show>416 </span>417 <div class="peach-field-value-row">418 <input class="peach-field-value peach-field-value--edit peach-card-number"419 placeholder="•••• •••• •••• ••••"420 autocomplete="cc-number" inputmode="numeric" maxLength={19}421 value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}422 onFocus={() => setCardNumberFocused(true)}423 onBlur={() => setCardNumberFocused(false)}424 onInput={(e) => {425 const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');426 setCardNumber(raw);427 }} />428 </div>429 </div>430 {}431 <div class="peach-field-card">432 <span class="peach-label">expiry</span>433 <div class="peach-card-expiry-row">434 <input class="peach-field-value peach-field-value--edit" placeholder="MM"435 autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"436 value={expMonth()}437 onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />438 <span class="peach-card-expiry-sep">/</span>439 <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"440 autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"441 value={expYear()}442 onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />443 </div>444 </div>445 {}446 <div class="peach-field-card">447 <span class="peach-label">CVC</span>448 <div class="peach-field-value-row">449 <input class="peach-field-value peach-field-value--edit"450 type={showCvc() ? "text" : "password"} placeholder="•••"451 autocomplete="cc-csc" inputmode="numeric" maxLength={4}452 value={cvc()}453 onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />454 <button class="peach-inline-action peach-password-toggle-btn" type="button"455 onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>456 <Show when={showCvc()} fallback={457 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">458 <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" />459 <circle cx="7" cy="5" r="1.75" />460 </svg>461 }>462 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">463 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />464 </svg>465 </Show>466 </button>467 </div>468 </div>469 </section>470 </Show>471472 {}473 <Show when={selectedType() === "note"}>474 <div class="peach-note-body">475 <NoteEditorInline476 initialContent={notes()}477 onContentChange={setNotes}478 onReady={(el) => setNoteEditorEl(el)}479 />480 </div>481 </Show>482483 {}484 <Show when={selectedType() === "login" && (showNotes() || notes())}>485 <div class="peach-field-card" style="margin:0 12px">486 <span class="peach-label">notes</span>487 <textarea488 class="peach-new-textarea"489 placeholder="anything worth remembering"490 value={notes()}491 onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}492 />493 </div>494 </Show>495496 {}497 <div class="peach-entry-add-links">498 <Show when={selectedType() === "login" && !showTotp() && !totp()}>499 <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>500 + add 2FA501 </button>502 </Show>503 <Show when={selectedType() === "login" && !showNotes() && !notes()}>504 <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>505 + add notes506 </button>507 </Show>508 </div>509510 {}511 <div class="peach-field-card" style="margin:4px 12px 12px">512 <span class="peach-label">tags</span>513 <div class="peach-tag-input-wrap">514 <For each={tags()}>515 {(tag) => (516 <span class="peach-tag-chip">517 {tag}518 <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>519 </span>520 )}521 </For>522 <input523 class="peach-tag-input"524 placeholder={tags().length === 0 ? "add tags…" : ""}525 value={tagInput()}526 onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}527 onKeyDown={handleTagKeyDown}528 onFocus={() => setShowTagSuggestions(true)}529 onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}530 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"531 />532 </div>533 <Show when={showTagSuggestions() && tagSuggestions().length > 0}>534 <div class="peach-tag-suggestions" style="margin-top:4px;">535 <For each={tagSuggestions()}>536 {(s) => (537 <button538 class="peach-tag-suggestion"539 onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}540 >{s}</button>541 )}542 </For>543 </div>544 </Show>545 </div>546547 <Show when={error()}>548 <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>549 </Show>550551 </div>552 </div>553 );554}