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) {96 if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);97 else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);98 }99 if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);100 }101 const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];102 const seen = new Set<string>();103 for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {104 if (seen.has(v)) continue;105 seen.add(v);106 const ec = emailMap.get(v) ?? 0;107 const uc = usernameMap.get(v) ?? 0;108 result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });109 }110 return result.sort((a, b) => b.count - a.count);111 });112113 const identitySuggestions = createMemo(() => {114 const field = editingField();115 if (field !== "username" && field !== "email") return [];116 const input = draft().toLowerCase();117 return allIdentities()118 .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))119 .slice(0, 8);120 });121122 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);123 onCleanup(() => window.clearInterval(interval));124125 const displayDomain = createMemo(() =>126 props.entry?.url ? getDisplayDomain(props.entry.url) : ""127 );128129 createEffect(() => {130 const domain = displayDomain();131 setFaviconSrc(domain ? faviconUrl(domain) : null);132 });133134 135 136 137 138 139 let lastEntryId: string | null = null;140 createEffect(() => {141 const id = props.entry?.id ?? null;142 if (id === lastEntryId) return;143 lastEntryId = id;144 setConfirmDelete(false);145 setEditingField(null);146 setEditingTags(false);147 setShowAdvanced(false);148 });149150 const totpState = createMemo(() => {151 void totpTick();152 if (!props.entry?.totp) return null;153 return generateTOTP(props.entry.totp);154 });155156 let prevTotpCode: string | undefined;157 createEffect(() => {158 const state = totpState();159 if (!state) { prevTotpCode = undefined; return; }160 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {161 setTotpHot(true);162 window.setTimeout(() => setTotpHot(false), 3000);163 }164 prevTotpCode = state.code;165 });166167 const totpProgress = createMemo(() => {168 const state = totpState();169 if (!state) return 0;170 return ((state.remaining - 1) / 29) * 100;171 });172173 createEffect(() => {174 const shell = document.getElementById("hd-shell");175 if (!shell) return;176177 const state = totpState();178 if (!state) {179 delete shell.dataset.totpActive;180 shell.style.removeProperty("--peach-totp-progress");181 return;182 }183184 shell.dataset.totpActive = "true";185 shell.style.setProperty(186 "--peach-totp-progress",187 `${Math.max(0, totpProgress())}%`188 );189190 onCleanup(() => {191 delete shell.dataset.totpActive;192 shell.style.removeProperty("--peach-totp-progress");193 });194 });195196 const titleSizeClass = createMemo(() => {197 const length = props.entry?.title.length ?? 0;198 if (length >= 28) return "peach-entry-name--xxl";199 if (length >= 20) return "peach-entry-name--xl";200 if (length >= 14) return "peach-entry-name--lg";201 return "";202 });203 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);204205 206207 function startEdit(field: EditableField, value: string) {208 setEditingField(field);209 setDraft(value);210 setIdentityHelperSuppressed(false);211 setShowGenerator(false);212 setConfirmDelete(false);213 if (field === "url") {214 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);215 }216 }217218 219 220 221 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {222 const c = candidate.trim();223 if (!c) return true;224 if (primary.trim() === c) return true;225 return others.some((u) => u.trim() === c);226 }227228 function addCurrentSite(url: string) {229 if (!url) return;230 if (!draft().trim()) {231 setDraft(url);232 return;233 }234 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;235 setAdditionalUrlsDraft((arr) => [...arr, url]);236 }237238 async function saveField() {239 const field = editingField();240 const entry = props.entry;241 if (!field || !entry) return;242 243 let value = field === "password" ? draft() : draft().trim();244 if (field === "totp" && value.startsWith("otpauth://")) {245 const parsed = parseOtpauthURI(value);246 value = parsed?.secret ?? value;247 }248 if (field === "password" && entry.type !== "note" && !value) {249 return;250 }251 setSaving(true);252 try {253 const patch: Partial<VaultEntry> = { [field]: value };254 if (field === "url") {255 256 patch.additionalUrls = additionalUrlsDraft();257 }258 await sendMessage({259 type: "UPDATE_ENTRY",260 payload: { id: entry.id, patch },261 });262 triggerRing();263 setEditingField(null);264 setShowGenerator(false);265 await props.onFieldSaved();266 } catch {267 268 } finally {269 setSaving(false);270 }271 }272273 function startEditTags() {274 setTagsDraft([...(props.entry?.tags ?? [])]);275 setTagInput("");276 setEditingTags(true);277 }278279 function addTag(value: string) {280 const n = value.toLowerCase().trim();281 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;282 setTagsDraft([...tagsDraft(), n]);283 setTagInput("");284 }285286 async function saveTags() {287 if (tagInput()) addTag(tagInput());288 const entry = props.entry;289 if (!entry) return;290 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;291 setEditingTags(false);292 setSaving(true);293 try {294 await sendMessage({295 type: "UPDATE_ENTRY",296 payload: { id: entry.id, patch: { tags: next } },297 });298 triggerRing();299 await props.onFieldSaved();300 } catch {301 302 } finally {303 setSaving(false);304 }305 }306307 function cancelEdit() {308 setEditingField(null);309 setDraft("");310 setShowGenerator(false);311 }312313 314 315 316 317 318 319 320 321 322 async function addOtherIdentity(target: "username" | "email") {323 const entry = props.entry;324 if (!entry) return;325 const current = editingField();326 const draftValue = draft().trim();327 const savedUsername = entry.username ?? "";328 const savedEmail = entry.email ?? "";329330 if (current === "username" && target === "username") {331 332 333 if (savedUsername.includes("@") && !entry.email) {334 setSaving(true);335 try {336 await sendMessage({337 type: "UPDATE_ENTRY",338 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },339 });340 await props.onFieldSaved();341 } finally {342 setSaving(false);343 }344 }345 startEdit("username", "");346 return;347 }348349 if (current === "username" && target === "email") {350 if (draftValue && draftValue !== savedUsername) {351 setSaving(true);352 try {353 await sendMessage({354 type: "UPDATE_ENTRY",355 payload: { id: entry.id, patch: { username: draftValue } },356 });357 await props.onFieldSaved();358 } finally {359 setSaving(false);360 }361 }362 startEdit("email", "");363 return;364 }365366 if (current === "email" && target === "username") {367 if (draftValue && draftValue !== savedEmail) {368 setSaving(true);369 try {370 await sendMessage({371 type: "UPDATE_ENTRY",372 payload: { id: entry.id, patch: { email: draftValue } },373 });374 await props.onFieldSaved();375 } finally {376 setSaving(false);377 }378 }379 startEdit("username", "");380 return;381 }382 }383384 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {385 switch (field) {386 case "title":387 return entry.title;388 case "url":389 return entry.url ?? "";390 case "username":391 return entry.username ?? "";392 case "email":393 return entry.email ?? "";394 case "password":395 return entry.password ?? "";396 case "notes":397 return entry.notes ?? "";398 case "totp":399 return entry.totp ?? "";400 }401 }402403 function handleEditorFocusOut(404 field: Exclude<EditableField, null>,405 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }406 ) {407 const nextTarget = event.relatedTarget;408 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;409410 411 412 413 if (editingField() !== field) return;414415 const entry = props.entry;416 if (!entry) return;417418 const currentValue = fieldValue(entry, field);419 const nextValue = field === "password" ? draft() : draft().trim();420 if (nextValue === currentValue) {421 cancelEdit();422 }423 }424425 426427 async function copyField(text: string, label: string, button?: HTMLButtonElement) {428 const ok = await popupCopy(text, label);429 if (ok) {430 const entry = props.entry;431 if (entry?.canary?.webhookUrl) {432 const trigger =433 label === "password"434 ? "password_copied"435 : label === "username"436 ? "username_copied"437 : undefined;438 if (trigger) {439 void sendMessage({440 type: "FIRE_CANARY_PING",441 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },442 });443 }444 }445 triggerRing();446 triggerPipPulse();447 setCopied(null);448 requestAnimationFrame(() => setCopied(label));449 if (button) {450 button.classList.remove("is-copied", "is-retreat");451 void button.offsetWidth;452 const drain = document.createElement("span");453 drain.className = "peach-copy-drain";454 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";455 drain.appendChild(bar);456 button.appendChild(drain);457 window.setTimeout(() => {458 button.classList.remove("is-copied");459 button.classList.add("is-retreat");460 drain.remove();461 window.setTimeout(() => button.classList.remove("is-retreat"), 160);462 }, 1800);463 }464 window.setTimeout(() => setCopied(null), 1800);465 }466 }467468 469470 createEffect(() => {471 if (!props.entry) return;472 const handler = (e: KeyboardEvent) => {473 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;474 if (inInput) return;475 switch (e.key) {476 case "c": e.preventDefault();477 if (props.entry?.password) void copyField(props.entry.password, "password"); break;478 case "u": e.preventDefault();479 if (props.entry?.username) void copyField(props.entry.username, "username"); break;480 case "Escape": e.preventDefault(); props.onEscape?.(); break;481 }482 };483 window.addEventListener("keydown", handler);484 onCleanup(() => window.removeEventListener("keydown", handler));485 });486487 488489 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {490 requestAnimationFrame(() => { el.focus(); el.select?.(); });491 }492493 function onEditKeyDown(e: KeyboardEvent) {494 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {495 e.preventDefault();496 void saveField();497 }498 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }499 }500501 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {502 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;503 if (e.key === "ArrowDown" && listEl) {504 e.preventDefault();505 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();506 return;507 }508 onEditKeyDown(e);509 }510511 return (512 <>513 <div514 class="peach-ghost"515 aria-hidden="true"516 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;"517 >518 {props.entry?.title || "empty"}519 </div>520521 <div class="peach-stage" style="position:relative;">522 <Show when={props.entry} fallback={<EmptyVaultState />}>523 {(entry) => (524 <>525 {}526 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>527 <div class="peach-breach-badge">528 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">529 <path d="M6 1L11 10H1L6 1z" />530 <line x1="6" y1="5" x2="6" y2="7.5" />531 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />532 </svg>533 <span style="flex:1">found in a known data breach</span>534 <button535 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"536 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}537 >538 Change password539 </button>540 </div>541 </Show>542543 {}544 <div class="peach-entry-header">545 <div class="peach-entry-header-name">546 <Show547 when={editingField() === "title"}548 fallback={549 <button550 class="peach-entry-name-button"551 title="Click to edit title"552 onClick={() => startEdit("title", entry().title)}553 >554 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>555 <span class="peach-entry-name-text">556 <Show557 when={useAnimatedTitle()}558 fallback={entry().title}559 >560 <For each={entry().title.split("")}>561 {(ch, i) => (562 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>563 {ch === " " ? "\u00A0" : ch}564 </span>565 )}566 </For>567 </Show>568 </span>569 </h1>570 </button>571 }572 >573 <div574 class="peach-inline-title-wrap"575 onFocusOut={(event) => handleEditorFocusOut("title", event)}576 >577 <input578 class="peach-inline-title-input"579 value={draft()}580 onInput={(e) => setDraft(e.currentTarget.value)}581 onKeyDown={onEditKeyDown}582 ref={autoFocus}583 disabled={saving()}584 />585 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />586 </div>587 </Show>588 </div>589590 {}591 <Show when={editingField() !== "title"}>592 <Show593 when={confirmDelete()}594 fallback={595 <button596 class="peach-entry-trash-btn"597 onClick={() => setConfirmDelete(true)}598 aria-label="Move to trash"599 title="Delete entry"600 >601 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">602 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />603 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />604 </svg>605 </button>606 }607 >608 <div class="peach-entry-trash-confirm">609 <span class="peach-entry-trash-confirm-label">trash?</span>610 <button611 class="peach-btn peach-btn--sm peach-btn--danger"612 style="font-size:10px;padding:2px 8px;min-height:22px;"613 onClick={() => { setConfirmDelete(false); props.onDelete(); }}614 >615 yes616 </button>617 <button618 class="peach-btn peach-btn--ghost peach-btn--sm"619 style="font-size:10px;padding:2px 8px;min-height:22px;"620 onClick={() => setConfirmDelete(false)}621 >622 no623 </button>624 </div>625 </Show>626 </Show>627 </div>628629 {}630 <Show when={entry().type !== "note" && entry().type !== "card"}>631 <div class="peach-domain" style="position:relative;z-index:1;">632 <Show633 when={editingField() === "url"}634 fallback={635 <div636 class="peach-domain-row peach-inline-clickable"637 title="Click to edit URL"638 onClick={() => startEdit("url", entry().url ?? "")}639 >640 <Show when={faviconSrc()}>641 <img642 src={faviconSrc()!}643 width="16"644 height="16"645 alt=""646 style="border-radius:3px;margin-right:5px;flex-shrink:0;"647 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}648 />649 </Show>650 <span class="peach-domain-value">651 {displayDomain() || (entry().url ? entry().url : "add a website url")}652 </span>653 <span class="peach-inline-edit-hint">edit</span>654 </div>655 }656 >657 <div658 class="peach-inline-field-wrap peach-inline-field-wrap--url"659 onFocusOut={(event) => handleEditorFocusOut("url", event)}660 >661 <div class="peach-inline-field-row">662 <input663 class="peach-inline-input"664 type="url"665 placeholder="https://"666 value={draft()}667 onInput={(e) => setDraft(e.currentTarget.value)}668 onKeyDown={onEditKeyDown}669 ref={autoFocus}670 disabled={saving()}671 />672 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />673 </div>674 <div class="peach-additional-urls">675 <Show when={additionalUrlsDraft().length > 0}>676 <span class="peach-label peach-additional-urls-label">also matches</span>677 </Show>678 <For each={additionalUrlsDraft()}>679 {(value, i) => (680 <div class="peach-additional-url-row">681 <input682 class="peach-inline-input"683 type="url"684 placeholder="https://"685 value={value}686 onInput={(e) => setAdditionalUrlsDraft((arr) => {687 const next = [...arr];688 next[i()] = e.currentTarget.value;689 return next;690 })}691 disabled={saving()}692 />693 <button694 class="peach-additional-url-remove"695 type="button"696 aria-label="Remove URL"697 onMouseDown={(e) => e.preventDefault()}698 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}699 disabled={saving()}700 >×</button>701 </div>702 )}703 </For>704 <button705 class="peach-add-field-link peach-additional-urls-add"706 type="button"707 onMouseDown={(e) => e.preventDefault()}708 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}709 disabled={saving()}710 >711 + add another url712 </button>713 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>714 <button715 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"716 type="button"717 title={props.currentTabUrl}718 onMouseDown={(e) => e.preventDefault()}719 onClick={() => addCurrentSite(props.currentTabUrl!)}720 disabled={saving()}721 >722 + add current site723 </button>724 </Show>725 </div>726 </div>727 </Show>728 </div>729 </Show>730731 {}732 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>733 <div734 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"735 onClick={startEditTags}736 title="Click to edit tags"737 >738 <For each={entry().tags!}>739 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}740 </For>741 </div>742 </Show>743744 {}745 <Show when={entry().type === "note"}>746 <div class="peach-entry-note-toolbar-row">747 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />748 </div>749 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">750 <NoteEditorInline751 entryKey={entry().id}752 initialContent={entry().notes ?? ""}753 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}754 onBlur={saveNoteInline}755 autoFocus={false}756 />757 </section>758 </Show>759760 {}761 <Show when={entry().type === "card"}>762 <section class="peach-fields" style="position:relative;z-index:1;">763 {}764 <Show when={entry().last4}>765 <div class="peach-field-card" style="padding-bottom:4px;">766 <span class="peach-label">767 <Show when={entry().network && entry().network !== "unknown"}>768 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>769 </Show>770 {entry().cardNickname || `•••• ${entry().last4}`}771 </span>772 </div>773 </Show>774775 {}776 <div class="peach-field-card">777 <span class="peach-label">cardholder name</span>778 <div class="peach-field-value-row">779 <span class="peach-field-value">{entry().cardholderName || "—"}</span>780 <Show when={entry().cardholderName}>781 <button782 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}783 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}784 aria-label="Copy cardholder name"785 >786 <CopyGlyph /><span class="peach-copy-sheen" />787 </button>788 </Show>789 </div>790 </div>791792 {}793 <div class="peach-field-card">794 <span class="peach-label">card number</span>795 <div class="peach-field-value-row">796 <span class={`peach-field-value peach-card-number`}>797 <Show when={showCardNumber()} fallback={798 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>799 }>800 <span>{entry().cardNumber ?? ""}</span>801 </Show>802 </span>803 <div class="peach-field-actions">804 <button805 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"806 onClick={() => setShowCardNumber(!showCardNumber())}807 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}808 >809 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>810 <EyeClosedGlyph />811 </Show>812 </button>813 <Show when={entry().cardNumber}>814 <button815 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}816 onClick={(e) => {817 const cardNum = entry().cardNumber!;818 if (entry().canary?.webhookUrl) {819 void sendMessage({820 type: "FIRE_CANARY_PING",821 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },822 });823 }824 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);825 }}826 aria-label="Copy card number"827 >828 <CopyGlyph /><span class="peach-copy-sheen" />829 </button>830 </Show>831 </div>832 </div>833 </div>834835 {}836 <Show when={entry().expMonth || entry().expYear}>837 <div class="peach-field-card">838 <span class="peach-label">expiry</span>839 <div class="peach-field-value-row">840 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>841 <button842 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}843 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}844 aria-label="Copy expiry"845 >846 <CopyGlyph /><span class="peach-copy-sheen" />847 </button>848 </div>849 </div>850 </Show>851852 {}853 <Show when={entry().cvc}>854 <div class="peach-field-card">855 <span class="peach-label">CVC</span>856 <div class="peach-field-value-row">857 <span class="peach-field-value">858 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>859 <span>{entry().cvc}</span>860 </Show>861 </span>862 <div class="peach-field-actions">863 <button864 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"865 onClick={() => setShowCardCvc(!showCardCvc())}866 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}867 >868 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>869 <EyeClosedGlyph />870 </Show>871 </button>872 <button873 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}874 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}875 aria-label="Copy CVC"876 >877 <CopyGlyph /><span class="peach-copy-sheen" />878 </button>879 </div>880 </div>881 </div>882 </Show>883 </section>884 </Show>885886 {}887 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>888 <section class="peach-fields" style="position:relative;z-index:1;">889890 {891892}893 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>894 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>895 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>896 <Show897 when={editingField() === "username"}898 fallback={899 <div class="peach-field-value-row">900 <span901 class="peach-field-value peach-inline-clickable"902 title="Click to edit"903 onClick={() => startEdit("username", entry().username ?? "")}904 >905 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}906 </span>907 <Show when={entry().username}>908 <button909 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}910 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}911 aria-label="Copy username"912 >913 <CopyGlyph /><span class="peach-copy-sheen" />914 </button>915 </Show>916 </div>917 }918 >919 <div920 class="peach-inline-field-wrap"921 onFocusOut={(event) => handleEditorFocusOut("username", event)}922 >923 <input924 class="peach-inline-input"925 value={draft()}926 onInput={(e) => setDraft(e.currentTarget.value)}927 onKeyDown={(e) => identityKeyDown(e, "username")}928 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}929 disabled={saving()}930 autocomplete="off"931 />932 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />933 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>934 <IdentitySuggestList935 listRef={(el) => (usernameIdentityEl = el)}936 inputRef={() => usernameInlineInput}937 suggestions={identitySuggestions()}938 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}939 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}940 />941 </Show>942 {943944945}946 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>947 <button948 class="peach-add-field-btn"949 type="button"950 onMouseDown={(e) => e.preventDefault()}951 onClick={() => {952 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);953 const hasSeparateEmail = !!entry().email;954 if (isEmailShape && !hasSeparateEmail) {955 void addOtherIdentity("username");956 } else {957 void addOtherIdentity("email");958 }959 }}960 >961 {(() => {962 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);963 const hasSeparateEmail = !!entry().email;964 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";965 })()}966 </button>967 </Show>968 </div>969 </Show>970 </div>971 </Show>972973 {974}975 <Show when={entry().email || editingField() === "email"}>976 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>977 <span class="peach-label">email</span>978 <Show979 when={editingField() === "email"}980 fallback={981 <div class="peach-field-value-row">982 <span983 class="peach-field-value peach-inline-clickable"984 title="Click to edit"985 onClick={() => startEdit("email", entry().email ?? "")}986 >987 {entry().email}988 </span>989 <Show when={entry().email}>990 <button991 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}992 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}993 aria-label="Copy email"994 >995 <CopyGlyph /><span class="peach-copy-sheen" />996 </button>997 </Show>998 </div>999 }1000 >1001 <div1002 class="peach-inline-field-wrap"1003 onFocusOut={(event) => handleEditorFocusOut("email", event)}1004 >1005 <input1006 class="peach-inline-input"1007 value={draft()}1008 onInput={(e) => setDraft(e.currentTarget.value)}1009 onKeyDown={(e) => identityKeyDown(e, "email")}1010 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1011 disabled={saving()}1012 autocomplete="email"1013 type="email"1014 />1015 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1016 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1017 <IdentitySuggestList1018 listRef={(el) => (emailIdentityEl = el)}1019 inputRef={() => emailInlineInput}1020 suggestions={identitySuggestions()}1021 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1022 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1023 />1024 </Show>1025 <Show when={!entry().username || entry().username?.includes("@")}>1026 <button1027 class="peach-add-field-btn"1028 type="button"1029 onMouseDown={(e) => e.preventDefault()}1030 onClick={() => void addOtherIdentity("username")}1031 >1032 + add username1033 </button>1034 </Show>1035 </div>1036 </Show>1037 </div>1038 </Show>10391040 {}1041 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1042 <span class="peach-label">password</span>1043 <Show1044 when={editingField() === "password"}1045 fallback={1046 <div class="peach-field-value-row">1047 <span1048 class="peach-password-value"1049 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1050 style="cursor:pointer;"1051 title="Click to edit password"1052 onClick={() => startEdit("password", entry().password ?? "")}1053 >1054 <Show1055 when={showPassword()}1056 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1057 >1058 <span class="peach-password-text">{entry().password ?? ""}</span>1059 </Show>1060 </span>1061 <div class="peach-field-actions">1062 <button1063 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1064 onClick={() => setShowPassword(!showPassword())}1065 aria-label="Reveal password"1066 >1067 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1068 <EyeClosedGlyph />1069 </Show>1070 </button>1071 <button1072 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1073 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1074 aria-label="Copy password"1075 >1076 <CopyGlyph /><span class="peach-copy-sheen" />1077 </button>1078 </div>1079 </div>1080 }1081 >1082 {}1083 <div1084 class="peach-inline-field-wrap"1085 onFocusOut={(event) => handleEditorFocusOut("password", event)}1086 >1087 <div class="peach-password-edit-row">1088 <input1089 class="peach-inline-input"1090 type={showPassword() ? "text" : "password"}1091 placeholder="paste or type a password"1092 value={draft()}1093 onInput={(e) => setDraft(e.currentTarget.value)}1094 onKeyDown={(e) => {1095 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1096 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1097 }}1098 ref={autoFocus}1099 disabled={saving()}1100 autocomplete="new-password"1101 />1102 <button1103 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1104 type="button"1105 onClick={() => setShowPassword(!showPassword())}1106 aria-label={showPassword() ? "Hide" : "Show"}1107 >1108 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1109 <EyeClosedGlyph />1110 </Show>1111 </button>1112 </div>1113 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1114 <button1115 class="peach-generate-toggle-link"1116 type="button"1117 onClick={() => setShowGenerator(!showGenerator())}1118 >1119 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1120 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1121 <path d="M9.5 2.5H11V1" />1122 </svg>1123 {showGenerator() ? "hide generator" : "generate"}1124 </button>1125 <Show when={showGenerator()}>1126 <GeneratorDrawer1127 open={true}1128 compact={true}1129 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1130 />1131 </Show>1132 </div>1133 </Show>1134 </div>11351136 </section>1137 </Show>11381139 {}1140 <Show when={entry().type !== "note" && entry().type !== "card"}>1141 <Show1142 when={entry().notes || editingField() === "notes"}1143 fallback={1144 <button1145 class="peach-add-field-link peach-entry-add-link"1146 onClick={() => startEdit("notes", "")}1147 >1148 + notes1149 </button>1150 }1151 >1152 <div style="position:relative;z-index:1;margin-top:6px;">1153 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1154 <Show1155 when={editingField() === "notes"}1156 fallback={1157 <div1158 class="peach-note-content peach-inline-clickable"1159 style="cursor:text;white-space:pre-wrap;"1160 onClick={() => startEdit("notes", entry().notes ?? "")}1161 >1162 {entry().notes}1163 </div>1164 }1165 >1166 <div1167 class="peach-inline-field-wrap"1168 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1169 >1170 <textarea1171 class="peach-inline-textarea"1172 value={draft()}1173 onInput={(e) => setDraft(e.currentTarget.value)}1174 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1175 ref={(el) => requestAnimationFrame(() => el?.focus())}1176 rows={4}1177 disabled={saving()}1178 />1179 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1180 </div>1181 </Show>1182 </div>1183 </Show>1184 </Show>11851186 {}1187 <Show1188 when={editingTags()}1189 fallback={1190 <Show when={!entry().tags || entry().tags!.length === 0}>1191 <button1192 class="peach-add-field-link peach-entry-add-link"1193 onClick={startEditTags}1194 >1195 + tags1196 </button>1197 </Show>1198 }1199 >1200 <div1201 class="peach-inline-tag-editor"1202 onFocusOut={(e) => {1203 const next = e.relatedTarget;1204 if (next instanceof Node && e.currentTarget.contains(next)) return;1205 void saveTags();1206 }}1207 >1208 <span class="peach-label">tags</span>1209 <div class="peach-tag-input-wrap">1210 <For each={tagsDraft()}>1211 {(tag) => (1212 <span class="peach-tag-chip">1213 {tag}1214 <button1215 class="peach-tag-chip-remove"1216 onMouseDown={(e) => e.preventDefault()}1217 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1218 aria-label={`Remove ${tag}`}1219 >×</button>1220 </span>1221 )}1222 </For>1223 <input1224 class="peach-tag-input"1225 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1226 value={tagInput()}1227 onInput={(e) => setTagInput(e.currentTarget.value)}1228 onKeyDown={(e) => {1229 if (e.key === "Enter" || e.key === ",") {1230 e.preventDefault();1231 addTag(tagInput());1232 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1233 setTagsDraft(tagsDraft().slice(0, -1));1234 } else if (e.key === "Escape") {1235 e.preventDefault();1236 setEditingTags(false);1237 setTagInput("");1238 }1239 }}1240 ref={(el) => requestAnimationFrame(() => el?.focus())}1241 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1242 />1243 </div>1244 </div>1245 </Show>12461247 {}1248 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1249 <Show1250 when={totpState() || editingField() === "totp"}1251 fallback={1252 <button1253 class="peach-add-field-link peach-entry-add-link"1254 onClick={() => startEdit("totp", "")}1255 >1256 + 2FA1257 </button>1258 }1259 >1260 <section class="peach-totp-band">1261 <Show1262 when={editingField() === "totp"}1263 fallback={1264 <Show when={totpState()}>1265 {(state) => (1266 <>1267 <div1268 class="peach-totp-main"1269 onClick={() => {1270 1271 1272 if (totpClickTimer) return;1273 totpClickTimer = setTimeout(() => {1274 totpClickTimer = null;1275 void copyField(state().code.replace(" ", ""), "totp");1276 }, 220);1277 }}1278 onDblClick={() => {1279 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1280 startEdit("totp", entry().totp ?? "");1281 }}1282 title="Click to copy · double-click to edit"1283 >1284 <span class="peach-label">2-factor</span>1285 <div class="peach-totp-code-row">1286 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1287 <span class="peach-totp-code-text">1288 {state().code.slice(0, 3)} {state().code.slice(3)}1289 </span>1290 </p>1291 </div>1292 </div>1293 <div class="peach-totp-meta">1294 <span class="peach-label">expires</span>1295 <div class="peach-totp-countdown">{state().remaining}s</div>1296 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1297 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1298 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1299 stroke-dasharray="106.8"1300 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1301 />1302 </svg>1303 </div>1304 </>1305 )}1306 </Show>1307 }1308 >1309 <div1310 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1311 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1312 >1313 <span class="peach-label">2-factor</span>1314 <input1315 class="peach-inline-input"1316 value={draft()}1317 onInput={(e) => setDraft(e.currentTarget.value)}1318 onKeyDown={onEditKeyDown}1319 ref={autoFocus}1320 disabled={saving()}1321 placeholder="otpauth://... or base32 secret"1322 autocomplete="off"1323 />1324 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1325 </div>1326 </Show>1327 </section>1328 </Show>1329 </Show>13301331 {}1332 <Show when={entry().type === "login"}>1333 <button1334 class="peach-add-field-link peach-entry-add-link"1335 onClick={() => setShowAdvanced((v) => !v)}1336 aria-expanded={showAdvanced()}1337 >1338 {showAdvanced() ? "− Advanced" : "+ Advanced"}1339 </button>1340 <Show when={showAdvanced()}>1341 <Show when={!isPasskeyOnly(entry())}>1342 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1343 </Show>13441345 <Show when={entry().passkey}>1346 {(passkey) => {1347 const currentBrowser = detectBrowser();1348 const crossBrowser =1349 passkey().browser &&1350 currentBrowser !== "unknown" &&1351 passkey().browser !== currentBrowser;1352 const algorithmLabel =1353 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1354 const formatDate = (ts?: number) =>1355 ts ? new Date(ts).toLocaleDateString() : "Never";1356 return (1357 <section1358 class="peach-fields peach-passkey-section"1359 style="position:relative;z-index:1;margin-top:6px;"1360 >1361 <div class="peach-field-card">1362 <span class="peach-label">passkey · {passkey().rpId}</span>1363 <div class="peach-field-value-row">1364 <span class="peach-field-value">{passkey().userName}</span>1365 <span class="peach-passkey-badge">passkey</span>1366 </div>1367 </div>13681369 <div class="peach-field-card">1370 <span class="peach-label">algorithm</span>1371 <div class="peach-field-value-row">1372 <span class="peach-field-value">{algorithmLabel}</span>1373 </div>1374 </div>13751376 <div class="peach-field-card">1377 <span class="peach-label">activity</span>1378 <div class="peach-field-value-row peach-passkey-activity">1379 <span class="peach-field-value">1380 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1381 </span>1382 <span class="peach-passkey-meta">1383 created {formatDate(passkey().createdAt)}1384 </span>1385 <span class="peach-passkey-meta">1386 last used {formatDate(passkey().lastUsedAt)}1387 </span>1388 </div>1389 </div>13901391 <Show when={crossBrowser}>1392 <div class="peach-info-notice peach-info-notice--warn">1393 {`This passkey was created on ${1394 passkey().browser === "chrome" ? "Chrome" : "Firefox"1395 }. To use it on ${1396 currentBrowser === "chrome" ? "Chrome" : "Firefox"1397 }, re-register it on this device.`}1398 </div>1399 </Show>14001401 <div class="peach-info-notice">1402 {passkey().browser === "firefox"1403 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1404 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1405 </div>14061407 <button1408 class="peach-add-field-link peach-entry-add-link"1409 style="color:rgba(220,38,38,0.7);"1410 onClick={async () => {1411 const id = entry().id;1412 await sendMessage({1413 type: "UPDATE_ENTRY",1414 payload: { id, patch: { passkey: undefined } },1415 });1416 await props.onFieldSaved();1417 }}1418 >1419 − remove passkey1420 </button>1421 </section>1422 );1423 }}1424 </Show>1425 </Show>1426 </Show>14271428 {}1429 <div style="height:4px;" />1430 </>1431 )}1432 </Show>1433 </div>14341435 </>1436 );1437}1438143914401441function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1442 return (1443 <div class="peach-inline-actions">1444 <button1445 class="peach-inline-save-btn"1446 onClick={props.onSave}1447 disabled={props.saving}1448 aria-label="Save"1449 title="Save (Enter)"1450 >1451 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1452 <path d="M1 4.5L4 7.5L10 1.5" />1453 </svg>1454 </button>1455 <button1456 class="peach-inline-cancel-btn"1457 onClick={props.onCancel}1458 disabled={props.saving}1459 aria-label="Cancel"1460 title="Cancel (Esc)"1461 >1462 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1463 <path d="M1 1L8 8M8 1L1 8" />1464 </svg>1465 </button>1466 </div>1467 );1468}14691470function EmptyVaultState() {1471 return (1472 <>1473 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1474 <div class="peach-entry-name">1475 <span class="peach-entry-name-text">1476 <span class="peach-entry-name-char" style="--char-index:0">e</span>1477 <span class="peach-entry-name-char" style="--char-index:1">m</span>1478 <span class="peach-entry-name-char" style="--char-index:2">p</span>1479 <span class="peach-entry-name-char" style="--char-index:3">t</span>1480 <span class="peach-entry-name-char" style="--char-index:4">y</span>1481 </span>1482 </div>1483 </div>1484 <div class="peach-empty-copy">1485 <span class="peach-label">vault</span>1486 <p>Your vault is ready for its first entry.</p>1487 </div>1488 </>1489 );1490}14911492function CopyGlyph() {1493 return (1494 <span class="peach-copy-icon">1495 <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">1496 <rect x="4" y="4" width="8" height="9" rx="1.5" />1497 <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" />1498 </svg>1499 </span>1500 );1501}15021503function EyeClosedGlyph() {1504 return (1505 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1506 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1507 <path d="M3.5 5.5L2.5 7.5" />1508 <path d="M7 6.5V8.5" />1509 <path d="M10.5 5.5L11.5 7.5" />1510 </svg>1511 );1512}15131514function EyeOpenGlyph() {1515 return (1516 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1517 <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" />1518 <circle cx="7" cy="5" r="1.75" />1519 </svg>1520 );1521}15221523function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1524 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1525 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1526 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1527 const [testResult, setTestResult] = createSignal<string | null>(null);1528 const [testing, setTesting] = createSignal(false);15291530 async function save() {1531 const url = webhookUrl().trim();1532 const nextLabel = label().trim() || undefined;1533 const canary = enabled() && url1534 ? {1535 webhookUrl: url,1536 label: nextLabel,1537 lastPinged: props.entry.canary?.lastPinged,1538 pingCount: props.entry.canary?.pingCount,1539 }1540 : undefined;1541 await sendMessage({1542 type: "UPDATE_ENTRY",1543 payload: { id: props.entry.id, patch: { canary } },1544 });1545 await props.onSaved();1546 }15471548 async function testPing() {1549 const url = webhookUrl().trim();1550 if (!url) return;1551 setTesting(true);1552 setTestResult(null);1553 const res = await sendMessage({1554 type: "FIRE_CANARY_PING",1555 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1556 }) as { ok: boolean; error?: string };1557 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1558 setTesting(false);1559 }15601561 return (1562 <div class="peach-canary-section">1563 <div class="peach-field-value-row">1564 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1565 <button1566 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1567 onClick={() => { setEnabled(!enabled()); void save(); }}1568 aria-label="Toggle canary"1569 >1570 <span class="peach-toggle-knob" />1571 </button>1572 </div>1573 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1574 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1575 </p>1576 <Show when={enabled()}>1577 <div class="peach-inline-field-wrap" style="margin-top:8px;">1578 <input1579 class="peach-inline-input"1580 placeholder="https://hooks.zapier.com/..."1581 value={webhookUrl()}1582 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1583 onBlur={() => void save()}1584 />1585 <input1586 class="peach-inline-input"1587 placeholder="label (optional, e.g. AWS decoy)"1588 value={label()}1589 onInput={(e) => setLabel(e.currentTarget.value)}1590 onBlur={() => void save()}1591 />1592 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1593 <button1594 class="peach-btn peach-btn--ghost peach-btn--sm"1595 onClick={() => void testPing()}1596 disabled={testing() || !webhookUrl().trim()}1597 >1598 {testing() ? "sending..." : "test ping"}1599 </button>1600 <Show when={testResult()}>1601 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1602 </Show>1603 </div>1604 <Show when={props.entry.canary?.lastPinged}>1605 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1606 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1607 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1608 </p>1609 </Show>1610 </div>1611 </Show>1612 </div>1613 );1614}