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, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } 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}36373839function DomainFavicon(props: { domain: string; title: string }) {40 const src = props.domain ? faviconUrl(props.domain) : null;41 const [isReal, setIsReal] = createSignal(false);42 const label = () => titleInitials(props.title || props.domain);43 const bg = () => titleBackground(props.title || props.domain);44 const chipStyle = () =>45 `display:inline-flex;align-items:center;justify-content:center;` +46 `width:16px;height:16px;border-radius:3px;background:${bg()};` +47 `font-family:"IBM Plex Mono",monospace;font-size:0.4375rem;font-weight:600;` +48 `letter-spacing:0.04em;color:rgba(240,232,216,0.92);flex-shrink:0;margin-right:5px;`;49 return (50 <Show51 when={isReal()}52 fallback={53 <>54 <span style={chipStyle()} aria-hidden="true">{label()}</span>55 {src && (56 <img57 src={src}58 style="display:none"59 onError={() => {}}60 onLoad={async (e) => {61 const img = e.currentTarget as HTMLImageElement;62 if (img.naturalWidth <= 16) return;63 const sig = faviconImgSig(img);64 if (sig !== null) {65 const gSig = await globeFaviconSig();66 if (gSig !== null && sig === gSig) return;67 }68 setIsReal(true);69 }}70 />71 )}72 </>73 }74 >75 <img src={src!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />76 </Show>77 );78}7980export function EntryView(props: EntryProps) {81 const [editingField, setEditingField] = createSignal<EditableField>(null);82 const [draft, setDraft] = createSignal("");83 const [saving, setSaving] = createSignal(false);84 const [showPassword, setShowPassword] = createSignal(false);85 const [showGenerator, setShowGenerator] = createSignal(false);86 const [copied, setCopied] = createSignal<string | null>(null);87 88 89 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);90 const [editingTags, setEditingTags] = createSignal(false);91 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);92 const [tagInput, setTagInput] = createSignal("");93 const [showCardNumber, setShowCardNumber] = createSignal(false);94 const [showCardCvc, setShowCardCvc] = createSignal(false);95 const [totpTick, setTotpTick] = createSignal(0);96 const [confirmDelete, setConfirmDelete] = createSignal(false);97 const [totpHot, setTotpHot] = createSignal(false);98 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);99 100 const [showAdvanced, setShowAdvanced] = createSignal(false);101 const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);102 let usernameInlineInput: HTMLInputElement | undefined;103 let emailInlineInput: HTMLInputElement | undefined;104 let usernameIdentityEl: HTMLDivElement | undefined;105 let emailIdentityEl: HTMLDivElement | undefined;106 let lastSavedNote: string | null = null;107 let totpClickTimer: ReturnType<typeof setTimeout> | null = null;108 onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });109110 function noteExec(cmd: string, val?: string) {111 document.execCommand(cmd, false, val);112 noteEditorEl()?.focus();113 }114115 async function saveNoteInline(html: string) {116 const entry = props.entry;117 if (!entry) return;118 const currentStored = entry.notes ?? "";119 if (html === currentStored) return;120 if (lastSavedNote !== null && html === lastSavedNote) return;121 lastSavedNote = html;122 setSaving(true);123 try {124 await sendMessage({125 type: "UPDATE_ENTRY",126 payload: { id: entry.id, patch: { notes: html } },127 });128 triggerRing();129 await props.onFieldSaved();130 } catch {131 lastSavedNote = null;132 } finally {133 setSaving(false);134 }135 }136137 const allIdentities = createMemo(() => {138 const emailMap = new Map<string, number>();139 const usernameMap = new Map<string, number>();140 for (const e of props.entries ?? []) {141 const uname = e.username?.trim() ?? "";142 const em = e.email?.trim() ?? "";143 if (uname) {144 if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);145 else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);146 }147 if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);148 }149 const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];150 const seen = new Set<string>();151 for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {152 if (seen.has(v)) continue;153 seen.add(v);154 const ec = emailMap.get(v) ?? 0;155 const uc = usernameMap.get(v) ?? 0;156 result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });157 }158 return result.sort((a, b) => b.count - a.count);159 });160161 const identitySuggestions = createMemo(() => {162 const field = editingField();163 if (field !== "username" && field !== "email") return [];164 const input = draft().toLowerCase();165 return allIdentities()166 .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))167 .slice(0, 8);168 });169170 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);171 onCleanup(() => window.clearInterval(interval));172173 const displayDomain = createMemo(() =>174 props.entry?.url ? getDisplayDomain(props.entry.url) : ""175 );176177 178 179 180 181 182 let lastEntryId: string | null = null;183 createEffect(() => {184 const id = props.entry?.id ?? null;185 if (id === lastEntryId) return;186 lastEntryId = id;187 setConfirmDelete(false);188 setEditingField(null);189 setEditingTags(false);190 setShowAdvanced(false);191 });192193 const totpState = createMemo(() => {194 void totpTick();195 if (!props.entry?.totp) return null;196 return generateTOTP(props.entry.totp);197 });198199 let prevTotpCode: string | undefined;200 createEffect(() => {201 const state = totpState();202 if (!state) { prevTotpCode = undefined; return; }203 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {204 setTotpHot(true);205 window.setTimeout(() => setTotpHot(false), 3000);206 }207 prevTotpCode = state.code;208 });209210 const totpProgress = createMemo(() => {211 const state = totpState();212 if (!state) return 0;213 return ((state.remaining - 1) / 29) * 100;214 });215216 createEffect(() => {217 const shell = document.getElementById("hd-shell");218 if (!shell) return;219220 const state = totpState();221 if (!state) {222 delete shell.dataset.totpActive;223 shell.style.removeProperty("--peach-totp-progress");224 return;225 }226227 shell.dataset.totpActive = "true";228 shell.style.setProperty(229 "--peach-totp-progress",230 `${Math.max(0, totpProgress())}%`231 );232233 onCleanup(() => {234 delete shell.dataset.totpActive;235 shell.style.removeProperty("--peach-totp-progress");236 });237 });238239 const titleSizeClass = createMemo(() => {240 const length = props.entry?.title.length ?? 0;241 if (length >= 28) return "peach-entry-name--xxl";242 if (length >= 20) return "peach-entry-name--xl";243 if (length >= 14) return "peach-entry-name--lg";244 return "";245 });246 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);247248 249250 function startEdit(field: EditableField, value: string) {251 setEditingField(field);252 setDraft(value);253 setIdentityHelperSuppressed(false);254 setShowGenerator(false);255 setConfirmDelete(false);256 if (field === "url") {257 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);258 }259 }260261 262 263 264 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {265 const c = candidate.trim();266 if (!c) return true;267 if (primary.trim() === c) return true;268 return others.some((u) => u.trim() === c);269 }270271 function addCurrentSite(url: string) {272 if (!url) return;273 if (!draft().trim()) {274 setDraft(url);275 return;276 }277 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;278 setAdditionalUrlsDraft((arr) => [...arr, url]);279 }280281 async function saveField() {282 const field = editingField();283 const entry = props.entry;284 if (!field || !entry) return;285 286 let value = field === "password" ? draft() : draft().trim();287 if (field === "totp" && value.startsWith("otpauth://")) {288 const parsed = parseOtpauthURI(value);289 value = parsed?.secret ?? value;290 }291 if (field === "password" && entry.type !== "note" && !value) {292 return;293 }294 setSaving(true);295 try {296 const patch: Partial<VaultEntry> = { [field]: value };297 if (field === "url") {298 299 patch.additionalUrls = additionalUrlsDraft();300 }301 await sendMessage({302 type: "UPDATE_ENTRY",303 payload: { id: entry.id, patch },304 });305 triggerRing();306 setEditingField(null);307 setShowGenerator(false);308 await props.onFieldSaved();309 } catch {310 311 } finally {312 setSaving(false);313 }314 }315316 function startEditTags() {317 setTagsDraft([...(props.entry?.tags ?? [])]);318 setTagInput("");319 setEditingTags(true);320 }321322 function addTag(value: string) {323 const n = value.toLowerCase().trim();324 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;325 setTagsDraft([...tagsDraft(), n]);326 setTagInput("");327 }328329 async function saveTags() {330 if (tagInput()) addTag(tagInput());331 const entry = props.entry;332 if (!entry) return;333 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;334 setEditingTags(false);335 setSaving(true);336 try {337 await sendMessage({338 type: "UPDATE_ENTRY",339 payload: { id: entry.id, patch: { tags: next } },340 });341 triggerRing();342 await props.onFieldSaved();343 } catch {344 345 } finally {346 setSaving(false);347 }348 }349350 function cancelEdit() {351 setEditingField(null);352 setDraft("");353 setShowGenerator(false);354 }355356 357 358 359 360 361 362 363 364 365 async function addOtherIdentity(target: "username" | "email") {366 const entry = props.entry;367 if (!entry) return;368 const current = editingField();369 const draftValue = draft().trim();370 const savedUsername = entry.username ?? "";371 const savedEmail = entry.email ?? "";372373 if (current === "username" && target === "username") {374 375 376 if (savedUsername.includes("@") && !entry.email) {377 setSaving(true);378 try {379 await sendMessage({380 type: "UPDATE_ENTRY",381 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },382 });383 await props.onFieldSaved();384 } finally {385 setSaving(false);386 }387 }388 startEdit("username", "");389 return;390 }391392 if (current === "username" && target === "email") {393 if (draftValue && draftValue !== savedUsername) {394 setSaving(true);395 try {396 await sendMessage({397 type: "UPDATE_ENTRY",398 payload: { id: entry.id, patch: { username: draftValue } },399 });400 await props.onFieldSaved();401 } finally {402 setSaving(false);403 }404 }405 startEdit("email", "");406 return;407 }408409 if (current === "email" && target === "username") {410 if (draftValue && draftValue !== savedEmail) {411 setSaving(true);412 try {413 await sendMessage({414 type: "UPDATE_ENTRY",415 payload: { id: entry.id, patch: { email: draftValue } },416 });417 await props.onFieldSaved();418 } finally {419 setSaving(false);420 }421 }422 startEdit("username", "");423 return;424 }425 }426427 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {428 switch (field) {429 case "title":430 return entry.title;431 case "url":432 return entry.url ?? "";433 case "username":434 return entry.username ?? "";435 case "email":436 return entry.email ?? "";437 case "password":438 return entry.password ?? "";439 case "notes":440 return entry.notes ?? "";441 case "totp":442 return entry.totp ?? "";443 }444 }445446 function handleEditorFocusOut(447 field: Exclude<EditableField, null>,448 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }449 ) {450 const nextTarget = event.relatedTarget;451 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;452453 454 455 456 if (editingField() !== field) return;457458 const entry = props.entry;459 if (!entry) return;460461 const currentValue = fieldValue(entry, field);462 const nextValue = field === "password" ? draft() : draft().trim();463 if (nextValue === currentValue) {464 cancelEdit();465 }466 }467468 469470 async function copyField(text: string, label: string, button?: HTMLButtonElement) {471 const ok = await popupCopy(text, label);472 if (ok) {473 const entry = props.entry;474 if (entry?.canary?.webhookUrl) {475 const trigger =476 label === "password"477 ? "password_copied"478 : label === "username"479 ? "username_copied"480 : undefined;481 if (trigger) {482 void sendMessage({483 type: "FIRE_CANARY_PING",484 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },485 });486 }487 }488 triggerRing();489 triggerPipPulse();490 setCopied(null);491 requestAnimationFrame(() => setCopied(label));492 if (button) {493 button.classList.remove("is-copied", "is-retreat");494 void button.offsetWidth;495 const drain = document.createElement("span");496 drain.className = "peach-copy-drain";497 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";498 drain.appendChild(bar);499 button.appendChild(drain);500 window.setTimeout(() => {501 button.classList.remove("is-copied");502 button.classList.add("is-retreat");503 drain.remove();504 window.setTimeout(() => button.classList.remove("is-retreat"), 160);505 }, 1800);506 }507 window.setTimeout(() => setCopied(null), 1800);508 }509 }510511 512513 createEffect(() => {514 if (!props.entry) return;515 const handler = (e: KeyboardEvent) => {516 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;517 if (inInput) return;518 switch (e.key) {519 case "c": e.preventDefault();520 if (props.entry?.password) void copyField(props.entry.password, "password"); break;521 case "u": e.preventDefault();522 if (props.entry?.username) void copyField(props.entry.username, "username"); break;523 case "Escape": e.preventDefault(); props.onEscape?.(); break;524 }525 };526 window.addEventListener("keydown", handler);527 onCleanup(() => window.removeEventListener("keydown", handler));528 });529530 531532 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {533 requestAnimationFrame(() => { el.focus(); el.select?.(); });534 }535536 function onEditKeyDown(e: KeyboardEvent) {537 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {538 e.preventDefault();539 void saveField();540 }541 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }542 }543544 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {545 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;546 if (e.key === "ArrowDown" && listEl) {547 e.preventDefault();548 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();549 return;550 }551 onEditKeyDown(e);552 }553554 return (555 <>556 <div557 class="peach-ghost"558 aria-hidden="true"559 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;"560 >561 {props.entry?.title || "empty"}562 </div>563564 <div class="peach-stage" style="position:relative;">565 <Show when={props.entry} fallback={<EmptyVaultState />}>566 {(entry) => (567 <>568 {}569 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>570 <div class="peach-breach-badge">571 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">572 <path d="M6 1L11 10H1L6 1z" />573 <line x1="6" y1="5" x2="6" y2="7.5" />574 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />575 </svg>576 <span style="flex:1">found in a known data breach</span>577 </div>578 </Show>579580 {}581 <div class="peach-entry-header">582 <div class="peach-entry-header-name">583 <Show584 when={editingField() === "title"}585 fallback={586 <button587 class="peach-entry-name-button"588 title="Click to edit title"589 onClick={() => startEdit("title", entry().title)}590 >591 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>592 <span class="peach-entry-name-text">593 <Show594 when={useAnimatedTitle()}595 fallback={entry().title}596 >597 <For each={entry().title.split("")}>598 {(ch, i) => (599 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>600 {ch === " " ? "\u00A0" : ch}601 </span>602 )}603 </For>604 </Show>605 </span>606 </h1>607 </button>608 }609 >610 <div611 class="peach-inline-title-wrap"612 onFocusOut={(event) => handleEditorFocusOut("title", event)}613 >614 <input615 class="peach-inline-title-input"616 value={draft()}617 onInput={(e) => setDraft(e.currentTarget.value)}618 onKeyDown={onEditKeyDown}619 ref={autoFocus}620 disabled={saving()}621 />622 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />623 </div>624 </Show>625 </div>626627 {}628 <Show when={editingField() !== "title"}>629 <Show630 when={confirmDelete()}631 fallback={632 <button633 class="peach-entry-trash-btn"634 onClick={() => setConfirmDelete(true)}635 aria-label="Move to trash"636 title="Delete entry"637 >638 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">639 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />640 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />641 </svg>642 </button>643 }644 >645 <div class="peach-entry-trash-confirm">646 <span class="peach-entry-trash-confirm-label">trash?</span>647 <button648 class="peach-btn peach-btn--sm peach-btn--danger"649 style="font-size:10px;padding:2px 8px;min-height:22px;"650 onClick={() => { setConfirmDelete(false); props.onDelete(); }}651 >652 yes653 </button>654 <button655 class="peach-btn peach-btn--ghost peach-btn--sm"656 style="font-size:10px;padding:2px 8px;min-height:22px;"657 onClick={() => setConfirmDelete(false)}658 >659 no660 </button>661 </div>662 </Show>663 </Show>664 </div>665666 {}667 <Show when={entry().type !== "note" && entry().type !== "card"}>668 <div class="peach-domain" style="position:relative;z-index:1;">669 <Show670 when={editingField() === "url"}671 fallback={672 <div673 class="peach-domain-row peach-inline-clickable"674 title="Click to edit URL"675 onClick={() => startEdit("url", entry().url ?? "")}676 >677 <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />678 <span class="peach-domain-value">679 {displayDomain() || (entry().url ? entry().url : "add a website url")}680 </span>681 <span class="peach-inline-edit-hint">edit</span>682 </div>683 }684 >685 <div686 class="peach-inline-field-wrap peach-inline-field-wrap--url"687 onFocusOut={(event) => handleEditorFocusOut("url", event)}688 >689 <div class="peach-inline-field-row">690 <input691 class="peach-inline-input"692 type="url"693 placeholder="https://"694 value={draft()}695 onInput={(e) => setDraft(e.currentTarget.value)}696 onKeyDown={onEditKeyDown}697 ref={autoFocus}698 disabled={saving()}699 />700 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />701 </div>702 <div class="peach-additional-urls">703 <Show when={additionalUrlsDraft().length > 0}>704 <span class="peach-label peach-additional-urls-label">also matches</span>705 </Show>706 <For each={additionalUrlsDraft()}>707 {(value, i) => (708 <div class="peach-additional-url-row">709 <input710 class="peach-inline-input"711 type="url"712 placeholder="https://"713 value={value}714 onInput={(e) => setAdditionalUrlsDraft((arr) => {715 const next = [...arr];716 next[i()] = e.currentTarget.value;717 return next;718 })}719 disabled={saving()}720 />721 <button722 class="peach-additional-url-remove"723 type="button"724 aria-label="Remove URL"725 onMouseDown={(e) => e.preventDefault()}726 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}727 disabled={saving()}728 >×</button>729 </div>730 )}731 </For>732 <button733 class="peach-add-field-link peach-additional-urls-add"734 type="button"735 onMouseDown={(e) => e.preventDefault()}736 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}737 disabled={saving()}738 >739 + add another url740 </button>741 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>742 <button743 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"744 type="button"745 title={props.currentTabUrl}746 onMouseDown={(e) => e.preventDefault()}747 onClick={() => addCurrentSite(props.currentTabUrl!)}748 disabled={saving()}749 >750 + add current site751 </button>752 </Show>753 </div>754 </div>755 </Show>756 </div>757 </Show>758759 {}760 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>761 <div762 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"763 onClick={startEditTags}764 title="Click to edit tags"765 >766 <For each={entry().tags!}>767 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}768 </For>769 </div>770 </Show>771772 {}773 <Show when={entry().type === "note"}>774 <div class="peach-entry-note-toolbar-row">775 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />776 </div>777 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">778 <NoteEditorInline779 entryKey={entry().id}780 initialContent={entry().notes ?? ""}781 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}782 onBlur={saveNoteInline}783 autoFocus={false}784 />785 </section>786 </Show>787788 {}789 <Show when={entry().type === "card"}>790 <section class="peach-fields" style="position:relative;z-index:1;">791 {}792 <Show when={entry().last4}>793 <div class="peach-field-card" style="padding-bottom:4px;">794 <span class="peach-label">795 <Show when={entry().network && entry().network !== "unknown"}>796 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>797 </Show>798 {entry().cardNickname || `•••• ${entry().last4}`}799 </span>800 </div>801 </Show>802803 {}804 <div class="peach-field-card">805 <span class="peach-label">cardholder name</span>806 <div class="peach-field-value-row">807 <span class="peach-field-value">{entry().cardholderName || "—"}</span>808 <Show when={entry().cardholderName}>809 <button810 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}811 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}812 aria-label="Copy cardholder name"813 >814 <CopyGlyph /><span class="peach-copy-sheen" />815 </button>816 </Show>817 </div>818 </div>819820 {}821 <div class="peach-field-card">822 <span class="peach-label">card number</span>823 <div class="peach-field-value-row">824 <span class={`peach-field-value peach-card-number`}>825 <Show when={showCardNumber()} fallback={826 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>827 }>828 <span>{entry().cardNumber ?? ""}</span>829 </Show>830 </span>831 <div class="peach-field-actions">832 <button833 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"834 onClick={() => setShowCardNumber(!showCardNumber())}835 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}836 >837 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>838 <EyeClosedGlyph />839 </Show>840 </button>841 <Show when={entry().cardNumber}>842 <button843 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}844 onClick={(e) => {845 const cardNum = entry().cardNumber!;846 if (entry().canary?.webhookUrl) {847 void sendMessage({848 type: "FIRE_CANARY_PING",849 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },850 });851 }852 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);853 }}854 aria-label="Copy card number"855 >856 <CopyGlyph /><span class="peach-copy-sheen" />857 </button>858 </Show>859 </div>860 </div>861 </div>862863 {}864 <Show when={entry().expMonth || entry().expYear}>865 <div class="peach-field-card">866 <span class="peach-label">expiry</span>867 <div class="peach-field-value-row">868 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>869 <button870 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}871 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}872 aria-label="Copy expiry"873 >874 <CopyGlyph /><span class="peach-copy-sheen" />875 </button>876 </div>877 </div>878 </Show>879880 {}881 <Show when={entry().cvc}>882 <div class="peach-field-card">883 <span class="peach-label">CVC</span>884 <div class="peach-field-value-row">885 <span class="peach-field-value">886 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>887 <span>{entry().cvc}</span>888 </Show>889 </span>890 <div class="peach-field-actions">891 <button892 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"893 onClick={() => setShowCardCvc(!showCardCvc())}894 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}895 >896 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>897 <EyeClosedGlyph />898 </Show>899 </button>900 <button901 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}902 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}903 aria-label="Copy CVC"904 >905 <CopyGlyph /><span class="peach-copy-sheen" />906 </button>907 </div>908 </div>909 </div>910 </Show>911 </section>912 </Show>913914 {}915 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>916 <section class="peach-fields" style="position:relative;z-index:1;">917918 {919920}921 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>922 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>923 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>924 <Show925 when={editingField() === "username"}926 fallback={927 <div class="peach-field-value-row">928 <span929 class="peach-field-value peach-inline-clickable"930 title="Click to edit"931 onClick={() => startEdit("username", entry().username ?? "")}932 >933 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}934 </span>935 <Show when={entry().username}>936 <button937 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}938 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}939 aria-label="Copy username"940 >941 <CopyGlyph /><span class="peach-copy-sheen" />942 </button>943 </Show>944 </div>945 }946 >947 <div948 class="peach-inline-field-wrap"949 onFocusOut={(event) => handleEditorFocusOut("username", event)}950 >951 <input952 class="peach-inline-input"953 value={draft()}954 onInput={(e) => setDraft(e.currentTarget.value)}955 onKeyDown={(e) => identityKeyDown(e, "username")}956 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}957 disabled={saving()}958 autocomplete="off"959 />960 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />961 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>962 <IdentitySuggestList963 listRef={(el) => (usernameIdentityEl = el)}964 inputRef={() => usernameInlineInput}965 suggestions={identitySuggestions()}966 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}967 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}968 />969 </Show>970 {971972973}974 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>975 <button976 class="peach-add-field-btn"977 type="button"978 onMouseDown={(e) => e.preventDefault()}979 onClick={() => {980 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);981 const hasSeparateEmail = !!entry().email;982 if (isEmailShape && !hasSeparateEmail) {983 void addOtherIdentity("username");984 } else {985 void addOtherIdentity("email");986 }987 }}988 >989 {(() => {990 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);991 const hasSeparateEmail = !!entry().email;992 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";993 })()}994 </button>995 </Show>996 </div>997 </Show>998 </div>999 </Show>10001001 {1002}1003 <Show when={entry().email || editingField() === "email"}>1004 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1005 <span class="peach-label">email</span>1006 <Show1007 when={editingField() === "email"}1008 fallback={1009 <div class="peach-field-value-row">1010 <span1011 class="peach-field-value peach-inline-clickable"1012 title="Click to edit"1013 onClick={() => startEdit("email", entry().email ?? "")}1014 >1015 {entry().email}1016 </span>1017 <Show when={entry().email}>1018 <button1019 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1020 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1021 aria-label="Copy email"1022 >1023 <CopyGlyph /><span class="peach-copy-sheen" />1024 </button>1025 </Show>1026 </div>1027 }1028 >1029 <div1030 class="peach-inline-field-wrap"1031 onFocusOut={(event) => handleEditorFocusOut("email", event)}1032 >1033 <input1034 class="peach-inline-input"1035 value={draft()}1036 onInput={(e) => setDraft(e.currentTarget.value)}1037 onKeyDown={(e) => identityKeyDown(e, "email")}1038 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1039 disabled={saving()}1040 autocomplete="email"1041 type="email"1042 />1043 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1044 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1045 <IdentitySuggestList1046 listRef={(el) => (emailIdentityEl = el)}1047 inputRef={() => emailInlineInput}1048 suggestions={identitySuggestions()}1049 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1050 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1051 />1052 </Show>1053 <Show when={!entry().username || entry().username?.includes("@")}>1054 <button1055 class="peach-add-field-btn"1056 type="button"1057 onMouseDown={(e) => e.preventDefault()}1058 onClick={() => void addOtherIdentity("username")}1059 >1060 + add username1061 </button>1062 </Show>1063 </div>1064 </Show>1065 </div>1066 </Show>10671068 {}1069 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1070 <span class="peach-label">password</span>1071 <Show1072 when={editingField() === "password"}1073 fallback={1074 <div class="peach-field-value-row">1075 <span1076 class="peach-password-value"1077 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1078 style="cursor:pointer;"1079 title="Click to edit password"1080 onClick={() => startEdit("password", entry().password ?? "")}1081 >1082 <Show1083 when={showPassword()}1084 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1085 >1086 <span class="peach-password-text">{entry().password ?? ""}</span>1087 </Show>1088 </span>1089 <div class="peach-field-actions">1090 <button1091 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1092 onClick={() => setShowPassword(!showPassword())}1093 aria-label="Reveal password"1094 >1095 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1096 <EyeClosedGlyph />1097 </Show>1098 </button>1099 <button1100 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1101 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1102 aria-label="Copy password"1103 >1104 <CopyGlyph /><span class="peach-copy-sheen" />1105 </button>1106 </div>1107 </div>1108 }1109 >1110 {}1111 <div1112 class="peach-inline-field-wrap"1113 onFocusOut={(event) => handleEditorFocusOut("password", event)}1114 >1115 <div class="peach-password-edit-row">1116 <input1117 class="peach-inline-input"1118 type={showPassword() ? "text" : "password"}1119 placeholder="paste or type a password"1120 value={draft()}1121 onInput={(e) => setDraft(e.currentTarget.value)}1122 onKeyDown={(e) => {1123 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1124 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1125 }}1126 ref={autoFocus}1127 disabled={saving()}1128 autocomplete="new-password"1129 />1130 <button1131 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1132 type="button"1133 onClick={() => setShowPassword(!showPassword())}1134 aria-label={showPassword() ? "Hide" : "Show"}1135 >1136 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1137 <EyeClosedGlyph />1138 </Show>1139 </button>1140 </div>1141 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1142 <button1143 class="peach-generate-toggle-link"1144 type="button"1145 onClick={() => setShowGenerator(!showGenerator())}1146 >1147 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1148 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1149 <path d="M9.5 2.5H11V1" />1150 </svg>1151 {showGenerator() ? "hide generator" : "generate"}1152 </button>1153 <Show when={showGenerator()}>1154 <GeneratorDrawer1155 open={true}1156 compact={true}1157 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1158 />1159 </Show>1160 </div>1161 </Show>1162 </div>11631164 </section>1165 </Show>11661167 {}1168 <Show when={entry().type !== "note" && entry().type !== "card"}>1169 <Show1170 when={entry().notes || editingField() === "notes"}1171 fallback={1172 <button1173 class="peach-add-field-link peach-entry-add-link"1174 onClick={() => startEdit("notes", "")}1175 >1176 + notes1177 </button>1178 }1179 >1180 <div style="position:relative;z-index:1;margin-top:6px;">1181 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1182 <Show1183 when={editingField() === "notes"}1184 fallback={1185 <div1186 class="peach-note-content peach-inline-clickable"1187 style="cursor:text;white-space:pre-wrap;"1188 onClick={() => startEdit("notes", entry().notes ?? "")}1189 >1190 {entry().notes}1191 </div>1192 }1193 >1194 <div1195 class="peach-inline-field-wrap"1196 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1197 >1198 <textarea1199 class="peach-inline-textarea"1200 value={draft()}1201 onInput={(e) => setDraft(e.currentTarget.value)}1202 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1203 ref={(el) => requestAnimationFrame(() => el?.focus())}1204 rows={4}1205 disabled={saving()}1206 />1207 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1208 </div>1209 </Show>1210 </div>1211 </Show>1212 </Show>12131214 {}1215 <Show1216 when={editingTags()}1217 fallback={1218 <Show when={!entry().tags || entry().tags!.length === 0}>1219 <button1220 class="peach-add-field-link peach-entry-add-link"1221 onClick={startEditTags}1222 >1223 + tags1224 </button>1225 </Show>1226 }1227 >1228 <div1229 class="peach-inline-tag-editor"1230 onFocusOut={(e) => {1231 const next = e.relatedTarget;1232 if (next instanceof Node && e.currentTarget.contains(next)) return;1233 void saveTags();1234 }}1235 >1236 <span class="peach-label">tags</span>1237 <div class="peach-tag-input-wrap">1238 <For each={tagsDraft()}>1239 {(tag) => (1240 <span class="peach-tag-chip">1241 {tag}1242 <button1243 class="peach-tag-chip-remove"1244 onMouseDown={(e) => e.preventDefault()}1245 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1246 aria-label={`Remove ${tag}`}1247 >×</button>1248 </span>1249 )}1250 </For>1251 <input1252 class="peach-tag-input"1253 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1254 value={tagInput()}1255 onInput={(e) => setTagInput(e.currentTarget.value)}1256 onKeyDown={(e) => {1257 if (e.key === "Enter" || e.key === ",") {1258 e.preventDefault();1259 addTag(tagInput());1260 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1261 setTagsDraft(tagsDraft().slice(0, -1));1262 } else if (e.key === "Escape") {1263 e.preventDefault();1264 setEditingTags(false);1265 setTagInput("");1266 }1267 }}1268 ref={(el) => requestAnimationFrame(() => el?.focus())}1269 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1270 />1271 </div>1272 </div>1273 </Show>12741275 {}1276 <Show when={entry().type === "login"}>1277 <Show1278 when={totpState() || editingField() === "totp"}1279 fallback={1280 <button1281 class="peach-add-field-link peach-entry-add-link"1282 onClick={() => startEdit("totp", "")}1283 >1284 + 2FA1285 </button>1286 }1287 >1288 <section class="peach-totp-band">1289 <Show1290 when={editingField() === "totp"}1291 fallback={1292 <Show when={totpState()}>1293 {(state) => (1294 <>1295 <div1296 class="peach-totp-main"1297 onClick={() => {1298 1299 1300 if (totpClickTimer) return;1301 totpClickTimer = setTimeout(() => {1302 totpClickTimer = null;1303 void copyField(state().code.replace(" ", ""), "totp");1304 }, 220);1305 }}1306 onDblClick={() => {1307 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1308 startEdit("totp", entry().totp ?? "");1309 }}1310 title="Click to copy · double-click to edit"1311 >1312 <span class="peach-label">2-factor</span>1313 <div class="peach-totp-code-row">1314 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1315 <span class="peach-totp-code-text">1316 {state().code.slice(0, 3)} {state().code.slice(3)}1317 </span>1318 </p>1319 </div>1320 </div>1321 <div class="peach-totp-meta">1322 <span class="peach-label">expires</span>1323 <div class="peach-totp-countdown">{state().remaining}s</div>1324 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1325 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1326 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1327 stroke-dasharray="106.8"1328 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1329 />1330 </svg>1331 </div>1332 </>1333 )}1334 </Show>1335 }1336 >1337 <div1338 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1339 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1340 >1341 <span class="peach-label">2-factor</span>1342 <input1343 class="peach-inline-input"1344 value={draft()}1345 onInput={(e) => setDraft(e.currentTarget.value)}1346 onKeyDown={onEditKeyDown}1347 ref={autoFocus}1348 disabled={saving()}1349 placeholder="otpauth://... or base32 secret"1350 autocomplete="off"1351 />1352 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1353 </div>1354 </Show>1355 </section>1356 </Show>1357 </Show>13581359 {}1360 <Show when={entry().type === "login"}>1361 <button1362 class="peach-add-field-link peach-entry-add-link"1363 onClick={() => setShowAdvanced((v) => !v)}1364 aria-expanded={showAdvanced()}1365 >1366 {showAdvanced() ? "− Advanced" : "+ Advanced"}1367 </button>1368 <Show when={showAdvanced()}>1369 <Show when={!isPasskeyOnly(entry())}>1370 <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1371 </Show>13721373 <Show when={entry().passkey}>1374 {(passkey) => {1375 const currentBrowser = detectBrowser();1376 const crossBrowser =1377 passkey().browser &&1378 currentBrowser !== "unknown" &&1379 passkey().browser !== currentBrowser;1380 const algorithmLabel =1381 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1382 const formatDate = (ts?: number) =>1383 ts ? new Date(ts).toLocaleDateString() : "Never";1384 return (1385 <section1386 class="peach-fields peach-passkey-section"1387 style="position:relative;z-index:1;margin-top:6px;"1388 >1389 <div class="peach-field-card">1390 <span class="peach-label">passkey · {passkey().rpId}</span>1391 <div class="peach-field-value-row">1392 <span class="peach-field-value">{passkey().userName}</span>1393 <span class="peach-passkey-badge">passkey</span>1394 </div>1395 </div>13961397 <div class="peach-field-card">1398 <span class="peach-label">algorithm</span>1399 <div class="peach-field-value-row">1400 <span class="peach-field-value">{algorithmLabel}</span>1401 </div>1402 </div>14031404 <div class="peach-field-card">1405 <span class="peach-label">activity</span>1406 <div class="peach-field-value-row peach-passkey-activity">1407 <span class="peach-field-value">1408 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1409 </span>1410 <span class="peach-passkey-meta">1411 created {formatDate(passkey().createdAt)}1412 </span>1413 <span class="peach-passkey-meta">1414 last used {formatDate(passkey().lastUsedAt)}1415 </span>1416 </div>1417 </div>14181419 <Show when={crossBrowser}>1420 <div class="peach-info-notice peach-info-notice--warn">1421 {`This passkey was created on ${1422 passkey().browser === "chrome" ? "Chrome" : "Firefox"1423 }. To use it on ${1424 currentBrowser === "chrome" ? "Chrome" : "Firefox"1425 }, re-register it on this device.`}1426 </div>1427 </Show>14281429 <div class="peach-info-notice">1430 {passkey().browser === "firefox"1431 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1432 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1433 </div>14341435 <button1436 class="peach-add-field-link peach-entry-add-link"1437 style="color:rgba(220,38,38,0.7);"1438 onClick={async () => {1439 const id = entry().id;1440 await sendMessage({1441 type: "UPDATE_ENTRY",1442 payload: { id, patch: { passkey: undefined } },1443 });1444 await props.onFieldSaved();1445 }}1446 >1447 − remove passkey1448 </button>1449 </section>1450 );1451 }}1452 </Show>1453 </Show>1454 </Show>14551456 {}1457 <div style="height:4px;" />1458 </>1459 )}1460 </Show>1461 </div>14621463 </>1464 );1465}1466146714681469function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1470 return (1471 <div class="peach-inline-actions">1472 <button1473 class="peach-inline-save-btn"1474 onClick={props.onSave}1475 disabled={props.saving}1476 aria-label="Save"1477 title="Save (Enter)"1478 >1479 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1480 <path d="M1 4.5L4 7.5L10 1.5" />1481 </svg>1482 </button>1483 <button1484 class="peach-inline-cancel-btn"1485 onClick={props.onCancel}1486 disabled={props.saving}1487 aria-label="Cancel"1488 title="Cancel (Esc)"1489 >1490 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1491 <path d="M1 1L8 8M8 1L1 8" />1492 </svg>1493 </button>1494 </div>1495 );1496}14971498function EmptyVaultState() {1499 return (1500 <>1501 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1502 <div class="peach-entry-name">1503 <span class="peach-entry-name-text">1504 <span class="peach-entry-name-char" style="--char-index:0">e</span>1505 <span class="peach-entry-name-char" style="--char-index:1">m</span>1506 <span class="peach-entry-name-char" style="--char-index:2">p</span>1507 <span class="peach-entry-name-char" style="--char-index:3">t</span>1508 <span class="peach-entry-name-char" style="--char-index:4">y</span>1509 </span>1510 </div>1511 </div>1512 <div class="peach-empty-copy">1513 <span class="peach-label">vault</span>1514 <p>Your vault is ready for its first entry.</p>1515 </div>1516 </>1517 );1518}15191520function CopyGlyph() {1521 return (1522 <span class="peach-copy-icon">1523 <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">1524 <rect x="4" y="4" width="8" height="9" rx="1.5" />1525 <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" />1526 </svg>1527 </span>1528 );1529}15301531function EyeClosedGlyph() {1532 return (1533 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1534 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1535 <path d="M3.5 5.5L2.5 7.5" />1536 <path d="M7 6.5V8.5" />1537 <path d="M10.5 5.5L11.5 7.5" />1538 </svg>1539 );1540}15411542function EyeOpenGlyph() {1543 return (1544 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1545 <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" />1546 <circle cx="7" cy="5" r="1.75" />1547 </svg>1548 );1549}15501551function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1552 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1553 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1554 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1555 const [testResult, setTestResult] = createSignal<string | null>(null);1556 const [testing, setTesting] = createSignal(false);15571558 1559 1560 1561 1562 1563 1564 const isProGated = () => !props.isPro && !props.entry.canary;15651566 async function save() {1567 const url = webhookUrl().trim();1568 const nextLabel = label().trim() || undefined;1569 const canary = enabled() && url1570 ? {1571 webhookUrl: url,1572 label: nextLabel,1573 lastPinged: props.entry.canary?.lastPinged,1574 pingCount: props.entry.canary?.pingCount,1575 }1576 : undefined;1577 await sendMessage({1578 type: "UPDATE_ENTRY",1579 payload: { id: props.entry.id, patch: { canary } },1580 });1581 await props.onSaved();1582 }15831584 async function testPing() {1585 const url = webhookUrl().trim();1586 if (!url) return;1587 setTesting(true);1588 setTestResult(null);1589 const res = await sendMessage({1590 type: "FIRE_CANARY_PING",1591 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1592 }) as { ok: boolean; error?: string };1593 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1594 setTesting(false);1595 }15961597 return (1598 <div class="peach-canary-section">1599 <div class="peach-field-value-row">1600 <span class="peach-field-value" style="font-size:12px;">1601 canary alert1602 <Show when={isProGated()}>1603 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1604 </Show>1605 </span>1606 <button1607 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1608 onClick={() => {1609 if (isProGated()) {1610 1611 1612 1613 props.onProGate?.("canary_entry");1614 return;1615 }1616 setEnabled(!enabled());1617 void save();1618 }}1619 aria-label="Toggle canary"1620 aria-disabled={isProGated() ? "true" : "false"}1621 >1622 <span class="peach-toggle-knob" />1623 </button>1624 </div>1625 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1626 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1627 </p>1628 <Show when={enabled()}>1629 <div class="peach-inline-field-wrap" style="margin-top:8px;">1630 <input1631 class="peach-inline-input"1632 placeholder="https://hooks.zapier.com/..."1633 value={webhookUrl()}1634 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1635 onBlur={() => void save()}1636 />1637 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1638 When triggered, the entry title is sent to this URL. Use an endpoint you control.1639 </p>1640 <input1641 class="peach-inline-input"1642 placeholder="label (optional, e.g. AWS decoy)"1643 value={label()}1644 onInput={(e) => setLabel(e.currentTarget.value)}1645 onBlur={() => void save()}1646 />1647 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1648 <button1649 class="peach-btn peach-btn--ghost peach-btn--sm"1650 onClick={() => void testPing()}1651 disabled={testing() || !webhookUrl().trim()}1652 >1653 {testing() ? "sending..." : "test ping"}1654 </button>1655 <Show when={testResult()}>1656 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1657 </Show>1658 </div>1659 <Show when={props.entry.canary?.lastPinged}>1660 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1661 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1662 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1663 </p>1664 </Show>1665 </div>1666 </Show>1667 </div>1668 );1669}