1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { ProFeature, 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 3031 isPro?: boolean;32 3334 onProGate?: (feature: ProFeature) => void;35}3637export function EntryView(props: EntryProps) {38 const [editingField, setEditingField] = createSignal<EditableField>(null);39 const [draft, setDraft] = createSignal("");40 const [saving, setSaving] = createSignal(false);41 const [showPassword, setShowPassword] = createSignal(false);42 const [showGenerator, setShowGenerator] = createSignal(false);43 const [copied, setCopied] = createSignal<string | null>(null);44 45 46 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);47 const [editingTags, setEditingTags] = createSignal(false);48 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);49 const [tagInput, setTagInput] = createSignal("");50 const [showCardNumber, setShowCardNumber] = createSignal(false);51 const [showCardCvc, setShowCardCvc] = createSignal(false);52 const [totpTick, setTotpTick] = createSignal(0);53 const [confirmDelete, setConfirmDelete] = createSignal(false);54 const [totpHot, setTotpHot] = createSignal(false);55 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);56 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);57 58 const [showAdvanced, setShowAdvanced] = createSignal(false);59 const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);60 let usernameInlineInput: HTMLInputElement | undefined;61 let emailInlineInput: HTMLInputElement | undefined;62 let usernameIdentityEl: HTMLDivElement | undefined;63 let emailIdentityEl: HTMLDivElement | undefined;64 let lastSavedNote: string | null = null;65 let totpClickTimer: ReturnType<typeof setTimeout> | null = null;66 onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });6768 function noteExec(cmd: string, val?: string) {69 document.execCommand(cmd, false, val);70 noteEditorEl()?.focus();71 }7273 async function saveNoteInline(html: string) {74 const entry = props.entry;75 if (!entry) return;76 const currentStored = entry.notes ?? "";77 if (html === currentStored) return;78 if (lastSavedNote !== null && html === lastSavedNote) return;79 lastSavedNote = html;80 setSaving(true);81 try {82 await sendMessage({83 type: "UPDATE_ENTRY",84 payload: { id: entry.id, patch: { notes: html } },85 });86 triggerRing();87 await props.onFieldSaved();88 } catch {89 lastSavedNote = null;90 } finally {91 setSaving(false);92 }93 }9495 const allIdentities = createMemo(() => {96 const emailMap = new Map<string, number>();97 const usernameMap = new Map<string, number>();98 for (const e of props.entries ?? []) {99 const uname = e.username?.trim() ?? "";100 const em = e.email?.trim() ?? "";101 if (uname) {102 if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);103 else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);104 }105 if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);106 }107 const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];108 const seen = new Set<string>();109 for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {110 if (seen.has(v)) continue;111 seen.add(v);112 const ec = emailMap.get(v) ?? 0;113 const uc = usernameMap.get(v) ?? 0;114 result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });115 }116 return result.sort((a, b) => b.count - a.count);117 });118119 const identitySuggestions = createMemo(() => {120 const field = editingField();121 if (field !== "username" && field !== "email") return [];122 const input = draft().toLowerCase();123 return allIdentities()124 .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))125 .slice(0, 8);126 });127128 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);129 onCleanup(() => window.clearInterval(interval));130131 const displayDomain = createMemo(() =>132 props.entry?.url ? getDisplayDomain(props.entry.url) : ""133 );134135 createEffect(() => {136 const domain = displayDomain();137 setFaviconSrc(domain ? faviconUrl(domain) : null);138 });139140 141 142 143 144 145 let lastEntryId: string | null = null;146 createEffect(() => {147 const id = props.entry?.id ?? null;148 if (id === lastEntryId) return;149 lastEntryId = id;150 setConfirmDelete(false);151 setEditingField(null);152 setEditingTags(false);153 setShowAdvanced(false);154 });155156 const totpState = createMemo(() => {157 void totpTick();158 if (!props.entry?.totp) return null;159 return generateTOTP(props.entry.totp);160 });161162 let prevTotpCode: string | undefined;163 createEffect(() => {164 const state = totpState();165 if (!state) { prevTotpCode = undefined; return; }166 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {167 setTotpHot(true);168 window.setTimeout(() => setTotpHot(false), 3000);169 }170 prevTotpCode = state.code;171 });172173 const totpProgress = createMemo(() => {174 const state = totpState();175 if (!state) return 0;176 return ((state.remaining - 1) / 29) * 100;177 });178179 createEffect(() => {180 const shell = document.getElementById("hd-shell");181 if (!shell) return;182183 const state = totpState();184 if (!state) {185 delete shell.dataset.totpActive;186 shell.style.removeProperty("--peach-totp-progress");187 return;188 }189190 shell.dataset.totpActive = "true";191 shell.style.setProperty(192 "--peach-totp-progress",193 `${Math.max(0, totpProgress())}%`194 );195196 onCleanup(() => {197 delete shell.dataset.totpActive;198 shell.style.removeProperty("--peach-totp-progress");199 });200 });201202 const titleSizeClass = createMemo(() => {203 const length = props.entry?.title.length ?? 0;204 if (length >= 28) return "peach-entry-name--xxl";205 if (length >= 20) return "peach-entry-name--xl";206 if (length >= 14) return "peach-entry-name--lg";207 return "";208 });209 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);210211 212213 function startEdit(field: EditableField, value: string) {214 setEditingField(field);215 setDraft(value);216 setIdentityHelperSuppressed(false);217 setShowGenerator(false);218 setConfirmDelete(false);219 if (field === "url") {220 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);221 }222 }223224 225 226 227 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {228 const c = candidate.trim();229 if (!c) return true;230 if (primary.trim() === c) return true;231 return others.some((u) => u.trim() === c);232 }233234 function addCurrentSite(url: string) {235 if (!url) return;236 if (!draft().trim()) {237 setDraft(url);238 return;239 }240 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;241 setAdditionalUrlsDraft((arr) => [...arr, url]);242 }243244 async function saveField() {245 const field = editingField();246 const entry = props.entry;247 if (!field || !entry) return;248 249 let value = field === "password" ? draft() : draft().trim();250 if (field === "totp" && value.startsWith("otpauth://")) {251 const parsed = parseOtpauthURI(value);252 value = parsed?.secret ?? value;253 }254 if (field === "password" && entry.type !== "note" && !value) {255 return;256 }257 setSaving(true);258 try {259 const patch: Partial<VaultEntry> = { [field]: value };260 if (field === "url") {261 262 patch.additionalUrls = additionalUrlsDraft();263 }264 await sendMessage({265 type: "UPDATE_ENTRY",266 payload: { id: entry.id, patch },267 });268 triggerRing();269 setEditingField(null);270 setShowGenerator(false);271 await props.onFieldSaved();272 } catch {273 274 } finally {275 setSaving(false);276 }277 }278279 function startEditTags() {280 setTagsDraft([...(props.entry?.tags ?? [])]);281 setTagInput("");282 setEditingTags(true);283 }284285 function addTag(value: string) {286 const n = value.toLowerCase().trim();287 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;288 setTagsDraft([...tagsDraft(), n]);289 setTagInput("");290 }291292 async function saveTags() {293 if (tagInput()) addTag(tagInput());294 const entry = props.entry;295 if (!entry) return;296 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;297 setEditingTags(false);298 setSaving(true);299 try {300 await sendMessage({301 type: "UPDATE_ENTRY",302 payload: { id: entry.id, patch: { tags: next } },303 });304 triggerRing();305 await props.onFieldSaved();306 } catch {307 308 } finally {309 setSaving(false);310 }311 }312313 function cancelEdit() {314 setEditingField(null);315 setDraft("");316 setShowGenerator(false);317 }318319 320 321 322 323 324 325 326 327 328 async function addOtherIdentity(target: "username" | "email") {329 const entry = props.entry;330 if (!entry) return;331 const current = editingField();332 const draftValue = draft().trim();333 const savedUsername = entry.username ?? "";334 const savedEmail = entry.email ?? "";335336 if (current === "username" && target === "username") {337 338 339 if (savedUsername.includes("@") && !entry.email) {340 setSaving(true);341 try {342 await sendMessage({343 type: "UPDATE_ENTRY",344 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },345 });346 await props.onFieldSaved();347 } finally {348 setSaving(false);349 }350 }351 startEdit("username", "");352 return;353 }354355 if (current === "username" && target === "email") {356 if (draftValue && draftValue !== savedUsername) {357 setSaving(true);358 try {359 await sendMessage({360 type: "UPDATE_ENTRY",361 payload: { id: entry.id, patch: { username: draftValue } },362 });363 await props.onFieldSaved();364 } finally {365 setSaving(false);366 }367 }368 startEdit("email", "");369 return;370 }371372 if (current === "email" && target === "username") {373 if (draftValue && draftValue !== savedEmail) {374 setSaving(true);375 try {376 await sendMessage({377 type: "UPDATE_ENTRY",378 payload: { id: entry.id, patch: { email: draftValue } },379 });380 await props.onFieldSaved();381 } finally {382 setSaving(false);383 }384 }385 startEdit("username", "");386 return;387 }388 }389390 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {391 switch (field) {392 case "title":393 return entry.title;394 case "url":395 return entry.url ?? "";396 case "username":397 return entry.username ?? "";398 case "email":399 return entry.email ?? "";400 case "password":401 return entry.password ?? "";402 case "notes":403 return entry.notes ?? "";404 case "totp":405 return entry.totp ?? "";406 }407 }408409 function handleEditorFocusOut(410 field: Exclude<EditableField, null>,411 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }412 ) {413 const nextTarget = event.relatedTarget;414 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;415416 417 418 419 if (editingField() !== field) return;420421 const entry = props.entry;422 if (!entry) return;423424 const currentValue = fieldValue(entry, field);425 const nextValue = field === "password" ? draft() : draft().trim();426 if (nextValue === currentValue) {427 cancelEdit();428 }429 }430431 432433 async function copyField(text: string, label: string, button?: HTMLButtonElement) {434 const ok = await popupCopy(text, label);435 if (ok) {436 const entry = props.entry;437 if (entry?.canary?.webhookUrl) {438 const trigger =439 label === "password"440 ? "password_copied"441 : label === "username"442 ? "username_copied"443 : undefined;444 if (trigger) {445 void sendMessage({446 type: "FIRE_CANARY_PING",447 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },448 });449 }450 }451 triggerRing();452 triggerPipPulse();453 setCopied(null);454 requestAnimationFrame(() => setCopied(label));455 if (button) {456 button.classList.remove("is-copied", "is-retreat");457 void button.offsetWidth;458 const drain = document.createElement("span");459 drain.className = "peach-copy-drain";460 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";461 drain.appendChild(bar);462 button.appendChild(drain);463 window.setTimeout(() => {464 button.classList.remove("is-copied");465 button.classList.add("is-retreat");466 drain.remove();467 window.setTimeout(() => button.classList.remove("is-retreat"), 160);468 }, 1800);469 }470 window.setTimeout(() => setCopied(null), 1800);471 }472 }473474 475476 createEffect(() => {477 if (!props.entry) return;478 const handler = (e: KeyboardEvent) => {479 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;480 if (inInput) return;481 switch (e.key) {482 case "c": e.preventDefault();483 if (props.entry?.password) void copyField(props.entry.password, "password"); break;484 case "u": e.preventDefault();485 if (props.entry?.username) void copyField(props.entry.username, "username"); break;486 case "Escape": e.preventDefault(); props.onEscape?.(); break;487 }488 };489 window.addEventListener("keydown", handler);490 onCleanup(() => window.removeEventListener("keydown", handler));491 });492493 494495 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {496 requestAnimationFrame(() => { el.focus(); el.select?.(); });497 }498499 function onEditKeyDown(e: KeyboardEvent) {500 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {501 e.preventDefault();502 void saveField();503 }504 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }505 }506507 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {508 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;509 if (e.key === "ArrowDown" && listEl) {510 e.preventDefault();511 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();512 return;513 }514 onEditKeyDown(e);515 }516517 return (518 <>519 <div520 class="peach-ghost"521 aria-hidden="true"522 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:var(--peach-ghost-opacity,0.03);pointer-events:none;line-height:1.25;overflow:hidden;white-space:nowrap;z-index:0;"523 >524 {props.entry?.title || "empty"}525 </div>526527 <div class="peach-stage" style="position:relative;">528 <Show when={props.entry} fallback={<EmptyVaultState />}>529 {(entry) => (530 <>531 {}532 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>533 <div class="peach-breach-badge">534 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">535 <path d="M6 1L11 10H1L6 1z" />536 <line x1="6" y1="5" x2="6" y2="7.5" />537 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />538 </svg>539 <span style="flex:1">found in a known data breach</span>540 <button541 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"542 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}543 >544 Change password545 </button>546 </div>547 </Show>548549 {}550 <div class="peach-entry-header">551 <div class="peach-entry-header-name">552 <Show553 when={editingField() === "title"}554 fallback={555 <button556 class="peach-entry-name-button"557 title="Click to edit title"558 onClick={() => startEdit("title", entry().title)}559 >560 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>561 <span class="peach-entry-name-text">562 <Show563 when={useAnimatedTitle()}564 fallback={entry().title}565 >566 <For each={entry().title.split("")}>567 {(ch, i) => (568 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>569 {ch === " " ? "\u00A0" : ch}570 </span>571 )}572 </For>573 </Show>574 </span>575 </h1>576 </button>577 }578 >579 <div580 class="peach-inline-title-wrap"581 onFocusOut={(event) => handleEditorFocusOut("title", event)}582 >583 <input584 class="peach-inline-title-input"585 value={draft()}586 onInput={(e) => setDraft(e.currentTarget.value)}587 onKeyDown={onEditKeyDown}588 ref={autoFocus}589 disabled={saving()}590 />591 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />592 </div>593 </Show>594 </div>595596 {}597 <Show when={editingField() !== "title"}>598 <Show599 when={confirmDelete()}600 fallback={601 <button602 class="peach-entry-trash-btn"603 onClick={() => setConfirmDelete(true)}604 aria-label="Move to trash"605 title="Delete entry"606 >607 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">608 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />609 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />610 </svg>611 </button>612 }613 >614 <div class="peach-entry-trash-confirm">615 <span class="peach-entry-trash-confirm-label">trash?</span>616 <button617 class="peach-btn peach-btn--sm peach-btn--danger"618 style="font-size:10px;padding:2px 8px;min-height:22px;"619 onClick={() => { setConfirmDelete(false); props.onDelete(); }}620 >621 yes622 </button>623 <button624 class="peach-btn peach-btn--ghost peach-btn--sm"625 style="font-size:10px;padding:2px 8px;min-height:22px;"626 onClick={() => setConfirmDelete(false)}627 >628 no629 </button>630 </div>631 </Show>632 </Show>633 </div>634635 {}636 <Show when={entry().type !== "note" && entry().type !== "card"}>637 <div class="peach-domain" style="position:relative;z-index:1;">638 <Show639 when={editingField() === "url"}640 fallback={641 <div642 class="peach-domain-row peach-inline-clickable"643 title="Click to edit URL"644 onClick={() => startEdit("url", entry().url ?? "")}645 >646 <Show when={faviconSrc()}>647 <img648 src={faviconSrc()!}649 width="16"650 height="16"651 alt=""652 style="border-radius:3px;margin-right:5px;flex-shrink:0;"653 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}654 />655 </Show>656 <span class="peach-domain-value">657 {displayDomain() || (entry().url ? entry().url : "add a website url")}658 </span>659 <span class="peach-inline-edit-hint">edit</span>660 </div>661 }662 >663 <div664 class="peach-inline-field-wrap peach-inline-field-wrap--url"665 onFocusOut={(event) => handleEditorFocusOut("url", event)}666 >667 <div class="peach-inline-field-row">668 <input669 class="peach-inline-input"670 type="url"671 placeholder="https://"672 value={draft()}673 onInput={(e) => setDraft(e.currentTarget.value)}674 onKeyDown={onEditKeyDown}675 ref={autoFocus}676 disabled={saving()}677 />678 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />679 </div>680 <div class="peach-additional-urls">681 <Show when={additionalUrlsDraft().length > 0}>682 <span class="peach-label peach-additional-urls-label">also matches</span>683 </Show>684 <For each={additionalUrlsDraft()}>685 {(value, i) => (686 <div class="peach-additional-url-row">687 <input688 class="peach-inline-input"689 type="url"690 placeholder="https://"691 value={value}692 onInput={(e) => setAdditionalUrlsDraft((arr) => {693 const next = [...arr];694 next[i()] = e.currentTarget.value;695 return next;696 })}697 disabled={saving()}698 />699 <button700 class="peach-additional-url-remove"701 type="button"702 aria-label="Remove URL"703 onMouseDown={(e) => e.preventDefault()}704 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}705 disabled={saving()}706 >×</button>707 </div>708 )}709 </For>710 <button711 class="peach-add-field-link peach-additional-urls-add"712 type="button"713 onMouseDown={(e) => e.preventDefault()}714 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}715 disabled={saving()}716 >717 + add another url718 </button>719 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>720 <button721 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"722 type="button"723 title={props.currentTabUrl}724 onMouseDown={(e) => e.preventDefault()}725 onClick={() => addCurrentSite(props.currentTabUrl!)}726 disabled={saving()}727 >728 + add current site729 </button>730 </Show>731 </div>732 </div>733 </Show>734 </div>735 </Show>736737 {}738 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>739 <div740 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"741 onClick={startEditTags}742 title="Click to edit tags"743 >744 <For each={entry().tags!}>745 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}746 </For>747 </div>748 </Show>749750 {}751 <Show when={entry().type === "note"}>752 <div class="peach-entry-note-toolbar-row">753 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />754 </div>755 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">756 <NoteEditorInline757 entryKey={entry().id}758 initialContent={entry().notes ?? ""}759 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}760 onBlur={saveNoteInline}761 autoFocus={false}762 />763 </section>764 </Show>765766 {}767 <Show when={entry().type === "card"}>768 <section class="peach-fields" style="position:relative;z-index:1;">769 {}770 <Show when={entry().last4}>771 <div class="peach-field-card" style="padding-bottom:4px;">772 <span class="peach-label">773 <Show when={entry().network && entry().network !== "unknown"}>774 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>775 </Show>776 {entry().cardNickname || `•••• ${entry().last4}`}777 </span>778 </div>779 </Show>780781 {}782 <div class="peach-field-card">783 <span class="peach-label">cardholder name</span>784 <div class="peach-field-value-row">785 <span class="peach-field-value">{entry().cardholderName || "—"}</span>786 <Show when={entry().cardholderName}>787 <button788 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}789 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}790 aria-label="Copy cardholder name"791 >792 <CopyGlyph /><span class="peach-copy-sheen" />793 </button>794 </Show>795 </div>796 </div>797798 {}799 <div class="peach-field-card">800 <span class="peach-label">card number</span>801 <div class="peach-field-value-row">802 <span class={`peach-field-value peach-card-number`}>803 <Show when={showCardNumber()} fallback={804 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>805 }>806 <span>{entry().cardNumber ?? ""}</span>807 </Show>808 </span>809 <div class="peach-field-actions">810 <button811 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"812 onClick={() => setShowCardNumber(!showCardNumber())}813 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}814 >815 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>816 <EyeClosedGlyph />817 </Show>818 </button>819 <Show when={entry().cardNumber}>820 <button821 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}822 onClick={(e) => {823 const cardNum = entry().cardNumber!;824 if (entry().canary?.webhookUrl) {825 void sendMessage({826 type: "FIRE_CANARY_PING",827 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },828 });829 }830 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);831 }}832 aria-label="Copy card number"833 >834 <CopyGlyph /><span class="peach-copy-sheen" />835 </button>836 </Show>837 </div>838 </div>839 </div>840841 {}842 <Show when={entry().expMonth || entry().expYear}>843 <div class="peach-field-card">844 <span class="peach-label">expiry</span>845 <div class="peach-field-value-row">846 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>847 <button848 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}849 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}850 aria-label="Copy expiry"851 >852 <CopyGlyph /><span class="peach-copy-sheen" />853 </button>854 </div>855 </div>856 </Show>857858 {}859 <Show when={entry().cvc}>860 <div class="peach-field-card">861 <span class="peach-label">CVC</span>862 <div class="peach-field-value-row">863 <span class="peach-field-value">864 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>865 <span>{entry().cvc}</span>866 </Show>867 </span>868 <div class="peach-field-actions">869 <button870 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"871 onClick={() => setShowCardCvc(!showCardCvc())}872 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}873 >874 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>875 <EyeClosedGlyph />876 </Show>877 </button>878 <button879 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}880 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}881 aria-label="Copy CVC"882 >883 <CopyGlyph /><span class="peach-copy-sheen" />884 </button>885 </div>886 </div>887 </div>888 </Show>889 </section>890 </Show>891892 {}893 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>894 <section class="peach-fields" style="position:relative;z-index:1;">895896 {897898}899 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>900 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>901 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>902 <Show903 when={editingField() === "username"}904 fallback={905 <div class="peach-field-value-row">906 <span907 class="peach-field-value peach-inline-clickable"908 title="Click to edit"909 onClick={() => startEdit("username", entry().username ?? "")}910 >911 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}912 </span>913 <Show when={entry().username}>914 <button915 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}916 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}917 aria-label="Copy username"918 >919 <CopyGlyph /><span class="peach-copy-sheen" />920 </button>921 </Show>922 </div>923 }924 >925 <div926 class="peach-inline-field-wrap"927 onFocusOut={(event) => handleEditorFocusOut("username", event)}928 >929 <input930 class="peach-inline-input"931 value={draft()}932 onInput={(e) => setDraft(e.currentTarget.value)}933 onKeyDown={(e) => identityKeyDown(e, "username")}934 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}935 disabled={saving()}936 autocomplete="off"937 />938 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />939 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>940 <IdentitySuggestList941 listRef={(el) => (usernameIdentityEl = el)}942 inputRef={() => usernameInlineInput}943 suggestions={identitySuggestions()}944 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}945 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}946 />947 </Show>948 {949950951}952 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>953 <button954 class="peach-add-field-btn"955 type="button"956 onMouseDown={(e) => e.preventDefault()}957 onClick={() => {958 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);959 const hasSeparateEmail = !!entry().email;960 if (isEmailShape && !hasSeparateEmail) {961 void addOtherIdentity("username");962 } else {963 void addOtherIdentity("email");964 }965 }}966 >967 {(() => {968 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);969 const hasSeparateEmail = !!entry().email;970 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";971 })()}972 </button>973 </Show>974 </div>975 </Show>976 </div>977 </Show>978979 {980}981 <Show when={entry().email || editingField() === "email"}>982 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>983 <span class="peach-label">email</span>984 <Show985 when={editingField() === "email"}986 fallback={987 <div class="peach-field-value-row">988 <span989 class="peach-field-value peach-inline-clickable"990 title="Click to edit"991 onClick={() => startEdit("email", entry().email ?? "")}992 >993 {entry().email}994 </span>995 <Show when={entry().email}>996 <button997 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}998 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}999 aria-label="Copy email"1000 >1001 <CopyGlyph /><span class="peach-copy-sheen" />1002 </button>1003 </Show>1004 </div>1005 }1006 >1007 <div1008 class="peach-inline-field-wrap"1009 onFocusOut={(event) => handleEditorFocusOut("email", event)}1010 >1011 <input1012 class="peach-inline-input"1013 value={draft()}1014 onInput={(e) => setDraft(e.currentTarget.value)}1015 onKeyDown={(e) => identityKeyDown(e, "email")}1016 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1017 disabled={saving()}1018 autocomplete="email"1019 type="email"1020 />1021 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1022 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1023 <IdentitySuggestList1024 listRef={(el) => (emailIdentityEl = el)}1025 inputRef={() => emailInlineInput}1026 suggestions={identitySuggestions()}1027 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1028 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1029 />1030 </Show>1031 <Show when={!entry().username || entry().username?.includes("@")}>1032 <button1033 class="peach-add-field-btn"1034 type="button"1035 onMouseDown={(e) => e.preventDefault()}1036 onClick={() => void addOtherIdentity("username")}1037 >1038 + add username1039 </button>1040 </Show>1041 </div>1042 </Show>1043 </div>1044 </Show>10451046 {}1047 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1048 <span class="peach-label">password</span>1049 <Show1050 when={editingField() === "password"}1051 fallback={1052 <div class="peach-field-value-row">1053 <span1054 class="peach-password-value"1055 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1056 style="cursor:pointer;"1057 title="Click to edit password"1058 onClick={() => startEdit("password", entry().password ?? "")}1059 >1060 <Show1061 when={showPassword()}1062 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1063 >1064 <span class="peach-password-text">{entry().password ?? ""}</span>1065 </Show>1066 </span>1067 <div class="peach-field-actions">1068 <button1069 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1070 onClick={() => setShowPassword(!showPassword())}1071 aria-label="Reveal password"1072 >1073 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1074 <EyeClosedGlyph />1075 </Show>1076 </button>1077 <button1078 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1079 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1080 aria-label="Copy password"1081 >1082 <CopyGlyph /><span class="peach-copy-sheen" />1083 </button>1084 </div>1085 </div>1086 }1087 >1088 {}1089 <div1090 class="peach-inline-field-wrap"1091 onFocusOut={(event) => handleEditorFocusOut("password", event)}1092 >1093 <div class="peach-password-edit-row">1094 <input1095 class="peach-inline-input"1096 type={showPassword() ? "text" : "password"}1097 placeholder="paste or type a password"1098 value={draft()}1099 onInput={(e) => setDraft(e.currentTarget.value)}1100 onKeyDown={(e) => {1101 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1102 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1103 }}1104 ref={autoFocus}1105 disabled={saving()}1106 autocomplete="new-password"1107 />1108 <button1109 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1110 type="button"1111 onClick={() => setShowPassword(!showPassword())}1112 aria-label={showPassword() ? "Hide" : "Show"}1113 >1114 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1115 <EyeClosedGlyph />1116 </Show>1117 </button>1118 </div>1119 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1120 <button1121 class="peach-generate-toggle-link"1122 type="button"1123 onClick={() => setShowGenerator(!showGenerator())}1124 >1125 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1126 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1127 <path d="M9.5 2.5H11V1" />1128 </svg>1129 {showGenerator() ? "hide generator" : "generate"}1130 </button>1131 <Show when={showGenerator()}>1132 <GeneratorDrawer1133 open={true}1134 compact={true}1135 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1136 />1137 </Show>1138 </div>1139 </Show>1140 </div>11411142 </section>1143 </Show>11441145 {}1146 <Show when={entry().type !== "note" && entry().type !== "card"}>1147 <Show1148 when={entry().notes || editingField() === "notes"}1149 fallback={1150 <button1151 class="peach-add-field-link peach-entry-add-link"1152 onClick={() => startEdit("notes", "")}1153 >1154 + notes1155 </button>1156 }1157 >1158 <div style="position:relative;z-index:1;margin-top:6px;">1159 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1160 <Show1161 when={editingField() === "notes"}1162 fallback={1163 <div1164 class="peach-note-content peach-inline-clickable"1165 style="cursor:text;white-space:pre-wrap;"1166 onClick={() => startEdit("notes", entry().notes ?? "")}1167 >1168 {entry().notes}1169 </div>1170 }1171 >1172 <div1173 class="peach-inline-field-wrap"1174 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1175 >1176 <textarea1177 class="peach-inline-textarea"1178 value={draft()}1179 onInput={(e) => setDraft(e.currentTarget.value)}1180 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1181 ref={(el) => requestAnimationFrame(() => el?.focus())}1182 rows={4}1183 disabled={saving()}1184 />1185 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1186 </div>1187 </Show>1188 </div>1189 </Show>1190 </Show>11911192 {}1193 <Show1194 when={editingTags()}1195 fallback={1196 <Show when={!entry().tags || entry().tags!.length === 0}>1197 <button1198 class="peach-add-field-link peach-entry-add-link"1199 onClick={startEditTags}1200 >1201 + tags1202 </button>1203 </Show>1204 }1205 >1206 <div1207 class="peach-inline-tag-editor"1208 onFocusOut={(e) => {1209 const next = e.relatedTarget;1210 if (next instanceof Node && e.currentTarget.contains(next)) return;1211 void saveTags();1212 }}1213 >1214 <span class="peach-label">tags</span>1215 <div class="peach-tag-input-wrap">1216 <For each={tagsDraft()}>1217 {(tag) => (1218 <span class="peach-tag-chip">1219 {tag}1220 <button1221 class="peach-tag-chip-remove"1222 onMouseDown={(e) => e.preventDefault()}1223 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1224 aria-label={`Remove ${tag}`}1225 >×</button>1226 </span>1227 )}1228 </For>1229 <input1230 class="peach-tag-input"1231 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1232 value={tagInput()}1233 onInput={(e) => setTagInput(e.currentTarget.value)}1234 onKeyDown={(e) => {1235 if (e.key === "Enter" || e.key === ",") {1236 e.preventDefault();1237 addTag(tagInput());1238 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1239 setTagsDraft(tagsDraft().slice(0, -1));1240 } else if (e.key === "Escape") {1241 e.preventDefault();1242 setEditingTags(false);1243 setTagInput("");1244 }1245 }}1246 ref={(el) => requestAnimationFrame(() => el?.focus())}1247 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1248 />1249 </div>1250 </div>1251 </Show>12521253 {}1254 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1255 <Show1256 when={totpState() || editingField() === "totp"}1257 fallback={1258 <button1259 class="peach-add-field-link peach-entry-add-link"1260 onClick={() => startEdit("totp", "")}1261 >1262 + 2FA1263 </button>1264 }1265 >1266 <section class="peach-totp-band">1267 <Show1268 when={editingField() === "totp"}1269 fallback={1270 <Show when={totpState()}>1271 {(state) => (1272 <>1273 <div1274 class="peach-totp-main"1275 onClick={() => {1276 1277 1278 if (totpClickTimer) return;1279 totpClickTimer = setTimeout(() => {1280 totpClickTimer = null;1281 void copyField(state().code.replace(" ", ""), "totp");1282 }, 220);1283 }}1284 onDblClick={() => {1285 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1286 startEdit("totp", entry().totp ?? "");1287 }}1288 title="Click to copy · double-click to edit"1289 >1290 <span class="peach-label">2-factor</span>1291 <div class="peach-totp-code-row">1292 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1293 <span class="peach-totp-code-text">1294 {state().code.slice(0, 3)} {state().code.slice(3)}1295 </span>1296 </p>1297 </div>1298 </div>1299 <div class="peach-totp-meta">1300 <span class="peach-label">expires</span>1301 <div class="peach-totp-countdown">{state().remaining}s</div>1302 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1303 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1304 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1305 stroke-dasharray="106.8"1306 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1307 />1308 </svg>1309 </div>1310 </>1311 )}1312 </Show>1313 }1314 >1315 <div1316 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1317 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1318 >1319 <span class="peach-label">2-factor</span>1320 <input1321 class="peach-inline-input"1322 value={draft()}1323 onInput={(e) => setDraft(e.currentTarget.value)}1324 onKeyDown={onEditKeyDown}1325 ref={autoFocus}1326 disabled={saving()}1327 placeholder="otpauth://... or base32 secret"1328 autocomplete="off"1329 />1330 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1331 </div>1332 </Show>1333 </section>1334 </Show>1335 </Show>13361337 {}1338 <Show when={entry().type === "login"}>1339 <button1340 class="peach-add-field-link peach-entry-add-link"1341 onClick={() => setShowAdvanced((v) => !v)}1342 aria-expanded={showAdvanced()}1343 >1344 {showAdvanced() ? "− Advanced" : "+ Advanced"}1345 </button>1346 <Show when={showAdvanced()}>1347 <Show when={!isPasskeyOnly(entry())}>1348 <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1349 </Show>13501351 <Show when={entry().passkey}>1352 {(passkey) => {1353 const currentBrowser = detectBrowser();1354 const crossBrowser =1355 passkey().browser &&1356 currentBrowser !== "unknown" &&1357 passkey().browser !== currentBrowser;1358 const algorithmLabel =1359 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1360 const formatDate = (ts?: number) =>1361 ts ? new Date(ts).toLocaleDateString() : "Never";1362 return (1363 <section1364 class="peach-fields peach-passkey-section"1365 style="position:relative;z-index:1;margin-top:6px;"1366 >1367 <div class="peach-field-card">1368 <span class="peach-label">passkey · {passkey().rpId}</span>1369 <div class="peach-field-value-row">1370 <span class="peach-field-value">{passkey().userName}</span>1371 <span class="peach-passkey-badge">passkey</span>1372 </div>1373 </div>13741375 <div class="peach-field-card">1376 <span class="peach-label">algorithm</span>1377 <div class="peach-field-value-row">1378 <span class="peach-field-value">{algorithmLabel}</span>1379 </div>1380 </div>13811382 <div class="peach-field-card">1383 <span class="peach-label">activity</span>1384 <div class="peach-field-value-row peach-passkey-activity">1385 <span class="peach-field-value">1386 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1387 </span>1388 <span class="peach-passkey-meta">1389 created {formatDate(passkey().createdAt)}1390 </span>1391 <span class="peach-passkey-meta">1392 last used {formatDate(passkey().lastUsedAt)}1393 </span>1394 </div>1395 </div>13961397 <Show when={crossBrowser}>1398 <div class="peach-info-notice peach-info-notice--warn">1399 {`This passkey was created on ${1400 passkey().browser === "chrome" ? "Chrome" : "Firefox"1401 }. To use it on ${1402 currentBrowser === "chrome" ? "Chrome" : "Firefox"1403 }, re-register it on this device.`}1404 </div>1405 </Show>14061407 <div class="peach-info-notice">1408 {passkey().browser === "firefox"1409 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1410 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1411 </div>14121413 <button1414 class="peach-add-field-link peach-entry-add-link"1415 style="color:rgba(220,38,38,0.7);"1416 onClick={async () => {1417 const id = entry().id;1418 await sendMessage({1419 type: "UPDATE_ENTRY",1420 payload: { id, patch: { passkey: undefined } },1421 });1422 await props.onFieldSaved();1423 }}1424 >1425 − remove passkey1426 </button>1427 </section>1428 );1429 }}1430 </Show>1431 </Show>1432 </Show>14331434 {}1435 <div style="height:4px;" />1436 </>1437 )}1438 </Show>1439 </div>14401441 </>1442 );1443}1444144514461447function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1448 return (1449 <div class="peach-inline-actions">1450 <button1451 class="peach-inline-save-btn"1452 onClick={props.onSave}1453 disabled={props.saving}1454 aria-label="Save"1455 title="Save (Enter)"1456 >1457 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1458 <path d="M1 4.5L4 7.5L10 1.5" />1459 </svg>1460 </button>1461 <button1462 class="peach-inline-cancel-btn"1463 onClick={props.onCancel}1464 disabled={props.saving}1465 aria-label="Cancel"1466 title="Cancel (Esc)"1467 >1468 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1469 <path d="M1 1L8 8M8 1L1 8" />1470 </svg>1471 </button>1472 </div>1473 );1474}14751476function EmptyVaultState() {1477 return (1478 <>1479 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1480 <div class="peach-entry-name">1481 <span class="peach-entry-name-text">1482 <span class="peach-entry-name-char" style="--char-index:0">e</span>1483 <span class="peach-entry-name-char" style="--char-index:1">m</span>1484 <span class="peach-entry-name-char" style="--char-index:2">p</span>1485 <span class="peach-entry-name-char" style="--char-index:3">t</span>1486 <span class="peach-entry-name-char" style="--char-index:4">y</span>1487 </span>1488 </div>1489 </div>1490 <div class="peach-empty-copy">1491 <span class="peach-label">vault</span>1492 <p>Your vault is ready for its first entry.</p>1493 </div>1494 </>1495 );1496}14971498function CopyGlyph() {1499 return (1500 <span class="peach-copy-icon">1501 <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">1502 <rect x="4" y="4" width="8" height="9" rx="1.5" />1503 <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" />1504 </svg>1505 </span>1506 );1507}15081509function EyeClosedGlyph() {1510 return (1511 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1512 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1513 <path d="M3.5 5.5L2.5 7.5" />1514 <path d="M7 6.5V8.5" />1515 <path d="M10.5 5.5L11.5 7.5" />1516 </svg>1517 );1518}15191520function EyeOpenGlyph() {1521 return (1522 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1523 <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" />1524 <circle cx="7" cy="5" r="1.75" />1525 </svg>1526 );1527}15281529function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1530 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1531 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1532 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1533 const [testResult, setTestResult] = createSignal<string | null>(null);1534 const [testing, setTesting] = createSignal(false);15351536 1537 1538 1539 1540 1541 1542 const isProGated = () => !props.isPro && !props.entry.canary;15431544 async function save() {1545 const url = webhookUrl().trim();1546 const nextLabel = label().trim() || undefined;1547 const canary = enabled() && url1548 ? {1549 webhookUrl: url,1550 label: nextLabel,1551 lastPinged: props.entry.canary?.lastPinged,1552 pingCount: props.entry.canary?.pingCount,1553 }1554 : undefined;1555 await sendMessage({1556 type: "UPDATE_ENTRY",1557 payload: { id: props.entry.id, patch: { canary } },1558 });1559 await props.onSaved();1560 }15611562 async function testPing() {1563 const url = webhookUrl().trim();1564 if (!url) return;1565 setTesting(true);1566 setTestResult(null);1567 const res = await sendMessage({1568 type: "FIRE_CANARY_PING",1569 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1570 }) as { ok: boolean; error?: string };1571 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1572 setTesting(false);1573 }15741575 return (1576 <div class="peach-canary-section">1577 <div class="peach-field-value-row">1578 <span class="peach-field-value" style="font-size:12px;">1579 canary alert1580 <Show when={isProGated()}>1581 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1582 </Show>1583 </span>1584 <button1585 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1586 onClick={() => {1587 if (isProGated()) {1588 1589 1590 1591 props.onProGate?.("canary_entry");1592 return;1593 }1594 setEnabled(!enabled());1595 void save();1596 }}1597 aria-label="Toggle canary"1598 aria-disabled={isProGated() ? "true" : "false"}1599 >1600 <span class="peach-toggle-knob" />1601 </button>1602 </div>1603 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1604 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1605 </p>1606 <Show when={enabled()}>1607 <div class="peach-inline-field-wrap" style="margin-top:8px;">1608 <input1609 class="peach-inline-input"1610 placeholder="https://hooks.zapier.com/..."1611 value={webhookUrl()}1612 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1613 onBlur={() => void save()}1614 />1615 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1616 When triggered, the entry title is sent to this URL. Use an endpoint you control.1617 </p>1618 <input1619 class="peach-inline-input"1620 placeholder="label (optional, e.g. AWS decoy)"1621 value={label()}1622 onInput={(e) => setLabel(e.currentTarget.value)}1623 onBlur={() => void save()}1624 />1625 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1626 <button1627 class="peach-btn peach-btn--ghost peach-btn--sm"1628 onClick={() => void testPing()}1629 disabled={testing() || !webhookUrl().trim()}1630 >1631 {testing() ? "sending..." : "test ping"}1632 </button>1633 <Show when={testResult()}>1634 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1635 </Show>1636 </div>1637 <Show when={props.entry.canary?.lastPinged}>1638 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1639 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1640 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1641 </p>1642 </Show>1643 </div>1644 </Show>1645 </div>1646 );1647}