1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";1213type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1415interface EntryProps {16 entry: VaultEntry | null;17 1819202122 currentTabUrl?: string;23 onDelete: () => void;24 25 onFieldSaved: () => void | Promise<void>;26 onEscape?: () => void;27}2829export function EntryView(props: EntryProps) {30 const [editingField, setEditingField] = createSignal<EditableField>(null);31 const [draft, setDraft] = createSignal("");32 const [saving, setSaving] = createSignal(false);33 const [showPassword, setShowPassword] = createSignal(false);34 const [showGenerator, setShowGenerator] = createSignal(false);35 const [copied, setCopied] = createSignal<string | null>(null);36 37 38 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);39 const [editingTags, setEditingTags] = createSignal(false);40 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);41 const [tagInput, setTagInput] = createSignal("");42 const [showCardNumber, setShowCardNumber] = createSignal(false);43 const [showCardCvc, setShowCardCvc] = createSignal(false);44 const [totpTick, setTotpTick] = createSignal(0);45 const [confirmDelete, setConfirmDelete] = createSignal(false);46 const [totpHot, setTotpHot] = createSignal(false);47 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);48 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);49 50 const [showAdvanced, setShowAdvanced] = createSignal(false);51 let lastSavedNote: string | null = null;5253 function noteExec(cmd: string, val?: string) {54 document.execCommand(cmd, false, val);55 noteEditorEl()?.focus();56 }5758 async function saveNoteInline(html: string) {59 const entry = props.entry;60 if (!entry) return;61 const currentStored = entry.notes ?? "";62 if (html === currentStored) return;63 if (lastSavedNote !== null && html === lastSavedNote) return;64 lastSavedNote = html;65 setSaving(true);66 try {67 await sendMessage({68 type: "UPDATE_ENTRY",69 payload: { id: entry.id, patch: { notes: html } },70 });71 triggerRing();72 await props.onFieldSaved();73 } catch {74 lastSavedNote = null;75 } finally {76 setSaving(false);77 }78 }7980 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);81 onCleanup(() => window.clearInterval(interval));8283 const displayDomain = createMemo(() =>84 props.entry?.url ? getDisplayDomain(props.entry.url) : ""85 );8687 createEffect(() => {88 const domain = displayDomain();89 setFaviconSrc(domain ? faviconUrl(domain) : null);90 });9192 93 94 createEffect(() => {95 void props.entry?.id;96 setConfirmDelete(false);97 setEditingField(null);98 setEditingTags(false);99 setShowAdvanced(false);100 });101102 const totpState = createMemo(() => {103 void totpTick();104 if (!props.entry?.totp) return null;105 return generateTOTP(props.entry.totp);106 });107108 let prevTotpCode: string | undefined;109 createEffect(() => {110 const state = totpState();111 if (!state) { prevTotpCode = undefined; return; }112 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {113 setTotpHot(true);114 window.setTimeout(() => setTotpHot(false), 3000);115 }116 prevTotpCode = state.code;117 });118119 const totpProgress = createMemo(() => {120 const state = totpState();121 if (!state) return 0;122 return ((state.remaining - 1) / 29) * 100;123 });124125 createEffect(() => {126 const shell = document.getElementById("hd-shell");127 if (!shell) return;128129 const state = totpState();130 if (!state) {131 delete shell.dataset.totpActive;132 shell.style.removeProperty("--peach-totp-progress");133 return;134 }135136 shell.dataset.totpActive = "true";137 shell.style.setProperty(138 "--peach-totp-progress",139 `${Math.max(0, totpProgress())}%`140 );141142 onCleanup(() => {143 delete shell.dataset.totpActive;144 shell.style.removeProperty("--peach-totp-progress");145 });146 });147148 const titleSizeClass = createMemo(() => {149 const length = props.entry?.title.length ?? 0;150 if (length >= 28) return "peach-entry-name--xxl";151 if (length >= 20) return "peach-entry-name--xl";152 if (length >= 14) return "peach-entry-name--lg";153 return "";154 });155 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);156157 158159 function startEdit(field: EditableField, value: string) {160 setEditingField(field);161 setDraft(value);162 setShowGenerator(false);163 setConfirmDelete(false);164 if (field === "url") {165 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);166 }167 }168169 170 171 172 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {173 const c = candidate.trim();174 if (!c) return true;175 if (primary.trim() === c) return true;176 return others.some((u) => u.trim() === c);177 }178179 function addCurrentSite(url: string) {180 if (!url) return;181 if (!draft().trim()) {182 setDraft(url);183 return;184 }185 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;186 setAdditionalUrlsDraft((arr) => [...arr, url]);187 }188189 async function saveField() {190 const field = editingField();191 const entry = props.entry;192 if (!field || !entry) return;193 194 let value = field === "password" ? draft() : draft().trim();195 if (field === "totp" && value.startsWith("otpauth://")) {196 const parsed = parseOtpauthURI(value);197 value = parsed?.secret ?? value;198 }199 if (field === "password" && entry.type !== "note" && !value) {200 return;201 }202 setSaving(true);203 try {204 const patch: Partial<VaultEntry> = { [field]: value };205 if (field === "url") {206 207 patch.additionalUrls = additionalUrlsDraft();208 }209 await sendMessage({210 type: "UPDATE_ENTRY",211 payload: { id: entry.id, patch },212 });213 triggerRing();214 setEditingField(null);215 setShowGenerator(false);216 await props.onFieldSaved();217 } catch {218 219 } finally {220 setSaving(false);221 }222 }223224 function startEditTags() {225 setTagsDraft([...(props.entry?.tags ?? [])]);226 setTagInput("");227 setEditingTags(true);228 }229230 function addTag(value: string) {231 const n = value.toLowerCase().trim();232 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;233 setTagsDraft([...tagsDraft(), n]);234 setTagInput("");235 }236237 async function saveTags() {238 if (tagInput()) addTag(tagInput());239 const entry = props.entry;240 if (!entry) return;241 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;242 setEditingTags(false);243 setSaving(true);244 try {245 await sendMessage({246 type: "UPDATE_ENTRY",247 payload: { id: entry.id, patch: { tags: next } },248 });249 triggerRing();250 await props.onFieldSaved();251 } catch {252 253 } finally {254 setSaving(false);255 }256 }257258 function cancelEdit() {259 setEditingField(null);260 setDraft("");261 setShowGenerator(false);262 }263264 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {265 switch (field) {266 case "title":267 return entry.title;268 case "url":269 return entry.url ?? "";270 case "username":271 return entry.username ?? "";272 case "email":273 return entry.email ?? "";274 case "password":275 return entry.password ?? "";276 case "notes":277 return entry.notes ?? "";278 case "totp":279 return entry.totp ?? "";280 }281 }282283 function handleEditorFocusOut(284 field: Exclude<EditableField, null>,285 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }286 ) {287 const nextTarget = event.relatedTarget;288 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;289290 const entry = props.entry;291 if (!entry) return;292293 const currentValue = fieldValue(entry, field);294 const nextValue = field === "password" ? draft() : draft().trim();295 if (nextValue === currentValue) {296 cancelEdit();297 }298 }299300 async function savePassword(password: string) {301 const entry = props.entry;302 if (!entry) return;303 setSaving(true);304 try {305 await sendMessage({306 type: "UPDATE_ENTRY",307 payload: { id: entry.id, patch: { password } },308 });309 triggerRing();310 setShowGenerator(false);311 await props.onFieldSaved();312 } catch {313 314 } finally {315 setSaving(false);316 }317 }318319 320321 async function copyField(text: string, label: string, button?: HTMLButtonElement) {322 const ok = await popupCopy(text, label);323 if (ok) {324 const entry = props.entry;325 if (entry?.canary?.webhookUrl) {326 const trigger =327 label === "password"328 ? "password_copied"329 : label === "username"330 ? "username_copied"331 : undefined;332 if (trigger) {333 void sendMessage({334 type: "FIRE_CANARY_PING",335 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },336 });337 }338 }339 triggerRing();340 triggerPipPulse();341 setCopied(null);342 requestAnimationFrame(() => setCopied(label));343 if (button) {344 button.classList.remove("is-copied", "is-retreat");345 void button.offsetWidth;346 const drain = document.createElement("span");347 drain.className = "peach-copy-drain";348 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";349 drain.appendChild(bar);350 button.appendChild(drain);351 window.setTimeout(() => {352 button.classList.remove("is-copied");353 button.classList.add("is-retreat");354 drain.remove();355 window.setTimeout(() => button.classList.remove("is-retreat"), 160);356 }, 1800);357 }358 window.setTimeout(() => setCopied(null), 1800);359 }360 }361362 363364 createEffect(() => {365 if (!props.entry) return;366 const handler = (e: KeyboardEvent) => {367 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;368 if (inInput) return;369 switch (e.key) {370 case "c": e.preventDefault();371 if (props.entry?.password) void copyField(props.entry.password, "password"); break;372 case "u": e.preventDefault();373 if (props.entry?.username) void copyField(props.entry.username, "username"); break;374 case "Escape": e.preventDefault(); props.onEscape?.(); break;375 }376 };377 window.addEventListener("keydown", handler);378 onCleanup(() => window.removeEventListener("keydown", handler));379 });380381 382383 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {384 requestAnimationFrame(() => { el.focus(); el.select?.(); });385 }386387 function onEditKeyDown(e: KeyboardEvent) {388 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {389 e.preventDefault();390 void saveField();391 }392 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }393 }394395 return (396 <>397 <div398 class="peach-ghost"399 aria-hidden="true"400 style="position:absolute;top:30px;left:12px;right:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:150px;font-style:italic;font-weight:400;color:var(--t0);opacity:0.03;pointer-events:none;line-height:1;overflow:hidden;white-space:nowrap;z-index:0;"401 >402 {props.entry?.title || "empty"}403 </div>404405 <div class="peach-stage" style="position:relative;">406 <Show when={props.entry} fallback={<EmptyVaultState />}>407 {(entry) => (408 <>409 {}410 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>411 <div class="peach-breach-badge">412 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">413 <path d="M6 1L11 10H1L6 1z" />414 <line x1="6" y1="5" x2="6" y2="7.5" />415 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />416 </svg>417 <span style="flex:1">found in a known data breach</span>418 <button419 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"420 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}421 >422 Change password423 </button>424 </div>425 </Show>426427 {}428 <div class="peach-entry-header">429 <div class="peach-entry-header-name">430 <Show431 when={editingField() === "title"}432 fallback={433 <button434 class="peach-entry-name-button"435 title="Click to edit title"436 onClick={() => startEdit("title", entry().title)}437 >438 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>439 <span class="peach-entry-name-text">440 <Show441 when={useAnimatedTitle()}442 fallback={entry().title}443 >444 <For each={entry().title.split("")}>445 {(ch, i) => (446 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>447 {ch === " " ? "\u00A0" : ch}448 </span>449 )}450 </For>451 </Show>452 </span>453 </h1>454 </button>455 }456 >457 <div458 class="peach-inline-title-wrap"459 onFocusOut={(event) => handleEditorFocusOut("title", event)}460 >461 <input462 class="peach-inline-title-input"463 value={draft()}464 onInput={(e) => setDraft(e.currentTarget.value)}465 onKeyDown={onEditKeyDown}466 ref={autoFocus}467 disabled={saving()}468 />469 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />470 </div>471 </Show>472 </div>473474 {}475 <Show when={editingField() !== "title"}>476 <Show477 when={confirmDelete()}478 fallback={479 <button480 class="peach-entry-trash-btn"481 onClick={() => setConfirmDelete(true)}482 aria-label="Move to trash"483 title="Delete entry"484 >485 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">486 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />487 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />488 </svg>489 </button>490 }491 >492 <div class="peach-entry-trash-confirm">493 <span class="peach-entry-trash-confirm-label">trash?</span>494 <button495 class="peach-btn peach-btn--sm peach-btn--danger"496 style="font-size:10px;padding:2px 8px;min-height:22px;"497 onClick={() => { setConfirmDelete(false); props.onDelete(); }}498 >499 yes500 </button>501 <button502 class="peach-btn peach-btn--ghost peach-btn--sm"503 style="font-size:10px;padding:2px 8px;min-height:22px;"504 onClick={() => setConfirmDelete(false)}505 >506 no507 </button>508 </div>509 </Show>510 </Show>511 </div>512513 {}514 <Show when={entry().type !== "note" && entry().type !== "card"}>515 <div class="peach-domain" style="position:relative;z-index:1;">516 <Show517 when={editingField() === "url"}518 fallback={519 <div520 class="peach-domain-row peach-inline-clickable"521 title="Click to edit URL"522 onClick={() => startEdit("url", entry().url ?? "")}523 >524 <Show when={faviconSrc()}>525 <img526 src={faviconSrc()!}527 width="16"528 height="16"529 alt=""530 style="border-radius:3px;margin-right:5px;flex-shrink:0;"531 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}532 />533 </Show>534 <span class="peach-domain-value">535 {displayDomain() || (entry().url ? entry().url : "add a website url")}536 </span>537 <span class="peach-inline-edit-hint">edit</span>538 </div>539 }540 >541 <div542 class="peach-inline-field-wrap peach-inline-field-wrap--url"543 onFocusOut={(event) => handleEditorFocusOut("url", event)}544 >545 <div class="peach-inline-field-row">546 <input547 class="peach-inline-input"548 type="url"549 placeholder="https://"550 value={draft()}551 onInput={(e) => setDraft(e.currentTarget.value)}552 onKeyDown={onEditKeyDown}553 ref={autoFocus}554 disabled={saving()}555 />556 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />557 </div>558 <div class="peach-additional-urls">559 <Show when={additionalUrlsDraft().length > 0}>560 <span class="peach-label peach-additional-urls-label">also matches</span>561 </Show>562 <For each={additionalUrlsDraft()}>563 {(value, i) => (564 <div class="peach-additional-url-row">565 <input566 class="peach-inline-input"567 type="url"568 placeholder="https://"569 value={value}570 onInput={(e) => setAdditionalUrlsDraft((arr) => {571 const next = [...arr];572 next[i()] = e.currentTarget.value;573 return next;574 })}575 disabled={saving()}576 />577 <button578 class="peach-additional-url-remove"579 type="button"580 aria-label="Remove URL"581 onMouseDown={(e) => e.preventDefault()}582 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}583 disabled={saving()}584 >×</button>585 </div>586 )}587 </For>588 <button589 class="peach-add-field-link peach-additional-urls-add"590 type="button"591 onMouseDown={(e) => e.preventDefault()}592 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}593 disabled={saving()}594 >595 + add another url596 </button>597 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>598 <button599 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"600 type="button"601 title={props.currentTabUrl}602 onMouseDown={(e) => e.preventDefault()}603 onClick={() => addCurrentSite(props.currentTabUrl!)}604 disabled={saving()}605 >606 + add current site607 </button>608 </Show>609 </div>610 </div>611 </Show>612 </div>613 </Show>614615 {}616 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>617 <div618 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"619 onClick={startEditTags}620 title="Click to edit tags"621 >622 <For each={entry().tags!}>623 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}624 </For>625 </div>626 </Show>627628 {}629 <Show when={entry().type === "note"}>630 <div class="peach-entry-note-toolbar-row">631 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />632 </div>633 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">634 <NoteEditorInline635 entryKey={entry().id}636 initialContent={entry().notes ?? ""}637 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}638 onBlur={saveNoteInline}639 autoFocus={false}640 />641 </section>642 </Show>643644 {}645 <Show when={entry().type === "card"}>646 <section class="peach-fields" style="position:relative;z-index:1;">647 {}648 <Show when={entry().last4}>649 <div class="peach-field-card" style="padding-bottom:4px;">650 <span class="peach-label">651 <Show when={entry().network && entry().network !== "unknown"}>652 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>653 </Show>654 {entry().cardNickname || `•••• ${entry().last4}`}655 </span>656 </div>657 </Show>658659 {}660 <div class="peach-field-card">661 <span class="peach-label">cardholder name</span>662 <div class="peach-field-value-row">663 <span class="peach-field-value">{entry().cardholderName || "—"}</span>664 <Show when={entry().cardholderName}>665 <button666 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}667 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}668 aria-label="Copy cardholder name"669 >670 <CopyGlyph /><span class="peach-copy-sheen" />671 </button>672 </Show>673 </div>674 </div>675676 {}677 <div class="peach-field-card">678 <span class="peach-label">card number</span>679 <div class="peach-field-value-row">680 <span class={`peach-field-value peach-card-number`}>681 <Show when={showCardNumber()} fallback={682 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>683 }>684 <span>{entry().cardNumber ?? ""}</span>685 </Show>686 </span>687 <div class="peach-field-actions">688 <button689 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"690 onClick={() => setShowCardNumber(!showCardNumber())}691 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}692 >693 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>694 <EyeClosedGlyph />695 </Show>696 </button>697 <Show when={entry().cardNumber}>698 <button699 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}700 onClick={(e) => {701 const cardNum = entry().cardNumber!;702 if (entry().canary?.webhookUrl) {703 void sendMessage({704 type: "FIRE_CANARY_PING",705 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },706 });707 }708 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);709 }}710 aria-label="Copy card number"711 >712 <CopyGlyph /><span class="peach-copy-sheen" />713 </button>714 </Show>715 </div>716 </div>717 </div>718719 {}720 <Show when={entry().expMonth || entry().expYear}>721 <div class="peach-field-card">722 <span class="peach-label">expiry</span>723 <div class="peach-field-value-row">724 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>725 <button726 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}727 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}728 aria-label="Copy expiry"729 >730 <CopyGlyph /><span class="peach-copy-sheen" />731 </button>732 </div>733 </div>734 </Show>735736 {}737 <Show when={entry().cvc}>738 <div class="peach-field-card">739 <span class="peach-label">CVC</span>740 <div class="peach-field-value-row">741 <span class="peach-field-value">742 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>743 <span>{entry().cvc}</span>744 </Show>745 </span>746 <div class="peach-field-actions">747 <button748 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"749 onClick={() => setShowCardCvc(!showCardCvc())}750 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}751 >752 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>753 <EyeClosedGlyph />754 </Show>755 </button>756 <button757 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}758 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}759 aria-label="Copy CVC"760 >761 <CopyGlyph /><span class="peach-copy-sheen" />762 </button>763 </div>764 </div>765 </div>766 </Show>767 </section>768 </Show>769770 {}771 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>772 <section class="peach-fields" style="position:relative;z-index:1;">773774 {}775 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>776 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>777 <span class="peach-label">{entry().username?.includes("@") ? "email" : "username"}</span>778 <Show779 when={editingField() === "username"}780 fallback={781 <div class="peach-field-value-row">782 <span783 class="peach-field-value peach-inline-clickable"784 title="Click to edit"785 onClick={() => startEdit("username", entry().username ?? "")}786 >787 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}788 </span>789 <Show when={entry().username}>790 <button791 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}792 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}793 aria-label="Copy username"794 >795 <CopyGlyph /><span class="peach-copy-sheen" />796 </button>797 </Show>798 </div>799 }800 >801 <div802 class="peach-inline-field-wrap"803 onFocusOut={(event) => handleEditorFocusOut("username", event)}804 >805 <input806 class="peach-inline-input"807 value={draft()}808 onInput={(e) => setDraft(e.currentTarget.value)}809 onKeyDown={onEditKeyDown}810 ref={autoFocus}811 disabled={saving()}812 autocomplete="off"813 />814 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />815 <Show when={!entry().email}>816 <button817 class="peach-add-field-btn"818 type="button"819 onMouseDown={(e) => e.preventDefault()}820 onClick={() => startEdit("email", "")}821 >822 + add email823 </button>824 </Show>825 </div>826 </Show>827 </div>828 </Show>829830 {}831 <Show when={entry().email || editingField() === "email"}>832 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>833 <span class="peach-label">email</span>834 <Show835 when={editingField() === "email"}836 fallback={837 <div class="peach-field-value-row">838 <span839 class="peach-field-value peach-inline-clickable"840 title="Click to edit"841 onClick={() => startEdit("email", entry().email ?? "")}842 >843 {entry().email}844 </span>845 <button846 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}847 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}848 aria-label="Copy email"849 >850 <CopyGlyph /><span class="peach-copy-sheen" />851 </button>852 </div>853 }854 >855 <div856 class="peach-inline-field-wrap"857 onFocusOut={(event) => handleEditorFocusOut("email", event)}858 >859 <input860 class="peach-inline-input"861 value={draft()}862 onInput={(e) => setDraft(e.currentTarget.value)}863 onKeyDown={onEditKeyDown}864 ref={autoFocus}865 disabled={saving()}866 autocomplete="email"867 />868 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />869 <Show when={!entry().username}>870 <button871 class="peach-add-field-btn"872 type="button"873 onMouseDown={(e) => e.preventDefault()}874 onClick={() => startEdit("username", "")}875 >876 + add username877 </button>878 </Show>879 </div>880 </Show>881 </div>882 </Show>883884 {}885 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>886 <span class="peach-label">password</span>887 <Show888 when={editingField() === "password"}889 fallback={890 <div class="peach-field-value-row">891 <span892 class="peach-password-value"893 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}894 style="cursor:pointer;"895 title="Click to edit password"896 onClick={() => startEdit("password", entry().password ?? "")}897 >898 <Show899 when={showPassword()}900 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}901 >902 <span class="peach-password-text">{entry().password ?? ""}</span>903 </Show>904 </span>905 <div class="peach-field-actions">906 <button907 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"908 onClick={() => setShowPassword(!showPassword())}909 aria-label="Reveal password"910 >911 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>912 <EyeClosedGlyph />913 </Show>914 </button>915 <button916 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}917 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}918 aria-label="Copy password"919 >920 <CopyGlyph /><span class="peach-copy-sheen" />921 </button>922 </div>923 </div>924 }925 >926 {}927 <div928 class="peach-inline-field-wrap"929 onFocusOut={(event) => handleEditorFocusOut("password", event)}930 >931 <div class="peach-password-edit-row">932 <input933 class="peach-inline-input"934 type={showPassword() ? "text" : "password"}935 placeholder="paste or type a password"936 value={draft()}937 onInput={(e) => setDraft(e.currentTarget.value)}938 onKeyDown={(e) => {939 if (e.key === "Enter") { e.preventDefault(); void saveField(); }940 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }941 }}942 ref={autoFocus}943 disabled={saving()}944 autocomplete="new-password"945 />946 <button947 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"948 type="button"949 onClick={() => setShowPassword(!showPassword())}950 aria-label={showPassword() ? "Hide" : "Show"}951 >952 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>953 <EyeClosedGlyph />954 </Show>955 </button>956 </div>957 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />958 <button959 class="peach-generate-toggle-link"960 type="button"961 onClick={() => setShowGenerator(!showGenerator())}962 >963 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">964 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />965 <path d="M9.5 2.5H11V1" />966 </svg>967 {showGenerator() ? "hide generator" : "generate"}968 </button>969 <Show when={showGenerator()}>970 <GeneratorDrawer971 open={true}972 compact={true}973 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}974 />975 </Show>976 </div>977 </Show>978 </div>979980 </section>981 </Show>982983 {}984 <Show when={entry().type !== "note" && entry().type !== "card"}>985 <Show986 when={entry().notes || editingField() === "notes"}987 fallback={988 <button989 class="peach-add-field-link peach-entry-add-link"990 onClick={() => startEdit("notes", "")}991 >992 + notes993 </button>994 }995 >996 <div style="position:relative;z-index:1;margin-top:6px;">997 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>998 <Show999 when={editingField() === "notes"}1000 fallback={1001 <div1002 class="peach-note-content peach-inline-clickable"1003 style="cursor:text;white-space:pre-wrap;"1004 onClick={() => startEdit("notes", entry().notes ?? "")}1005 >1006 {entry().notes}1007 </div>1008 }1009 >1010 <div1011 class="peach-inline-field-wrap"1012 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1013 >1014 <textarea1015 class="peach-inline-textarea"1016 value={draft()}1017 onInput={(e) => setDraft(e.currentTarget.value)}1018 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1019 ref={(el) => requestAnimationFrame(() => el?.focus())}1020 rows={4}1021 disabled={saving()}1022 />1023 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1024 </div>1025 </Show>1026 </div>1027 </Show>1028 </Show>10291030 {}1031 <Show1032 when={editingTags()}1033 fallback={1034 <Show when={!entry().tags || entry().tags!.length === 0}>1035 <button1036 class="peach-add-field-link peach-entry-add-link"1037 onClick={startEditTags}1038 >1039 + tags1040 </button>1041 </Show>1042 }1043 >1044 <div1045 class="peach-inline-tag-editor"1046 onFocusOut={(e) => {1047 const next = e.relatedTarget;1048 if (next instanceof Node && e.currentTarget.contains(next)) return;1049 void saveTags();1050 }}1051 >1052 <span class="peach-label">tags</span>1053 <div class="peach-tag-input-wrap">1054 <For each={tagsDraft()}>1055 {(tag) => (1056 <span class="peach-tag-chip">1057 {tag}1058 <button1059 class="peach-tag-chip-remove"1060 onMouseDown={(e) => e.preventDefault()}1061 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1062 aria-label={`Remove ${tag}`}1063 >×</button>1064 </span>1065 )}1066 </For>1067 <input1068 class="peach-tag-input"1069 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1070 value={tagInput()}1071 onInput={(e) => setTagInput(e.currentTarget.value)}1072 onKeyDown={(e) => {1073 if (e.key === "Enter" || e.key === ",") {1074 e.preventDefault();1075 addTag(tagInput());1076 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1077 setTagsDraft(tagsDraft().slice(0, -1));1078 } else if (e.key === "Escape") {1079 e.preventDefault();1080 setEditingTags(false);1081 setTagInput("");1082 }1083 }}1084 ref={(el) => requestAnimationFrame(() => el?.focus())}1085 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1086 />1087 </div>1088 </div>1089 </Show>10901091 {}1092 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1093 <Show1094 when={totpState() || editingField() === "totp"}1095 fallback={1096 <button1097 class="peach-add-field-link peach-entry-add-link"1098 onClick={() => startEdit("totp", "")}1099 >1100 + 2FA1101 </button>1102 }1103 >1104 <section class="peach-totp-band">1105 <Show1106 when={editingField() === "totp"}1107 fallback={1108 <Show when={totpState()}>1109 {(state) => (1110 <>1111 <div1112 class="peach-totp-main"1113 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1114 onDblClick={() => startEdit("totp", entry().totp ?? "")}1115 title="Click to copy · double-click to edit"1116 >1117 <span class="peach-label">2-factor</span>1118 <div class="peach-totp-code-row">1119 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1120 <span class="peach-totp-code-text">1121 {state().code.slice(0, 3)} {state().code.slice(3)}1122 </span>1123 </p>1124 </div>1125 </div>1126 <div class="peach-totp-meta">1127 <span class="peach-label">expires</span>1128 <div class="peach-totp-countdown">{state().remaining}s</div>1129 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1130 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1131 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1132 stroke-dasharray="106.8"1133 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1134 />1135 </svg>1136 </div>1137 </>1138 )}1139 </Show>1140 }1141 >1142 <div1143 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1144 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1145 >1146 <span class="peach-label">2-factor</span>1147 <input1148 class="peach-inline-input"1149 value={draft()}1150 onInput={(e) => setDraft(e.currentTarget.value)}1151 onKeyDown={onEditKeyDown}1152 ref={autoFocus}1153 disabled={saving()}1154 placeholder="otpauth://... or base32 secret"1155 autocomplete="off"1156 />1157 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1158 </div>1159 </Show>1160 </section>1161 </Show>1162 </Show>11631164 {}1165 <Show when={entry().type === "login"}>1166 <button1167 class="peach-add-field-link peach-entry-add-link"1168 onClick={() => setShowAdvanced((v) => !v)}1169 aria-expanded={showAdvanced()}1170 >1171 {showAdvanced() ? "− Advanced" : "+ Advanced"}1172 </button>1173 <Show when={showAdvanced()}>1174 <Show when={!isPasskeyOnly(entry())}>1175 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1176 </Show>11771178 <Show when={entry().passkey}>1179 {(passkey) => {1180 const currentBrowser = detectBrowser();1181 const crossBrowser =1182 passkey().browser &&1183 currentBrowser !== "unknown" &&1184 passkey().browser !== currentBrowser;1185 const algorithmLabel =1186 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1187 const formatDate = (ts?: number) =>1188 ts ? new Date(ts).toLocaleDateString() : "Never";1189 return (1190 <section1191 class="peach-fields peach-passkey-section"1192 style="position:relative;z-index:1;margin-top:6px;"1193 >1194 <div class="peach-field-card">1195 <span class="peach-label">passkey · {passkey().rpId}</span>1196 <div class="peach-field-value-row">1197 <span class="peach-field-value">{passkey().userName}</span>1198 <span class="peach-passkey-badge">passkey</span>1199 </div>1200 </div>12011202 <div class="peach-field-card">1203 <span class="peach-label">algorithm</span>1204 <div class="peach-field-value-row">1205 <span class="peach-field-value">{algorithmLabel}</span>1206 </div>1207 </div>12081209 <div class="peach-field-card">1210 <span class="peach-label">activity</span>1211 <div class="peach-field-value-row peach-passkey-activity">1212 <span class="peach-field-value">1213 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1214 </span>1215 <span class="peach-passkey-meta">1216 created {formatDate(passkey().createdAt)}1217 </span>1218 <span class="peach-passkey-meta">1219 last used {formatDate(passkey().lastUsedAt)}1220 </span>1221 </div>1222 </div>12231224 <Show when={crossBrowser}>1225 <div class="peach-info-notice peach-info-notice--warn">1226 {`This passkey was created on ${1227 passkey().browser === "chrome" ? "Chrome" : "Firefox"1228 }. To use it on ${1229 currentBrowser === "chrome" ? "Chrome" : "Firefox"1230 }, re-register it on this device.`}1231 </div>1232 </Show>12331234 <div class="peach-info-notice">1235 {passkey().browser === "firefox"1236 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1237 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1238 </div>12391240 <button1241 class="peach-add-field-link peach-entry-add-link"1242 style="color:rgba(220,38,38,0.7);"1243 onClick={async () => {1244 const id = entry().id;1245 await sendMessage({1246 type: "UPDATE_ENTRY",1247 payload: { id, patch: { passkey: undefined } },1248 });1249 await props.onFieldSaved();1250 }}1251 >1252 − remove passkey1253 </button>1254 </section>1255 );1256 }}1257 </Show>1258 </Show>1259 </Show>12601261 {}1262 <div style="height:4px;" />1263 </>1264 )}1265 </Show>1266 </div>12671268 </>1269 );1270}1271127212731274function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1275 return (1276 <div class="peach-inline-actions">1277 <button1278 class="peach-inline-save-btn"1279 onClick={props.onSave}1280 disabled={props.saving}1281 aria-label="Save"1282 title="Save (Enter)"1283 >1284 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1285 <path d="M1 4.5L4 7.5L10 1.5" />1286 </svg>1287 </button>1288 <button1289 class="peach-inline-cancel-btn"1290 onClick={props.onCancel}1291 disabled={props.saving}1292 aria-label="Cancel"1293 title="Cancel (Esc)"1294 >1295 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1296 <path d="M1 1L8 8M8 1L1 8" />1297 </svg>1298 </button>1299 </div>1300 );1301}13021303function EmptyVaultState() {1304 return (1305 <>1306 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1307 <div class="peach-entry-name">1308 <span class="peach-entry-name-text">1309 <span class="peach-entry-name-char" style="--char-index:0">e</span>1310 <span class="peach-entry-name-char" style="--char-index:1">m</span>1311 <span class="peach-entry-name-char" style="--char-index:2">p</span>1312 <span class="peach-entry-name-char" style="--char-index:3">t</span>1313 <span class="peach-entry-name-char" style="--char-index:4">y</span>1314 </span>1315 </div>1316 </div>1317 <div class="peach-empty-copy">1318 <span class="peach-label">vault</span>1319 <p>Your vault is ready for its first entry.</p>1320 </div>1321 </>1322 );1323}13241325function CopyGlyph() {1326 return (1327 <span class="peach-copy-icon">1328 <svg class="peach-copy-icon-svg" width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1329 <rect x="4" y="4" width="8" height="9" rx="1.5" />1330 <path d="M10 4V2.5A1.5 1.5 0 0 0 8.5 1h-6A1.5 1.5 0 0 0 1 2.5v7A1.5 1.5 0 0 0 2.5 11H4" />1331 </svg>1332 </span>1333 );1334}13351336function EyeClosedGlyph() {1337 return (1338 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1339 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1340 <path d="M3.5 5.5L2.5 7.5" />1341 <path d="M7 6.5V8.5" />1342 <path d="M10.5 5.5L11.5 7.5" />1343 </svg>1344 );1345}13461347function EyeOpenGlyph() {1348 return (1349 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1350 <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" />1351 <circle cx="7" cy="5" r="1.75" />1352 </svg>1353 );1354}13551356function ChevronGlyph(props: { open: boolean }) {1357 return (1358 <svg1359 width="8"1360 height="8"1361 viewBox="0 0 8 8"1362 fill="none"1363 stroke="currentColor"1364 stroke-width="1.3"1365 stroke-linecap="round"1366 stroke-linejoin="round"1367 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1368 >1369 <path d="M2 1L6 4L2 7" />1370 </svg>1371 );1372}13731374function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1375 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1376 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1377 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1378 const [testResult, setTestResult] = createSignal<string | null>(null);1379 const [testing, setTesting] = createSignal(false);13801381 async function save() {1382 const url = webhookUrl().trim();1383 const nextLabel = label().trim() || undefined;1384 const canary = enabled() && url1385 ? {1386 webhookUrl: url,1387 label: nextLabel,1388 lastPinged: props.entry.canary?.lastPinged,1389 pingCount: props.entry.canary?.pingCount,1390 }1391 : undefined;1392 await sendMessage({1393 type: "UPDATE_ENTRY",1394 payload: { id: props.entry.id, patch: { canary } },1395 });1396 await props.onSaved();1397 }13981399 async function testPing() {1400 const url = webhookUrl().trim();1401 if (!url) return;1402 setTesting(true);1403 setTestResult(null);1404 const res = await sendMessage({1405 type: "FIRE_CANARY_PING",1406 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1407 }) as { ok: boolean; error?: string };1408 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1409 setTesting(false);1410 }14111412 return (1413 <div class="peach-canary-section">1414 <div class="peach-field-value-row">1415 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1416 <button1417 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1418 onClick={() => { setEnabled(!enabled()); void save(); }}1419 aria-label="Toggle canary"1420 >1421 <span class="peach-toggle-knob" />1422 </button>1423 </div>1424 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1425 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1426 </p>1427 <Show when={enabled()}>1428 <div class="peach-inline-field-wrap" style="margin-top:8px;">1429 <input1430 class="peach-inline-input"1431 placeholder="https://hooks.zapier.com/..."1432 value={webhookUrl()}1433 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1434 onBlur={() => void save()}1435 />1436 <input1437 class="peach-inline-input"1438 placeholder="label (optional, e.g. AWS decoy)"1439 value={label()}1440 onInput={(e) => setLabel(e.currentTarget.value)}1441 onBlur={() => void save()}1442 />1443 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1444 <button1445 class="peach-btn peach-btn--ghost peach-btn--sm"1446 onClick={() => void testPing()}1447 disabled={testing() || !webhookUrl().trim()}1448 >1449 {testing() ? "sending..." : "test ping"}1450 </button>1451 <Show when={testResult()}>1452 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1453 </Show>1454 </div>1455 <Show when={props.entry.canary?.lastPinged}>1456 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1457 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1458 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1459 </p>1460 </Show>1461 </div>1462 </Show>1463 </div>1464 );1465}