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 async function savePassword(password: string) {426 const entry = props.entry;427 if (!entry) return;428 setSaving(true);429 try {430 await sendMessage({431 type: "UPDATE_ENTRY",432 payload: { id: entry.id, patch: { password } },433 });434 triggerRing();435 setShowGenerator(false);436 await props.onFieldSaved();437 } catch {438 439 } finally {440 setSaving(false);441 }442 }443444 445446 async function copyField(text: string, label: string, button?: HTMLButtonElement) {447 const ok = await popupCopy(text, label);448 if (ok) {449 const entry = props.entry;450 if (entry?.canary?.webhookUrl) {451 const trigger =452 label === "password"453 ? "password_copied"454 : label === "username"455 ? "username_copied"456 : undefined;457 if (trigger) {458 void sendMessage({459 type: "FIRE_CANARY_PING",460 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },461 });462 }463 }464 triggerRing();465 triggerPipPulse();466 setCopied(null);467 requestAnimationFrame(() => setCopied(label));468 if (button) {469 button.classList.remove("is-copied", "is-retreat");470 void button.offsetWidth;471 const drain = document.createElement("span");472 drain.className = "peach-copy-drain";473 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";474 drain.appendChild(bar);475 button.appendChild(drain);476 window.setTimeout(() => {477 button.classList.remove("is-copied");478 button.classList.add("is-retreat");479 drain.remove();480 window.setTimeout(() => button.classList.remove("is-retreat"), 160);481 }, 1800);482 }483 window.setTimeout(() => setCopied(null), 1800);484 }485 }486487 488489 createEffect(() => {490 if (!props.entry) return;491 const handler = (e: KeyboardEvent) => {492 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;493 if (inInput) return;494 switch (e.key) {495 case "c": e.preventDefault();496 if (props.entry?.password) void copyField(props.entry.password, "password"); break;497 case "u": e.preventDefault();498 if (props.entry?.username) void copyField(props.entry.username, "username"); break;499 case "Escape": e.preventDefault(); props.onEscape?.(); break;500 }501 };502 window.addEventListener("keydown", handler);503 onCleanup(() => window.removeEventListener("keydown", handler));504 });505506 507508 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {509 requestAnimationFrame(() => { el.focus(); el.select?.(); });510 }511512 function onEditKeyDown(e: KeyboardEvent) {513 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {514 e.preventDefault();515 void saveField();516 }517 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }518 }519520 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {521 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;522 if (e.key === "ArrowDown" && listEl) {523 e.preventDefault();524 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();525 return;526 }527 onEditKeyDown(e);528 }529530 return (531 <>532 <div533 class="peach-ghost"534 aria-hidden="true"535 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;"536 >537 {props.entry?.title || "empty"}538 </div>539540 <div class="peach-stage" style="position:relative;">541 <Show when={props.entry} fallback={<EmptyVaultState />}>542 {(entry) => (543 <>544 {}545 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>546 <div class="peach-breach-badge">547 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">548 <path d="M6 1L11 10H1L6 1z" />549 <line x1="6" y1="5" x2="6" y2="7.5" />550 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />551 </svg>552 <span style="flex:1">found in a known data breach</span>553 <button554 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"555 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}556 >557 Change password558 </button>559 </div>560 </Show>561562 {}563 <div class="peach-entry-header">564 <div class="peach-entry-header-name">565 <Show566 when={editingField() === "title"}567 fallback={568 <button569 class="peach-entry-name-button"570 title="Click to edit title"571 onClick={() => startEdit("title", entry().title)}572 >573 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>574 <span class="peach-entry-name-text">575 <Show576 when={useAnimatedTitle()}577 fallback={entry().title}578 >579 <For each={entry().title.split("")}>580 {(ch, i) => (581 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>582 {ch === " " ? "\u00A0" : ch}583 </span>584 )}585 </For>586 </Show>587 </span>588 </h1>589 </button>590 }591 >592 <div593 class="peach-inline-title-wrap"594 onFocusOut={(event) => handleEditorFocusOut("title", event)}595 >596 <input597 class="peach-inline-title-input"598 value={draft()}599 onInput={(e) => setDraft(e.currentTarget.value)}600 onKeyDown={onEditKeyDown}601 ref={autoFocus}602 disabled={saving()}603 />604 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />605 </div>606 </Show>607 </div>608609 {}610 <Show when={editingField() !== "title"}>611 <Show612 when={confirmDelete()}613 fallback={614 <button615 class="peach-entry-trash-btn"616 onClick={() => setConfirmDelete(true)}617 aria-label="Move to trash"618 title="Delete entry"619 >620 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">621 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />622 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />623 </svg>624 </button>625 }626 >627 <div class="peach-entry-trash-confirm">628 <span class="peach-entry-trash-confirm-label">trash?</span>629 <button630 class="peach-btn peach-btn--sm peach-btn--danger"631 style="font-size:10px;padding:2px 8px;min-height:22px;"632 onClick={() => { setConfirmDelete(false); props.onDelete(); }}633 >634 yes635 </button>636 <button637 class="peach-btn peach-btn--ghost peach-btn--sm"638 style="font-size:10px;padding:2px 8px;min-height:22px;"639 onClick={() => setConfirmDelete(false)}640 >641 no642 </button>643 </div>644 </Show>645 </Show>646 </div>647648 {}649 <Show when={entry().type !== "note" && entry().type !== "card"}>650 <div class="peach-domain" style="position:relative;z-index:1;">651 <Show652 when={editingField() === "url"}653 fallback={654 <div655 class="peach-domain-row peach-inline-clickable"656 title="Click to edit URL"657 onClick={() => startEdit("url", entry().url ?? "")}658 >659 <Show when={faviconSrc()}>660 <img661 src={faviconSrc()!}662 width="16"663 height="16"664 alt=""665 style="border-radius:3px;margin-right:5px;flex-shrink:0;"666 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}667 />668 </Show>669 <span class="peach-domain-value">670 {displayDomain() || (entry().url ? entry().url : "add a website url")}671 </span>672 <span class="peach-inline-edit-hint">edit</span>673 </div>674 }675 >676 <div677 class="peach-inline-field-wrap peach-inline-field-wrap--url"678 onFocusOut={(event) => handleEditorFocusOut("url", event)}679 >680 <div class="peach-inline-field-row">681 <input682 class="peach-inline-input"683 type="url"684 placeholder="https://"685 value={draft()}686 onInput={(e) => setDraft(e.currentTarget.value)}687 onKeyDown={onEditKeyDown}688 ref={autoFocus}689 disabled={saving()}690 />691 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />692 </div>693 <div class="peach-additional-urls">694 <Show when={additionalUrlsDraft().length > 0}>695 <span class="peach-label peach-additional-urls-label">also matches</span>696 </Show>697 <For each={additionalUrlsDraft()}>698 {(value, i) => (699 <div class="peach-additional-url-row">700 <input701 class="peach-inline-input"702 type="url"703 placeholder="https://"704 value={value}705 onInput={(e) => setAdditionalUrlsDraft((arr) => {706 const next = [...arr];707 next[i()] = e.currentTarget.value;708 return next;709 })}710 disabled={saving()}711 />712 <button713 class="peach-additional-url-remove"714 type="button"715 aria-label="Remove URL"716 onMouseDown={(e) => e.preventDefault()}717 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}718 disabled={saving()}719 >×</button>720 </div>721 )}722 </For>723 <button724 class="peach-add-field-link peach-additional-urls-add"725 type="button"726 onMouseDown={(e) => e.preventDefault()}727 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}728 disabled={saving()}729 >730 + add another url731 </button>732 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>733 <button734 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"735 type="button"736 title={props.currentTabUrl}737 onMouseDown={(e) => e.preventDefault()}738 onClick={() => addCurrentSite(props.currentTabUrl!)}739 disabled={saving()}740 >741 + add current site742 </button>743 </Show>744 </div>745 </div>746 </Show>747 </div>748 </Show>749750 {}751 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>752 <div753 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"754 onClick={startEditTags}755 title="Click to edit tags"756 >757 <For each={entry().tags!}>758 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}759 </For>760 </div>761 </Show>762763 {}764 <Show when={entry().type === "note"}>765 <div class="peach-entry-note-toolbar-row">766 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />767 </div>768 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">769 <NoteEditorInline770 entryKey={entry().id}771 initialContent={entry().notes ?? ""}772 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}773 onBlur={saveNoteInline}774 autoFocus={false}775 />776 </section>777 </Show>778779 {}780 <Show when={entry().type === "card"}>781 <section class="peach-fields" style="position:relative;z-index:1;">782 {}783 <Show when={entry().last4}>784 <div class="peach-field-card" style="padding-bottom:4px;">785 <span class="peach-label">786 <Show when={entry().network && entry().network !== "unknown"}>787 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>788 </Show>789 {entry().cardNickname || `•••• ${entry().last4}`}790 </span>791 </div>792 </Show>793794 {}795 <div class="peach-field-card">796 <span class="peach-label">cardholder name</span>797 <div class="peach-field-value-row">798 <span class="peach-field-value">{entry().cardholderName || "—"}</span>799 <Show when={entry().cardholderName}>800 <button801 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}802 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}803 aria-label="Copy cardholder name"804 >805 <CopyGlyph /><span class="peach-copy-sheen" />806 </button>807 </Show>808 </div>809 </div>810811 {}812 <div class="peach-field-card">813 <span class="peach-label">card number</span>814 <div class="peach-field-value-row">815 <span class={`peach-field-value peach-card-number`}>816 <Show when={showCardNumber()} fallback={817 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>818 }>819 <span>{entry().cardNumber ?? ""}</span>820 </Show>821 </span>822 <div class="peach-field-actions">823 <button824 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"825 onClick={() => setShowCardNumber(!showCardNumber())}826 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}827 >828 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>829 <EyeClosedGlyph />830 </Show>831 </button>832 <Show when={entry().cardNumber}>833 <button834 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}835 onClick={(e) => {836 const cardNum = entry().cardNumber!;837 if (entry().canary?.webhookUrl) {838 void sendMessage({839 type: "FIRE_CANARY_PING",840 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },841 });842 }843 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);844 }}845 aria-label="Copy card number"846 >847 <CopyGlyph /><span class="peach-copy-sheen" />848 </button>849 </Show>850 </div>851 </div>852 </div>853854 {}855 <Show when={entry().expMonth || entry().expYear}>856 <div class="peach-field-card">857 <span class="peach-label">expiry</span>858 <div class="peach-field-value-row">859 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>860 <button861 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}862 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}863 aria-label="Copy expiry"864 >865 <CopyGlyph /><span class="peach-copy-sheen" />866 </button>867 </div>868 </div>869 </Show>870871 {}872 <Show when={entry().cvc}>873 <div class="peach-field-card">874 <span class="peach-label">CVC</span>875 <div class="peach-field-value-row">876 <span class="peach-field-value">877 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>878 <span>{entry().cvc}</span>879 </Show>880 </span>881 <div class="peach-field-actions">882 <button883 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"884 onClick={() => setShowCardCvc(!showCardCvc())}885 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}886 >887 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>888 <EyeClosedGlyph />889 </Show>890 </button>891 <button892 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}893 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}894 aria-label="Copy CVC"895 >896 <CopyGlyph /><span class="peach-copy-sheen" />897 </button>898 </div>899 </div>900 </div>901 </Show>902 </section>903 </Show>904905 {}906 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>907 <section class="peach-fields" style="position:relative;z-index:1;">908909 {910911}912 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>913 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>914 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>915 <Show916 when={editingField() === "username"}917 fallback={918 <div class="peach-field-value-row">919 <span920 class="peach-field-value peach-inline-clickable"921 title="Click to edit"922 onClick={() => startEdit("username", entry().username ?? "")}923 >924 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}925 </span>926 <Show when={entry().username}>927 <button928 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}929 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}930 aria-label="Copy username"931 >932 <CopyGlyph /><span class="peach-copy-sheen" />933 </button>934 </Show>935 </div>936 }937 >938 <div939 class="peach-inline-field-wrap"940 onFocusOut={(event) => handleEditorFocusOut("username", event)}941 >942 <input943 class="peach-inline-input"944 value={draft()}945 onInput={(e) => setDraft(e.currentTarget.value)}946 onKeyDown={(e) => identityKeyDown(e, "username")}947 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}948 disabled={saving()}949 autocomplete="off"950 />951 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />952 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>953 <IdentitySuggestList954 listRef={(el) => (usernameIdentityEl = el)}955 inputRef={() => usernameInlineInput}956 suggestions={identitySuggestions()}957 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}958 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}959 />960 </Show>961 {962963964}965 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>966 <button967 class="peach-add-field-btn"968 type="button"969 onMouseDown={(e) => e.preventDefault()}970 onClick={() => {971 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);972 const hasSeparateEmail = !!entry().email;973 if (isEmailShape && !hasSeparateEmail) {974 void addOtherIdentity("username");975 } else {976 void addOtherIdentity("email");977 }978 }}979 >980 {(() => {981 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);982 const hasSeparateEmail = !!entry().email;983 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";984 })()}985 </button>986 </Show>987 </div>988 </Show>989 </div>990 </Show>991992 {993}994 <Show when={entry().email || editingField() === "email"}>995 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>996 <span class="peach-label">email</span>997 <Show998 when={editingField() === "email"}999 fallback={1000 <div class="peach-field-value-row">1001 <span1002 class="peach-field-value peach-inline-clickable"1003 title="Click to edit"1004 onClick={() => startEdit("email", entry().email ?? "")}1005 >1006 {entry().email}1007 </span>1008 <Show when={entry().email}>1009 <button1010 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1011 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1012 aria-label="Copy email"1013 >1014 <CopyGlyph /><span class="peach-copy-sheen" />1015 </button>1016 </Show>1017 </div>1018 }1019 >1020 <div1021 class="peach-inline-field-wrap"1022 onFocusOut={(event) => handleEditorFocusOut("email", event)}1023 >1024 <input1025 class="peach-inline-input"1026 value={draft()}1027 onInput={(e) => setDraft(e.currentTarget.value)}1028 onKeyDown={(e) => identityKeyDown(e, "email")}1029 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1030 disabled={saving()}1031 autocomplete="email"1032 type="email"1033 />1034 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1035 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1036 <IdentitySuggestList1037 listRef={(el) => (emailIdentityEl = el)}1038 inputRef={() => emailInlineInput}1039 suggestions={identitySuggestions()}1040 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1041 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1042 />1043 </Show>1044 <Show when={!entry().username || entry().username?.includes("@")}>1045 <button1046 class="peach-add-field-btn"1047 type="button"1048 onMouseDown={(e) => e.preventDefault()}1049 onClick={() => void addOtherIdentity("username")}1050 >1051 + add username1052 </button>1053 </Show>1054 </div>1055 </Show>1056 </div>1057 </Show>10581059 {}1060 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1061 <span class="peach-label">password</span>1062 <Show1063 when={editingField() === "password"}1064 fallback={1065 <div class="peach-field-value-row">1066 <span1067 class="peach-password-value"1068 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1069 style="cursor:pointer;"1070 title="Click to edit password"1071 onClick={() => startEdit("password", entry().password ?? "")}1072 >1073 <Show1074 when={showPassword()}1075 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1076 >1077 <span class="peach-password-text">{entry().password ?? ""}</span>1078 </Show>1079 </span>1080 <div class="peach-field-actions">1081 <button1082 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1083 onClick={() => setShowPassword(!showPassword())}1084 aria-label="Reveal password"1085 >1086 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1087 <EyeClosedGlyph />1088 </Show>1089 </button>1090 <button1091 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1092 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1093 aria-label="Copy password"1094 >1095 <CopyGlyph /><span class="peach-copy-sheen" />1096 </button>1097 </div>1098 </div>1099 }1100 >1101 {}1102 <div1103 class="peach-inline-field-wrap"1104 onFocusOut={(event) => handleEditorFocusOut("password", event)}1105 >1106 <div class="peach-password-edit-row">1107 <input1108 class="peach-inline-input"1109 type={showPassword() ? "text" : "password"}1110 placeholder="paste or type a password"1111 value={draft()}1112 onInput={(e) => setDraft(e.currentTarget.value)}1113 onKeyDown={(e) => {1114 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1115 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1116 }}1117 ref={autoFocus}1118 disabled={saving()}1119 autocomplete="new-password"1120 />1121 <button1122 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1123 type="button"1124 onClick={() => setShowPassword(!showPassword())}1125 aria-label={showPassword() ? "Hide" : "Show"}1126 >1127 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1128 <EyeClosedGlyph />1129 </Show>1130 </button>1131 </div>1132 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1133 <button1134 class="peach-generate-toggle-link"1135 type="button"1136 onClick={() => setShowGenerator(!showGenerator())}1137 >1138 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1139 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1140 <path d="M9.5 2.5H11V1" />1141 </svg>1142 {showGenerator() ? "hide generator" : "generate"}1143 </button>1144 <Show when={showGenerator()}>1145 <GeneratorDrawer1146 open={true}1147 compact={true}1148 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1149 />1150 </Show>1151 </div>1152 </Show>1153 </div>11541155 </section>1156 </Show>11571158 {}1159 <Show when={entry().type !== "note" && entry().type !== "card"}>1160 <Show1161 when={entry().notes || editingField() === "notes"}1162 fallback={1163 <button1164 class="peach-add-field-link peach-entry-add-link"1165 onClick={() => startEdit("notes", "")}1166 >1167 + notes1168 </button>1169 }1170 >1171 <div style="position:relative;z-index:1;margin-top:6px;">1172 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1173 <Show1174 when={editingField() === "notes"}1175 fallback={1176 <div1177 class="peach-note-content peach-inline-clickable"1178 style="cursor:text;white-space:pre-wrap;"1179 onClick={() => startEdit("notes", entry().notes ?? "")}1180 >1181 {entry().notes}1182 </div>1183 }1184 >1185 <div1186 class="peach-inline-field-wrap"1187 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1188 >1189 <textarea1190 class="peach-inline-textarea"1191 value={draft()}1192 onInput={(e) => setDraft(e.currentTarget.value)}1193 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1194 ref={(el) => requestAnimationFrame(() => el?.focus())}1195 rows={4}1196 disabled={saving()}1197 />1198 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1199 </div>1200 </Show>1201 </div>1202 </Show>1203 </Show>12041205 {}1206 <Show1207 when={editingTags()}1208 fallback={1209 <Show when={!entry().tags || entry().tags!.length === 0}>1210 <button1211 class="peach-add-field-link peach-entry-add-link"1212 onClick={startEditTags}1213 >1214 + tags1215 </button>1216 </Show>1217 }1218 >1219 <div1220 class="peach-inline-tag-editor"1221 onFocusOut={(e) => {1222 const next = e.relatedTarget;1223 if (next instanceof Node && e.currentTarget.contains(next)) return;1224 void saveTags();1225 }}1226 >1227 <span class="peach-label">tags</span>1228 <div class="peach-tag-input-wrap">1229 <For each={tagsDraft()}>1230 {(tag) => (1231 <span class="peach-tag-chip">1232 {tag}1233 <button1234 class="peach-tag-chip-remove"1235 onMouseDown={(e) => e.preventDefault()}1236 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1237 aria-label={`Remove ${tag}`}1238 >×</button>1239 </span>1240 )}1241 </For>1242 <input1243 class="peach-tag-input"1244 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1245 value={tagInput()}1246 onInput={(e) => setTagInput(e.currentTarget.value)}1247 onKeyDown={(e) => {1248 if (e.key === "Enter" || e.key === ",") {1249 e.preventDefault();1250 addTag(tagInput());1251 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1252 setTagsDraft(tagsDraft().slice(0, -1));1253 } else if (e.key === "Escape") {1254 e.preventDefault();1255 setEditingTags(false);1256 setTagInput("");1257 }1258 }}1259 ref={(el) => requestAnimationFrame(() => el?.focus())}1260 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1261 />1262 </div>1263 </div>1264 </Show>12651266 {}1267 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1268 <Show1269 when={totpState() || editingField() === "totp"}1270 fallback={1271 <button1272 class="peach-add-field-link peach-entry-add-link"1273 onClick={() => startEdit("totp", "")}1274 >1275 + 2FA1276 </button>1277 }1278 >1279 <section class="peach-totp-band">1280 <Show1281 when={editingField() === "totp"}1282 fallback={1283 <Show when={totpState()}>1284 {(state) => (1285 <>1286 <div1287 class="peach-totp-main"1288 onClick={() => {1289 1290 1291 if (totpClickTimer) return;1292 totpClickTimer = setTimeout(() => {1293 totpClickTimer = null;1294 void copyField(state().code.replace(" ", ""), "totp");1295 }, 220);1296 }}1297 onDblClick={() => {1298 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1299 startEdit("totp", entry().totp ?? "");1300 }}1301 title="Click to copy · double-click to edit"1302 >1303 <span class="peach-label">2-factor</span>1304 <div class="peach-totp-code-row">1305 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1306 <span class="peach-totp-code-text">1307 {state().code.slice(0, 3)} {state().code.slice(3)}1308 </span>1309 </p>1310 </div>1311 </div>1312 <div class="peach-totp-meta">1313 <span class="peach-label">expires</span>1314 <div class="peach-totp-countdown">{state().remaining}s</div>1315 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1316 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1317 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1318 stroke-dasharray="106.8"1319 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1320 />1321 </svg>1322 </div>1323 </>1324 )}1325 </Show>1326 }1327 >1328 <div1329 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1330 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1331 >1332 <span class="peach-label">2-factor</span>1333 <input1334 class="peach-inline-input"1335 value={draft()}1336 onInput={(e) => setDraft(e.currentTarget.value)}1337 onKeyDown={onEditKeyDown}1338 ref={autoFocus}1339 disabled={saving()}1340 placeholder="otpauth://... or base32 secret"1341 autocomplete="off"1342 />1343 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1344 </div>1345 </Show>1346 </section>1347 </Show>1348 </Show>13491350 {}1351 <Show when={entry().type === "login"}>1352 <button1353 class="peach-add-field-link peach-entry-add-link"1354 onClick={() => setShowAdvanced((v) => !v)}1355 aria-expanded={showAdvanced()}1356 >1357 {showAdvanced() ? "− Advanced" : "+ Advanced"}1358 </button>1359 <Show when={showAdvanced()}>1360 <Show when={!isPasskeyOnly(entry())}>1361 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1362 </Show>13631364 <Show when={entry().passkey}>1365 {(passkey) => {1366 const currentBrowser = detectBrowser();1367 const crossBrowser =1368 passkey().browser &&1369 currentBrowser !== "unknown" &&1370 passkey().browser !== currentBrowser;1371 const algorithmLabel =1372 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1373 const formatDate = (ts?: number) =>1374 ts ? new Date(ts).toLocaleDateString() : "Never";1375 return (1376 <section1377 class="peach-fields peach-passkey-section"1378 style="position:relative;z-index:1;margin-top:6px;"1379 >1380 <div class="peach-field-card">1381 <span class="peach-label">passkey · {passkey().rpId}</span>1382 <div class="peach-field-value-row">1383 <span class="peach-field-value">{passkey().userName}</span>1384 <span class="peach-passkey-badge">passkey</span>1385 </div>1386 </div>13871388 <div class="peach-field-card">1389 <span class="peach-label">algorithm</span>1390 <div class="peach-field-value-row">1391 <span class="peach-field-value">{algorithmLabel}</span>1392 </div>1393 </div>13941395 <div class="peach-field-card">1396 <span class="peach-label">activity</span>1397 <div class="peach-field-value-row peach-passkey-activity">1398 <span class="peach-field-value">1399 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1400 </span>1401 <span class="peach-passkey-meta">1402 created {formatDate(passkey().createdAt)}1403 </span>1404 <span class="peach-passkey-meta">1405 last used {formatDate(passkey().lastUsedAt)}1406 </span>1407 </div>1408 </div>14091410 <Show when={crossBrowser}>1411 <div class="peach-info-notice peach-info-notice--warn">1412 {`This passkey was created on ${1413 passkey().browser === "chrome" ? "Chrome" : "Firefox"1414 }. To use it on ${1415 currentBrowser === "chrome" ? "Chrome" : "Firefox"1416 }, re-register it on this device.`}1417 </div>1418 </Show>14191420 <div class="peach-info-notice">1421 {passkey().browser === "firefox"1422 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1423 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1424 </div>14251426 <button1427 class="peach-add-field-link peach-entry-add-link"1428 style="color:rgba(220,38,38,0.7);"1429 onClick={async () => {1430 const id = entry().id;1431 await sendMessage({1432 type: "UPDATE_ENTRY",1433 payload: { id, patch: { passkey: undefined } },1434 });1435 await props.onFieldSaved();1436 }}1437 >1438 − remove passkey1439 </button>1440 </section>1441 );1442 }}1443 </Show>1444 </Show>1445 </Show>14461447 {}1448 <div style="height:4px;" />1449 </>1450 )}1451 </Show>1452 </div>14531454 </>1455 );1456}1457145814591460function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1461 return (1462 <div class="peach-inline-actions">1463 <button1464 class="peach-inline-save-btn"1465 onClick={props.onSave}1466 disabled={props.saving}1467 aria-label="Save"1468 title="Save (Enter)"1469 >1470 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1471 <path d="M1 4.5L4 7.5L10 1.5" />1472 </svg>1473 </button>1474 <button1475 class="peach-inline-cancel-btn"1476 onClick={props.onCancel}1477 disabled={props.saving}1478 aria-label="Cancel"1479 title="Cancel (Esc)"1480 >1481 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1482 <path d="M1 1L8 8M8 1L1 8" />1483 </svg>1484 </button>1485 </div>1486 );1487}14881489function EmptyVaultState() {1490 return (1491 <>1492 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1493 <div class="peach-entry-name">1494 <span class="peach-entry-name-text">1495 <span class="peach-entry-name-char" style="--char-index:0">e</span>1496 <span class="peach-entry-name-char" style="--char-index:1">m</span>1497 <span class="peach-entry-name-char" style="--char-index:2">p</span>1498 <span class="peach-entry-name-char" style="--char-index:3">t</span>1499 <span class="peach-entry-name-char" style="--char-index:4">y</span>1500 </span>1501 </div>1502 </div>1503 <div class="peach-empty-copy">1504 <span class="peach-label">vault</span>1505 <p>Your vault is ready for its first entry.</p>1506 </div>1507 </>1508 );1509}15101511function CopyGlyph() {1512 return (1513 <span class="peach-copy-icon">1514 <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">1515 <rect x="4" y="4" width="8" height="9" rx="1.5" />1516 <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" />1517 </svg>1518 </span>1519 );1520}15211522function EyeClosedGlyph() {1523 return (1524 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1525 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1526 <path d="M3.5 5.5L2.5 7.5" />1527 <path d="M7 6.5V8.5" />1528 <path d="M10.5 5.5L11.5 7.5" />1529 </svg>1530 );1531}15321533function EyeOpenGlyph() {1534 return (1535 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1536 <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" />1537 <circle cx="7" cy="5" r="1.75" />1538 </svg>1539 );1540}15411542function ChevronGlyph(props: { open: boolean }) {1543 return (1544 <svg1545 width="8"1546 height="8"1547 viewBox="0 0 8 8"1548 fill="none"1549 stroke="currentColor"1550 stroke-width="1.3"1551 stroke-linecap="round"1552 stroke-linejoin="round"1553 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1554 >1555 <path d="M2 1L6 4L2 7" />1556 </svg>1557 );1558}15591560function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1561 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1562 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1563 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1564 const [testResult, setTestResult] = createSignal<string | null>(null);1565 const [testing, setTesting] = createSignal(false);15661567 async function save() {1568 const url = webhookUrl().trim();1569 const nextLabel = label().trim() || undefined;1570 const canary = enabled() && url1571 ? {1572 webhookUrl: url,1573 label: nextLabel,1574 lastPinged: props.entry.canary?.lastPinged,1575 pingCount: props.entry.canary?.pingCount,1576 }1577 : undefined;1578 await sendMessage({1579 type: "UPDATE_ENTRY",1580 payload: { id: props.entry.id, patch: { canary } },1581 });1582 await props.onSaved();1583 }15841585 async function testPing() {1586 const url = webhookUrl().trim();1587 if (!url) return;1588 setTesting(true);1589 setTestResult(null);1590 const res = await sendMessage({1591 type: "FIRE_CANARY_PING",1592 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1593 }) as { ok: boolean; error?: string };1594 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1595 setTesting(false);1596 }15971598 return (1599 <div class="peach-canary-section">1600 <div class="peach-field-value-row">1601 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1602 <button1603 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1604 onClick={() => { setEnabled(!enabled()); void save(); }}1605 aria-label="Toggle canary"1606 >1607 <span class="peach-toggle-knob" />1608 </button>1609 </div>1610 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1611 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1612 </p>1613 <Show when={enabled()}>1614 <div class="peach-inline-field-wrap" style="margin-top:8px;">1615 <input1616 class="peach-inline-input"1617 placeholder="https://hooks.zapier.com/..."1618 value={webhookUrl()}1619 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1620 onBlur={() => void save()}1621 />1622 <input1623 class="peach-inline-input"1624 placeholder="label (optional, e.g. AWS decoy)"1625 value={label()}1626 onInput={(e) => setLabel(e.currentTarget.value)}1627 onBlur={() => void save()}1628 />1629 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1630 <button1631 class="peach-btn peach-btn--ghost peach-btn--sm"1632 onClick={() => void testPing()}1633 disabled={testing() || !webhookUrl().trim()}1634 >1635 {testing() ? "sending..." : "test ping"}1636 </button>1637 <Show when={testResult()}>1638 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1639 </Show>1640 </div>1641 <Show when={props.entry.canary?.lastPinged}>1642 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1643 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1644 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1645 </p>1646 </Show>1647 </div>1648 </Show>1649 </div>1650 );1651}