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";12import { IdentitySuggestList } from "./Add";1314type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1516interface EntryProps {17 entry: VaultEntry | null;18 1920212223 currentTabUrl?: string;24 onDelete: () => void;25 26 onFieldSaved: () => void | Promise<void>;27 onEscape?: () => void;28 entries?: VaultEntry[];29}3031export function EntryView(props: EntryProps) {32 const [editingField, setEditingField] = createSignal<EditableField>(null);33 const [draft, setDraft] = createSignal("");34 const [saving, setSaving] = createSignal(false);35 const [showPassword, setShowPassword] = createSignal(false);36 const [showGenerator, setShowGenerator] = createSignal(false);37 const [copied, setCopied] = createSignal<string | null>(null);38 39 40 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);41 const [editingTags, setEditingTags] = createSignal(false);42 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);43 const [tagInput, setTagInput] = createSignal("");44 const [showCardNumber, setShowCardNumber] = createSignal(false);45 const [showCardCvc, setShowCardCvc] = createSignal(false);46 const [totpTick, setTotpTick] = createSignal(0);47 const [confirmDelete, setConfirmDelete] = createSignal(false);48 const [totpHot, setTotpHot] = createSignal(false);49 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);50 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);51 52 const [showAdvanced, setShowAdvanced] = createSignal(false);53 const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);54 let usernameInlineInput: HTMLInputElement | undefined;55 let emailInlineInput: HTMLInputElement | undefined;56 let usernameIdentityEl: HTMLDivElement | undefined;57 let emailIdentityEl: HTMLDivElement | undefined;58 let lastSavedNote: string | null = null;59 let totpClickTimer: ReturnType<typeof setTimeout> | null = null;60 onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });6162 function noteExec(cmd: string, val?: string) {63 document.execCommand(cmd, false, val);64 noteEditorEl()?.focus();65 }6667 async function saveNoteInline(html: string) {68 const entry = props.entry;69 if (!entry) return;70 const currentStored = entry.notes ?? "";71 if (html === currentStored) return;72 if (lastSavedNote !== null && html === lastSavedNote) return;73 lastSavedNote = html;74 setSaving(true);75 try {76 await sendMessage({77 type: "UPDATE_ENTRY",78 payload: { id: entry.id, patch: { notes: html } },79 });80 triggerRing();81 await props.onFieldSaved();82 } catch {83 lastSavedNote = null;84 } finally {85 setSaving(false);86 }87 }8889 const allIdentities = createMemo(() => {90 const emailMap = new Map<string, number>();91 const usernameMap = new Map<string, number>();92 for (const e of props.entries ?? []) {93 const uname = e.username?.trim() ?? "";94 const em = e.email?.trim() ?? "";95 if (uname) usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);96 if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);97 }98 const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];99 const seen = new Set<string>();100 for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {101 if (seen.has(v)) continue;102 seen.add(v);103 const ec = emailMap.get(v) ?? 0;104 const uc = usernameMap.get(v) ?? 0;105 result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });106 }107 return result.sort((a, b) => b.count - a.count);108 });109110 const identitySuggestions = createMemo(() => {111 const field = editingField();112 if (field !== "username" && field !== "email") return [];113 const input = draft().toLowerCase();114 return allIdentities()115 .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))116 .slice(0, 8);117 });118119 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);120 onCleanup(() => window.clearInterval(interval));121122 const displayDomain = createMemo(() =>123 props.entry?.url ? getDisplayDomain(props.entry.url) : ""124 );125126 createEffect(() => {127 const domain = displayDomain();128 setFaviconSrc(domain ? faviconUrl(domain) : null);129 });130131 132 133 134 135 136 let lastEntryId: string | null = null;137 createEffect(() => {138 const id = props.entry?.id ?? null;139 if (id === lastEntryId) return;140 lastEntryId = id;141 setConfirmDelete(false);142 setEditingField(null);143 setEditingTags(false);144 setShowAdvanced(false);145 });146147 const totpState = createMemo(() => {148 void totpTick();149 if (!props.entry?.totp) return null;150 return generateTOTP(props.entry.totp);151 });152153 let prevTotpCode: string | undefined;154 createEffect(() => {155 const state = totpState();156 if (!state) { prevTotpCode = undefined; return; }157 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {158 setTotpHot(true);159 window.setTimeout(() => setTotpHot(false), 3000);160 }161 prevTotpCode = state.code;162 });163164 const totpProgress = createMemo(() => {165 const state = totpState();166 if (!state) return 0;167 return ((state.remaining - 1) / 29) * 100;168 });169170 createEffect(() => {171 const shell = document.getElementById("hd-shell");172 if (!shell) return;173174 const state = totpState();175 if (!state) {176 delete shell.dataset.totpActive;177 shell.style.removeProperty("--peach-totp-progress");178 return;179 }180181 shell.dataset.totpActive = "true";182 shell.style.setProperty(183 "--peach-totp-progress",184 `${Math.max(0, totpProgress())}%`185 );186187 onCleanup(() => {188 delete shell.dataset.totpActive;189 shell.style.removeProperty("--peach-totp-progress");190 });191 });192193 const titleSizeClass = createMemo(() => {194 const length = props.entry?.title.length ?? 0;195 if (length >= 28) return "peach-entry-name--xxl";196 if (length >= 20) return "peach-entry-name--xl";197 if (length >= 14) return "peach-entry-name--lg";198 return "";199 });200 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);201202 203204 function startEdit(field: EditableField, value: string) {205 setEditingField(field);206 setDraft(value);207 setIdentityHelperSuppressed(false);208 setShowGenerator(false);209 setConfirmDelete(false);210 if (field === "url") {211 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);212 }213 }214215 216 217 218 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {219 const c = candidate.trim();220 if (!c) return true;221 if (primary.trim() === c) return true;222 return others.some((u) => u.trim() === c);223 }224225 function addCurrentSite(url: string) {226 if (!url) return;227 if (!draft().trim()) {228 setDraft(url);229 return;230 }231 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;232 setAdditionalUrlsDraft((arr) => [...arr, url]);233 }234235 async function saveField() {236 const field = editingField();237 const entry = props.entry;238 if (!field || !entry) return;239 240 let value = field === "password" ? draft() : draft().trim();241 if (field === "totp" && value.startsWith("otpauth://")) {242 const parsed = parseOtpauthURI(value);243 value = parsed?.secret ?? value;244 }245 if (field === "password" && entry.type !== "note" && !value) {246 return;247 }248 setSaving(true);249 try {250 const patch: Partial<VaultEntry> = { [field]: value };251 if (field === "url") {252 253 patch.additionalUrls = additionalUrlsDraft();254 }255 await sendMessage({256 type: "UPDATE_ENTRY",257 payload: { id: entry.id, patch },258 });259 triggerRing();260 setEditingField(null);261 setShowGenerator(false);262 await props.onFieldSaved();263 } catch {264 265 } finally {266 setSaving(false);267 }268 }269270 function startEditTags() {271 setTagsDraft([...(props.entry?.tags ?? [])]);272 setTagInput("");273 setEditingTags(true);274 }275276 function addTag(value: string) {277 const n = value.toLowerCase().trim();278 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;279 setTagsDraft([...tagsDraft(), n]);280 setTagInput("");281 }282283 async function saveTags() {284 if (tagInput()) addTag(tagInput());285 const entry = props.entry;286 if (!entry) return;287 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;288 setEditingTags(false);289 setSaving(true);290 try {291 await sendMessage({292 type: "UPDATE_ENTRY",293 payload: { id: entry.id, patch: { tags: next } },294 });295 triggerRing();296 await props.onFieldSaved();297 } catch {298 299 } finally {300 setSaving(false);301 }302 }303304 function cancelEdit() {305 setEditingField(null);306 setDraft("");307 setShowGenerator(false);308 }309310 311 312 313 314 315 316 317 318 319 async function addOtherIdentity(target: "username" | "email") {320 const entry = props.entry;321 if (!entry) return;322 const current = editingField();323 const draftValue = draft().trim();324 const savedUsername = entry.username ?? "";325 const savedEmail = entry.email ?? "";326327 if (current === "username" && target === "username") {328 329 330 if (savedUsername.includes("@") && !entry.email) {331 setSaving(true);332 try {333 await sendMessage({334 type: "UPDATE_ENTRY",335 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },336 });337 await props.onFieldSaved();338 } finally {339 setSaving(false);340 }341 }342 startEdit("username", "");343 return;344 }345346 if (current === "username" && target === "email") {347 if (draftValue && draftValue !== savedUsername) {348 setSaving(true);349 try {350 await sendMessage({351 type: "UPDATE_ENTRY",352 payload: { id: entry.id, patch: { username: draftValue } },353 });354 await props.onFieldSaved();355 } finally {356 setSaving(false);357 }358 }359 startEdit("email", "");360 return;361 }362363 if (current === "email" && target === "username") {364 if (draftValue && draftValue !== savedEmail) {365 setSaving(true);366 try {367 await sendMessage({368 type: "UPDATE_ENTRY",369 payload: { id: entry.id, patch: { email: draftValue } },370 });371 await props.onFieldSaved();372 } finally {373 setSaving(false);374 }375 }376 startEdit("username", "");377 return;378 }379 }380381 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {382 switch (field) {383 case "title":384 return entry.title;385 case "url":386 return entry.url ?? "";387 case "username":388 return entry.username ?? "";389 case "email":390 return entry.email ?? "";391 case "password":392 return entry.password ?? "";393 case "notes":394 return entry.notes ?? "";395 case "totp":396 return entry.totp ?? "";397 }398 }399400 function handleEditorFocusOut(401 field: Exclude<EditableField, null>,402 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }403 ) {404 const nextTarget = event.relatedTarget;405 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;406407 408 409 410 if (editingField() !== field) return;411412 const entry = props.entry;413 if (!entry) return;414415 const currentValue = fieldValue(entry, field);416 const nextValue = field === "password" ? draft() : draft().trim();417 if (nextValue === currentValue) {418 cancelEdit();419 }420 }421422 async function savePassword(password: string) {423 const entry = props.entry;424 if (!entry) return;425 setSaving(true);426 try {427 await sendMessage({428 type: "UPDATE_ENTRY",429 payload: { id: entry.id, patch: { password } },430 });431 triggerRing();432 setShowGenerator(false);433 await props.onFieldSaved();434 } catch {435 436 } finally {437 setSaving(false);438 }439 }440441 442443 async function copyField(text: string, label: string, button?: HTMLButtonElement) {444 const ok = await popupCopy(text, label);445 if (ok) {446 const entry = props.entry;447 if (entry?.canary?.webhookUrl) {448 const trigger =449 label === "password"450 ? "password_copied"451 : label === "username"452 ? "username_copied"453 : undefined;454 if (trigger) {455 void sendMessage({456 type: "FIRE_CANARY_PING",457 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },458 });459 }460 }461 triggerRing();462 triggerPipPulse();463 setCopied(null);464 requestAnimationFrame(() => setCopied(label));465 if (button) {466 button.classList.remove("is-copied", "is-retreat");467 void button.offsetWidth;468 const drain = document.createElement("span");469 drain.className = "peach-copy-drain";470 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";471 drain.appendChild(bar);472 button.appendChild(drain);473 window.setTimeout(() => {474 button.classList.remove("is-copied");475 button.classList.add("is-retreat");476 drain.remove();477 window.setTimeout(() => button.classList.remove("is-retreat"), 160);478 }, 1800);479 }480 window.setTimeout(() => setCopied(null), 1800);481 }482 }483484 485486 createEffect(() => {487 if (!props.entry) return;488 const handler = (e: KeyboardEvent) => {489 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;490 if (inInput) return;491 switch (e.key) {492 case "c": e.preventDefault();493 if (props.entry?.password) void copyField(props.entry.password, "password"); break;494 case "u": e.preventDefault();495 if (props.entry?.username) void copyField(props.entry.username, "username"); break;496 case "Escape": e.preventDefault(); props.onEscape?.(); break;497 }498 };499 window.addEventListener("keydown", handler);500 onCleanup(() => window.removeEventListener("keydown", handler));501 });502503 504505 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {506 requestAnimationFrame(() => { el.focus(); el.select?.(); });507 }508509 function onEditKeyDown(e: KeyboardEvent) {510 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {511 e.preventDefault();512 void saveField();513 }514 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }515 }516517 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {518 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;519 if (e.key === "ArrowDown" && listEl) {520 e.preventDefault();521 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();522 return;523 }524 onEditKeyDown(e);525 }526527 return (528 <>529 <div530 class="peach-ghost"531 aria-hidden="true"532 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;"533 >534 {props.entry?.title || "empty"}535 </div>536537 <div class="peach-stage" style="position:relative;">538 <Show when={props.entry} fallback={<EmptyVaultState />}>539 {(entry) => (540 <>541 {}542 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>543 <div class="peach-breach-badge">544 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">545 <path d="M6 1L11 10H1L6 1z" />546 <line x1="6" y1="5" x2="6" y2="7.5" />547 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />548 </svg>549 <span style="flex:1">found in a known data breach</span>550 <button551 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"552 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}553 >554 Change password555 </button>556 </div>557 </Show>558559 {}560 <div class="peach-entry-header">561 <div class="peach-entry-header-name">562 <Show563 when={editingField() === "title"}564 fallback={565 <button566 class="peach-entry-name-button"567 title="Click to edit title"568 onClick={() => startEdit("title", entry().title)}569 >570 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>571 <span class="peach-entry-name-text">572 <Show573 when={useAnimatedTitle()}574 fallback={entry().title}575 >576 <For each={entry().title.split("")}>577 {(ch, i) => (578 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>579 {ch === " " ? "\u00A0" : ch}580 </span>581 )}582 </For>583 </Show>584 </span>585 </h1>586 </button>587 }588 >589 <div590 class="peach-inline-title-wrap"591 onFocusOut={(event) => handleEditorFocusOut("title", event)}592 >593 <input594 class="peach-inline-title-input"595 value={draft()}596 onInput={(e) => setDraft(e.currentTarget.value)}597 onKeyDown={onEditKeyDown}598 ref={autoFocus}599 disabled={saving()}600 />601 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />602 </div>603 </Show>604 </div>605606 {}607 <Show when={editingField() !== "title"}>608 <Show609 when={confirmDelete()}610 fallback={611 <button612 class="peach-entry-trash-btn"613 onClick={() => setConfirmDelete(true)}614 aria-label="Move to trash"615 title="Delete entry"616 >617 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">618 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />619 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />620 </svg>621 </button>622 }623 >624 <div class="peach-entry-trash-confirm">625 <span class="peach-entry-trash-confirm-label">trash?</span>626 <button627 class="peach-btn peach-btn--sm peach-btn--danger"628 style="font-size:10px;padding:2px 8px;min-height:22px;"629 onClick={() => { setConfirmDelete(false); props.onDelete(); }}630 >631 yes632 </button>633 <button634 class="peach-btn peach-btn--ghost peach-btn--sm"635 style="font-size:10px;padding:2px 8px;min-height:22px;"636 onClick={() => setConfirmDelete(false)}637 >638 no639 </button>640 </div>641 </Show>642 </Show>643 </div>644645 {}646 <Show when={entry().type !== "note" && entry().type !== "card"}>647 <div class="peach-domain" style="position:relative;z-index:1;">648 <Show649 when={editingField() === "url"}650 fallback={651 <div652 class="peach-domain-row peach-inline-clickable"653 title="Click to edit URL"654 onClick={() => startEdit("url", entry().url ?? "")}655 >656 <Show when={faviconSrc()}>657 <img658 src={faviconSrc()!}659 width="16"660 height="16"661 alt=""662 style="border-radius:3px;margin-right:5px;flex-shrink:0;"663 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}664 />665 </Show>666 <span class="peach-domain-value">667 {displayDomain() || (entry().url ? entry().url : "add a website url")}668 </span>669 <span class="peach-inline-edit-hint">edit</span>670 </div>671 }672 >673 <div674 class="peach-inline-field-wrap peach-inline-field-wrap--url"675 onFocusOut={(event) => handleEditorFocusOut("url", event)}676 >677 <div class="peach-inline-field-row">678 <input679 class="peach-inline-input"680 type="url"681 placeholder="https://"682 value={draft()}683 onInput={(e) => setDraft(e.currentTarget.value)}684 onKeyDown={onEditKeyDown}685 ref={autoFocus}686 disabled={saving()}687 />688 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />689 </div>690 <div class="peach-additional-urls">691 <Show when={additionalUrlsDraft().length > 0}>692 <span class="peach-label peach-additional-urls-label">also matches</span>693 </Show>694 <For each={additionalUrlsDraft()}>695 {(value, i) => (696 <div class="peach-additional-url-row">697 <input698 class="peach-inline-input"699 type="url"700 placeholder="https://"701 value={value}702 onInput={(e) => setAdditionalUrlsDraft((arr) => {703 const next = [...arr];704 next[i()] = e.currentTarget.value;705 return next;706 })}707 disabled={saving()}708 />709 <button710 class="peach-additional-url-remove"711 type="button"712 aria-label="Remove URL"713 onMouseDown={(e) => e.preventDefault()}714 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}715 disabled={saving()}716 >×</button>717 </div>718 )}719 </For>720 <button721 class="peach-add-field-link peach-additional-urls-add"722 type="button"723 onMouseDown={(e) => e.preventDefault()}724 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}725 disabled={saving()}726 >727 + add another url728 </button>729 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>730 <button731 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"732 type="button"733 title={props.currentTabUrl}734 onMouseDown={(e) => e.preventDefault()}735 onClick={() => addCurrentSite(props.currentTabUrl!)}736 disabled={saving()}737 >738 + add current site739 </button>740 </Show>741 </div>742 </div>743 </Show>744 </div>745 </Show>746747 {}748 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>749 <div750 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"751 onClick={startEditTags}752 title="Click to edit tags"753 >754 <For each={entry().tags!}>755 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}756 </For>757 </div>758 </Show>759760 {}761 <Show when={entry().type === "note"}>762 <div class="peach-entry-note-toolbar-row">763 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />764 </div>765 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">766 <NoteEditorInline767 entryKey={entry().id}768 initialContent={entry().notes ?? ""}769 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}770 onBlur={saveNoteInline}771 autoFocus={false}772 />773 </section>774 </Show>775776 {}777 <Show when={entry().type === "card"}>778 <section class="peach-fields" style="position:relative;z-index:1;">779 {}780 <Show when={entry().last4}>781 <div class="peach-field-card" style="padding-bottom:4px;">782 <span class="peach-label">783 <Show when={entry().network && entry().network !== "unknown"}>784 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>785 </Show>786 {entry().cardNickname || `•••• ${entry().last4}`}787 </span>788 </div>789 </Show>790791 {}792 <div class="peach-field-card">793 <span class="peach-label">cardholder name</span>794 <div class="peach-field-value-row">795 <span class="peach-field-value">{entry().cardholderName || "—"}</span>796 <Show when={entry().cardholderName}>797 <button798 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}799 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}800 aria-label="Copy cardholder name"801 >802 <CopyGlyph /><span class="peach-copy-sheen" />803 </button>804 </Show>805 </div>806 </div>807808 {}809 <div class="peach-field-card">810 <span class="peach-label">card number</span>811 <div class="peach-field-value-row">812 <span class={`peach-field-value peach-card-number`}>813 <Show when={showCardNumber()} fallback={814 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>815 }>816 <span>{entry().cardNumber ?? ""}</span>817 </Show>818 </span>819 <div class="peach-field-actions">820 <button821 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"822 onClick={() => setShowCardNumber(!showCardNumber())}823 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}824 >825 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>826 <EyeClosedGlyph />827 </Show>828 </button>829 <Show when={entry().cardNumber}>830 <button831 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}832 onClick={(e) => {833 const cardNum = entry().cardNumber!;834 if (entry().canary?.webhookUrl) {835 void sendMessage({836 type: "FIRE_CANARY_PING",837 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },838 });839 }840 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);841 }}842 aria-label="Copy card number"843 >844 <CopyGlyph /><span class="peach-copy-sheen" />845 </button>846 </Show>847 </div>848 </div>849 </div>850851 {}852 <Show when={entry().expMonth || entry().expYear}>853 <div class="peach-field-card">854 <span class="peach-label">expiry</span>855 <div class="peach-field-value-row">856 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>857 <button858 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}859 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}860 aria-label="Copy expiry"861 >862 <CopyGlyph /><span class="peach-copy-sheen" />863 </button>864 </div>865 </div>866 </Show>867868 {}869 <Show when={entry().cvc}>870 <div class="peach-field-card">871 <span class="peach-label">CVC</span>872 <div class="peach-field-value-row">873 <span class="peach-field-value">874 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>875 <span>{entry().cvc}</span>876 </Show>877 </span>878 <div class="peach-field-actions">879 <button880 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"881 onClick={() => setShowCardCvc(!showCardCvc())}882 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}883 >884 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>885 <EyeClosedGlyph />886 </Show>887 </button>888 <button889 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}890 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}891 aria-label="Copy CVC"892 >893 <CopyGlyph /><span class="peach-copy-sheen" />894 </button>895 </div>896 </div>897 </div>898 </Show>899 </section>900 </Show>901902 {}903 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>904 <section class="peach-fields" style="position:relative;z-index:1;">905906 {907908}909 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>910 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>911 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>912 <Show913 when={editingField() === "username"}914 fallback={915 <div class="peach-field-value-row">916 <span917 class="peach-field-value peach-inline-clickable"918 title="Click to edit"919 onClick={() => startEdit("username", entry().username ?? "")}920 >921 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}922 </span>923 <Show when={entry().username}>924 <button925 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}926 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}927 aria-label="Copy username"928 >929 <CopyGlyph /><span class="peach-copy-sheen" />930 </button>931 </Show>932 </div>933 }934 >935 <div936 class="peach-inline-field-wrap"937 onFocusOut={(event) => handleEditorFocusOut("username", event)}938 >939 <input940 class="peach-inline-input"941 value={draft()}942 onInput={(e) => setDraft(e.currentTarget.value)}943 onKeyDown={(e) => identityKeyDown(e, "username")}944 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}945 disabled={saving()}946 autocomplete="off"947 />948 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />949 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>950 <IdentitySuggestList951 listRef={(el) => (usernameIdentityEl = el)}952 inputRef={() => usernameInlineInput}953 suggestions={identitySuggestions()}954 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}955 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}956 />957 </Show>958 {959960961}962 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>963 <button964 class="peach-add-field-btn"965 type="button"966 onMouseDown={(e) => e.preventDefault()}967 onClick={() => {968 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);969 const hasSeparateEmail = !!entry().email;970 if (isEmailShape && !hasSeparateEmail) {971 void addOtherIdentity("username");972 } else {973 void addOtherIdentity("email");974 }975 }}976 >977 {(() => {978 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);979 const hasSeparateEmail = !!entry().email;980 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";981 })()}982 </button>983 </Show>984 </div>985 </Show>986 </div>987 </Show>988989 {990}991 <Show when={entry().email || editingField() === "email"}>992 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>993 <span class="peach-label">email</span>994 <Show995 when={editingField() === "email"}996 fallback={997 <div class="peach-field-value-row">998 <span999 class="peach-field-value peach-inline-clickable"1000 title="Click to edit"1001 onClick={() => startEdit("email", entry().email ?? "")}1002 >1003 {entry().email}1004 </span>1005 <Show when={entry().email}>1006 <button1007 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1008 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1009 aria-label="Copy email"1010 >1011 <CopyGlyph /><span class="peach-copy-sheen" />1012 </button>1013 </Show>1014 </div>1015 }1016 >1017 <div1018 class="peach-inline-field-wrap"1019 onFocusOut={(event) => handleEditorFocusOut("email", event)}1020 >1021 <input1022 class="peach-inline-input"1023 value={draft()}1024 onInput={(e) => setDraft(e.currentTarget.value)}1025 onKeyDown={(e) => identityKeyDown(e, "email")}1026 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1027 disabled={saving()}1028 autocomplete="email"1029 type="email"1030 />1031 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1032 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1033 <IdentitySuggestList1034 listRef={(el) => (emailIdentityEl = el)}1035 inputRef={() => emailInlineInput}1036 suggestions={identitySuggestions()}1037 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1038 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1039 />1040 </Show>1041 <Show when={!entry().username || entry().username?.includes("@")}>1042 <button1043 class="peach-add-field-btn"1044 type="button"1045 onMouseDown={(e) => e.preventDefault()}1046 onClick={() => void addOtherIdentity("username")}1047 >1048 + add username1049 </button>1050 </Show>1051 </div>1052 </Show>1053 </div>1054 </Show>10551056 {}1057 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1058 <span class="peach-label">password</span>1059 <Show1060 when={editingField() === "password"}1061 fallback={1062 <div class="peach-field-value-row">1063 <span1064 class="peach-password-value"1065 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1066 style="cursor:pointer;"1067 title="Click to edit password"1068 onClick={() => startEdit("password", entry().password ?? "")}1069 >1070 <Show1071 when={showPassword()}1072 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1073 >1074 <span class="peach-password-text">{entry().password ?? ""}</span>1075 </Show>1076 </span>1077 <div class="peach-field-actions">1078 <button1079 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1080 onClick={() => setShowPassword(!showPassword())}1081 aria-label="Reveal password"1082 >1083 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1084 <EyeClosedGlyph />1085 </Show>1086 </button>1087 <button1088 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1089 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1090 aria-label="Copy password"1091 >1092 <CopyGlyph /><span class="peach-copy-sheen" />1093 </button>1094 </div>1095 </div>1096 }1097 >1098 {}1099 <div1100 class="peach-inline-field-wrap"1101 onFocusOut={(event) => handleEditorFocusOut("password", event)}1102 >1103 <div class="peach-password-edit-row">1104 <input1105 class="peach-inline-input"1106 type={showPassword() ? "text" : "password"}1107 placeholder="paste or type a password"1108 value={draft()}1109 onInput={(e) => setDraft(e.currentTarget.value)}1110 onKeyDown={(e) => {1111 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1112 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1113 }}1114 ref={autoFocus}1115 disabled={saving()}1116 autocomplete="new-password"1117 />1118 <button1119 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1120 type="button"1121 onClick={() => setShowPassword(!showPassword())}1122 aria-label={showPassword() ? "Hide" : "Show"}1123 >1124 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1125 <EyeClosedGlyph />1126 </Show>1127 </button>1128 </div>1129 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1130 <button1131 class="peach-generate-toggle-link"1132 type="button"1133 onClick={() => setShowGenerator(!showGenerator())}1134 >1135 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1136 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1137 <path d="M9.5 2.5H11V1" />1138 </svg>1139 {showGenerator() ? "hide generator" : "generate"}1140 </button>1141 <Show when={showGenerator()}>1142 <GeneratorDrawer1143 open={true}1144 compact={true}1145 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1146 />1147 </Show>1148 </div>1149 </Show>1150 </div>11511152 </section>1153 </Show>11541155 {}1156 <Show when={entry().type !== "note" && entry().type !== "card"}>1157 <Show1158 when={entry().notes || editingField() === "notes"}1159 fallback={1160 <button1161 class="peach-add-field-link peach-entry-add-link"1162 onClick={() => startEdit("notes", "")}1163 >1164 + notes1165 </button>1166 }1167 >1168 <div style="position:relative;z-index:1;margin-top:6px;">1169 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1170 <Show1171 when={editingField() === "notes"}1172 fallback={1173 <div1174 class="peach-note-content peach-inline-clickable"1175 style="cursor:text;white-space:pre-wrap;"1176 onClick={() => startEdit("notes", entry().notes ?? "")}1177 >1178 {entry().notes}1179 </div>1180 }1181 >1182 <div1183 class="peach-inline-field-wrap"1184 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1185 >1186 <textarea1187 class="peach-inline-textarea"1188 value={draft()}1189 onInput={(e) => setDraft(e.currentTarget.value)}1190 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1191 ref={(el) => requestAnimationFrame(() => el?.focus())}1192 rows={4}1193 disabled={saving()}1194 />1195 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1196 </div>1197 </Show>1198 </div>1199 </Show>1200 </Show>12011202 {}1203 <Show1204 when={editingTags()}1205 fallback={1206 <Show when={!entry().tags || entry().tags!.length === 0}>1207 <button1208 class="peach-add-field-link peach-entry-add-link"1209 onClick={startEditTags}1210 >1211 + tags1212 </button>1213 </Show>1214 }1215 >1216 <div1217 class="peach-inline-tag-editor"1218 onFocusOut={(e) => {1219 const next = e.relatedTarget;1220 if (next instanceof Node && e.currentTarget.contains(next)) return;1221 void saveTags();1222 }}1223 >1224 <span class="peach-label">tags</span>1225 <div class="peach-tag-input-wrap">1226 <For each={tagsDraft()}>1227 {(tag) => (1228 <span class="peach-tag-chip">1229 {tag}1230 <button1231 class="peach-tag-chip-remove"1232 onMouseDown={(e) => e.preventDefault()}1233 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1234 aria-label={`Remove ${tag}`}1235 >×</button>1236 </span>1237 )}1238 </For>1239 <input1240 class="peach-tag-input"1241 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1242 value={tagInput()}1243 onInput={(e) => setTagInput(e.currentTarget.value)}1244 onKeyDown={(e) => {1245 if (e.key === "Enter" || e.key === ",") {1246 e.preventDefault();1247 addTag(tagInput());1248 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1249 setTagsDraft(tagsDraft().slice(0, -1));1250 } else if (e.key === "Escape") {1251 e.preventDefault();1252 setEditingTags(false);1253 setTagInput("");1254 }1255 }}1256 ref={(el) => requestAnimationFrame(() => el?.focus())}1257 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1258 />1259 </div>1260 </div>1261 </Show>12621263 {}1264 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1265 <Show1266 when={totpState() || editingField() === "totp"}1267 fallback={1268 <button1269 class="peach-add-field-link peach-entry-add-link"1270 onClick={() => startEdit("totp", "")}1271 >1272 + 2FA1273 </button>1274 }1275 >1276 <section class="peach-totp-band">1277 <Show1278 when={editingField() === "totp"}1279 fallback={1280 <Show when={totpState()}>1281 {(state) => (1282 <>1283 <div1284 class="peach-totp-main"1285 onClick={() => {1286 1287 1288 if (totpClickTimer) return;1289 totpClickTimer = setTimeout(() => {1290 totpClickTimer = null;1291 void copyField(state().code.replace(" ", ""), "totp");1292 }, 220);1293 }}1294 onDblClick={() => {1295 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1296 startEdit("totp", entry().totp ?? "");1297 }}1298 title="Click to copy · double-click to edit"1299 >1300 <span class="peach-label">2-factor</span>1301 <div class="peach-totp-code-row">1302 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1303 <span class="peach-totp-code-text">1304 {state().code.slice(0, 3)} {state().code.slice(3)}1305 </span>1306 </p>1307 </div>1308 </div>1309 <div class="peach-totp-meta">1310 <span class="peach-label">expires</span>1311 <div class="peach-totp-countdown">{state().remaining}s</div>1312 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1313 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1314 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1315 stroke-dasharray="106.8"1316 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1317 />1318 </svg>1319 </div>1320 </>1321 )}1322 </Show>1323 }1324 >1325 <div1326 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1327 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1328 >1329 <span class="peach-label">2-factor</span>1330 <input1331 class="peach-inline-input"1332 value={draft()}1333 onInput={(e) => setDraft(e.currentTarget.value)}1334 onKeyDown={onEditKeyDown}1335 ref={autoFocus}1336 disabled={saving()}1337 placeholder="otpauth://... or base32 secret"1338 autocomplete="off"1339 />1340 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1341 </div>1342 </Show>1343 </section>1344 </Show>1345 </Show>13461347 {}1348 <Show when={entry().type === "login"}>1349 <button1350 class="peach-add-field-link peach-entry-add-link"1351 onClick={() => setShowAdvanced((v) => !v)}1352 aria-expanded={showAdvanced()}1353 >1354 {showAdvanced() ? "− Advanced" : "+ Advanced"}1355 </button>1356 <Show when={showAdvanced()}>1357 <Show when={!isPasskeyOnly(entry())}>1358 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1359 </Show>13601361 <Show when={entry().passkey}>1362 {(passkey) => {1363 const currentBrowser = detectBrowser();1364 const crossBrowser =1365 passkey().browser &&1366 currentBrowser !== "unknown" &&1367 passkey().browser !== currentBrowser;1368 const algorithmLabel =1369 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1370 const formatDate = (ts?: number) =>1371 ts ? new Date(ts).toLocaleDateString() : "Never";1372 return (1373 <section1374 class="peach-fields peach-passkey-section"1375 style="position:relative;z-index:1;margin-top:6px;"1376 >1377 <div class="peach-field-card">1378 <span class="peach-label">passkey · {passkey().rpId}</span>1379 <div class="peach-field-value-row">1380 <span class="peach-field-value">{passkey().userName}</span>1381 <span class="peach-passkey-badge">passkey</span>1382 </div>1383 </div>13841385 <div class="peach-field-card">1386 <span class="peach-label">algorithm</span>1387 <div class="peach-field-value-row">1388 <span class="peach-field-value">{algorithmLabel}</span>1389 </div>1390 </div>13911392 <div class="peach-field-card">1393 <span class="peach-label">activity</span>1394 <div class="peach-field-value-row peach-passkey-activity">1395 <span class="peach-field-value">1396 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1397 </span>1398 <span class="peach-passkey-meta">1399 created {formatDate(passkey().createdAt)}1400 </span>1401 <span class="peach-passkey-meta">1402 last used {formatDate(passkey().lastUsedAt)}1403 </span>1404 </div>1405 </div>14061407 <Show when={crossBrowser}>1408 <div class="peach-info-notice peach-info-notice--warn">1409 {`This passkey was created on ${1410 passkey().browser === "chrome" ? "Chrome" : "Firefox"1411 }. To use it on ${1412 currentBrowser === "chrome" ? "Chrome" : "Firefox"1413 }, re-register it on this device.`}1414 </div>1415 </Show>14161417 <div class="peach-info-notice">1418 {passkey().browser === "firefox"1419 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1420 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1421 </div>14221423 <button1424 class="peach-add-field-link peach-entry-add-link"1425 style="color:rgba(220,38,38,0.7);"1426 onClick={async () => {1427 const id = entry().id;1428 await sendMessage({1429 type: "UPDATE_ENTRY",1430 payload: { id, patch: { passkey: undefined } },1431 });1432 await props.onFieldSaved();1433 }}1434 >1435 − remove passkey1436 </button>1437 </section>1438 );1439 }}1440 </Show>1441 </Show>1442 </Show>14431444 {}1445 <div style="height:4px;" />1446 </>1447 )}1448 </Show>1449 </div>14501451 </>1452 );1453}1454145514561457function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1458 return (1459 <div class="peach-inline-actions">1460 <button1461 class="peach-inline-save-btn"1462 onClick={props.onSave}1463 disabled={props.saving}1464 aria-label="Save"1465 title="Save (Enter)"1466 >1467 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1468 <path d="M1 4.5L4 7.5L10 1.5" />1469 </svg>1470 </button>1471 <button1472 class="peach-inline-cancel-btn"1473 onClick={props.onCancel}1474 disabled={props.saving}1475 aria-label="Cancel"1476 title="Cancel (Esc)"1477 >1478 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1479 <path d="M1 1L8 8M8 1L1 8" />1480 </svg>1481 </button>1482 </div>1483 );1484}14851486function EmptyVaultState() {1487 return (1488 <>1489 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1490 <div class="peach-entry-name">1491 <span class="peach-entry-name-text">1492 <span class="peach-entry-name-char" style="--char-index:0">e</span>1493 <span class="peach-entry-name-char" style="--char-index:1">m</span>1494 <span class="peach-entry-name-char" style="--char-index:2">p</span>1495 <span class="peach-entry-name-char" style="--char-index:3">t</span>1496 <span class="peach-entry-name-char" style="--char-index:4">y</span>1497 </span>1498 </div>1499 </div>1500 <div class="peach-empty-copy">1501 <span class="peach-label">vault</span>1502 <p>Your vault is ready for its first entry.</p>1503 </div>1504 </>1505 );1506}15071508function CopyGlyph() {1509 return (1510 <span class="peach-copy-icon">1511 <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">1512 <rect x="4" y="4" width="8" height="9" rx="1.5" />1513 <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" />1514 </svg>1515 </span>1516 );1517}15181519function EyeClosedGlyph() {1520 return (1521 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1522 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1523 <path d="M3.5 5.5L2.5 7.5" />1524 <path d="M7 6.5V8.5" />1525 <path d="M10.5 5.5L11.5 7.5" />1526 </svg>1527 );1528}15291530function EyeOpenGlyph() {1531 return (1532 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1533 <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" />1534 <circle cx="7" cy="5" r="1.75" />1535 </svg>1536 );1537}15381539function ChevronGlyph(props: { open: boolean }) {1540 return (1541 <svg1542 width="8"1543 height="8"1544 viewBox="0 0 8 8"1545 fill="none"1546 stroke="currentColor"1547 stroke-width="1.3"1548 stroke-linecap="round"1549 stroke-linejoin="round"1550 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1551 >1552 <path d="M2 1L6 4L2 7" />1553 </svg>1554 );1555}15561557function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1558 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1559 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1560 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1561 const [testResult, setTestResult] = createSignal<string | null>(null);1562 const [testing, setTesting] = createSignal(false);15631564 async function save() {1565 const url = webhookUrl().trim();1566 const nextLabel = label().trim() || undefined;1567 const canary = enabled() && url1568 ? {1569 webhookUrl: url,1570 label: nextLabel,1571 lastPinged: props.entry.canary?.lastPinged,1572 pingCount: props.entry.canary?.pingCount,1573 }1574 : undefined;1575 await sendMessage({1576 type: "UPDATE_ENTRY",1577 payload: { id: props.entry.id, patch: { canary } },1578 });1579 await props.onSaved();1580 }15811582 async function testPing() {1583 const url = webhookUrl().trim();1584 if (!url) return;1585 setTesting(true);1586 setTestResult(null);1587 const res = await sendMessage({1588 type: "FIRE_CANARY_PING",1589 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1590 }) as { ok: boolean; error?: string };1591 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1592 setTesting(false);1593 }15941595 return (1596 <div class="peach-canary-section">1597 <div class="peach-field-value-row">1598 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1599 <button1600 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1601 onClick={() => { setEnabled(!enabled()); void save(); }}1602 aria-label="Toggle canary"1603 >1604 <span class="peach-toggle-knob" />1605 </button>1606 </div>1607 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1608 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1609 </p>1610 <Show when={enabled()}>1611 <div class="peach-inline-field-wrap" style="margin-top:8px;">1612 <input1613 class="peach-inline-input"1614 placeholder="https://hooks.zapier.com/..."1615 value={webhookUrl()}1616 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1617 onBlur={() => void save()}1618 />1619 <input1620 class="peach-inline-input"1621 placeholder="label (optional, e.g. AWS decoy)"1622 value={label()}1623 onInput={(e) => setLabel(e.currentTarget.value)}1624 onBlur={() => void save()}1625 />1626 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1627 <button1628 class="peach-btn peach-btn--ghost peach-btn--sm"1629 onClick={() => void testPing()}1630 disabled={testing() || !webhookUrl().trim()}1631 >1632 {testing() ? "sending..." : "test ping"}1633 </button>1634 <Show when={testResult()}>1635 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1636 </Show>1637 </div>1638 <Show when={props.entry.canary?.lastPinged}>1639 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1640 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1641 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1642 </p>1643 </Show>1644 </div>1645 </Show>1646 </div>1647 );1648}