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 { detectHomograph, 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;15161718function timeSince(when: number): string {19 const secs = Math.max(0, Math.floor((Date.now() - when) / 1000));20 if (secs < 60) return "just now";21 const mins = Math.floor(secs / 60);22 if (mins < 60) return `${mins}m ago`;23 const hours = Math.floor(mins / 60);24 if (hours < 24) return `${hours}h ago`;25 const days = Math.floor(hours / 24);26 if (days < 30) return `${days}d ago`;27 const months = Math.floor(days / 30);28 if (months < 12) return `${months}mo ago`;29 return `${Math.floor(months / 12)}y ago`;30}3132interface EntryProps {33 entry: VaultEntry | null;34 3536373839 currentTabUrl?: string;40 onDelete: () => void;41 42 onFieldSaved: () => void | Promise<void>;43 onEscape?: () => void;44 entries?: VaultEntry[];45 4647 isPro?: boolean;48 4950 onProGate?: (feature: ProFeature) => void;51}52535455function DomainFavicon(props: { domain: string; title: string }) {56 const src = props.domain ? faviconUrl(props.domain) : null;57 const [isReal, setIsReal] = createSignal(false);58 const label = () => titleInitials(props.title || props.domain);59 const bg = () => titleBackground(props.title || props.domain);60 const chipStyle = () =>61 `display:inline-flex;align-items:center;justify-content:center;` +62 `width:16px;height:16px;border-radius:3px;background:${bg()};` +63 `font-family:"IBM Plex Mono",monospace;font-size:0.4375rem;font-weight:600;` +64 `letter-spacing:0.04em;color:rgba(240,232,216,0.92);flex-shrink:0;margin-right:5px;`;65 return (66 <Show67 when={isReal()}68 fallback={69 <>70 <span style={chipStyle()} aria-hidden="true">{label()}</span>71 {src && (72 <img73 src={src}74 style="display:none"75 onError={() => {}}76 onLoad={async (e) => {77 const img = e.currentTarget as HTMLImageElement;78 if (img.naturalWidth <= 16) return;79 const sig = faviconImgSig(img);80 if (sig !== null) {81 const gSig = await globeFaviconSig();82 if (gSig !== null && sig === gSig) return;83 }84 setIsReal(true);85 }}86 />87 )}88 </>89 }90 >91 <img src={src!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />92 </Show>93 );94}9596export function EntryView(props: EntryProps) {97 const [editingField, setEditingField] = createSignal<EditableField>(null);98 const [draft, setDraft] = createSignal("");99 const [saving, setSaving] = createSignal(false);100 const [showPassword, setShowPassword] = createSignal(false);101 const [showGenerator, setShowGenerator] = createSignal(false);102 const [showHistory, setShowHistory] = createSignal(false);103 const [revealedHistory, setRevealedHistory] = createSignal<Set<number>>(new Set());104 const [copied, setCopied] = createSignal<string | null>(null);105 106 107 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);108 const [editingTags, setEditingTags] = createSignal(false);109 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);110 const [tagInput, setTagInput] = createSignal("");111 const [showCardNumber, setShowCardNumber] = createSignal(false);112 const [showCardCvc, setShowCardCvc] = createSignal(false);113 const [totpTick, setTotpTick] = createSignal(0);114 const [confirmDelete, setConfirmDelete] = createSignal(false);115 const [totpHot, setTotpHot] = createSignal(false);116 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);117 118 const [showAdvanced, setShowAdvanced] = createSignal(false);119 const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);120 let usernameInlineInput: HTMLInputElement | undefined;121 let emailInlineInput: HTMLInputElement | undefined;122 let usernameIdentityEl: HTMLDivElement | undefined;123 let emailIdentityEl: HTMLDivElement | undefined;124 let lastSavedNote: string | null = null;125 let totpClickTimer: ReturnType<typeof setTimeout> | null = null;126 onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });127128 function noteExec(cmd: string, val?: string) {129 document.execCommand(cmd, false, val);130 noteEditorEl()?.focus();131 }132133 async function saveNoteInline(html: string) {134 const entry = props.entry;135 if (!entry) return;136 const currentStored = entry.notes ?? "";137 if (html === currentStored) return;138 if (lastSavedNote !== null && html === lastSavedNote) return;139 lastSavedNote = html;140 setSaving(true);141 try {142 await sendMessage({143 type: "UPDATE_ENTRY",144 payload: { id: entry.id, patch: { notes: html } },145 });146 triggerRing();147 await props.onFieldSaved();148 } catch {149 lastSavedNote = null;150 } finally {151 setSaving(false);152 }153 }154155 const allIdentities = createMemo(() => {156 const emailMap = new Map<string, number>();157 const usernameMap = new Map<string, number>();158 for (const e of props.entries ?? []) {159 const uname = e.username?.trim() ?? "";160 const em = e.email?.trim() ?? "";161 if (uname) {162 if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);163 else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);164 }165 if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);166 }167 const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];168 const seen = new Set<string>();169 for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {170 if (seen.has(v)) continue;171 seen.add(v);172 const ec = emailMap.get(v) ?? 0;173 const uc = usernameMap.get(v) ?? 0;174 result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });175 }176 return result.sort((a, b) => b.count - a.count);177 });178179 const identitySuggestions = createMemo(() => {180 const field = editingField();181 if (field !== "username" && field !== "email") return [];182 return allIdentities()183 184 185 186 .filter(s => s.type === field)187 .slice(0, 8);188 });189190 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);191 onCleanup(() => window.clearInterval(interval));192193 const displayDomain = createMemo(() =>194 props.entry?.url ? getDisplayDomain(props.entry.url) : ""195 );196197 198 199 200 201 202 let lastEntryId: string | null = null;203 createEffect(() => {204 const id = props.entry?.id ?? null;205 if (id === lastEntryId) return;206 lastEntryId = id;207 setConfirmDelete(false);208 setEditingField(null);209 setEditingTags(false);210 setShowAdvanced(false);211 });212213 const totpState = createMemo(() => {214 void totpTick();215 if (!props.entry?.totp) return null;216 return generateTOTP(217 props.entry.totp,218 props.entry.totpAlgorithm,219 props.entry.totpDigits,220 props.entry.totpPeriod,221 );222 });223224 let prevTotpCode: string | undefined;225 createEffect(() => {226 const state = totpState();227 if (!state) { prevTotpCode = undefined; return; }228 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {229 setTotpHot(true);230 window.setTimeout(() => setTotpHot(false), 3000);231 }232 prevTotpCode = state.code;233 });234235 const totpProgress = createMemo(() => {236 const state = totpState();237 if (!state) return 0;238 return ((state.remaining - 1) / 29) * 100;239 });240241 createEffect(() => {242 const shell = document.getElementById("hd-shell");243 if (!shell) return;244245 const state = totpState();246 if (!state) {247 delete shell.dataset.totpActive;248 shell.style.removeProperty("--peach-totp-progress");249 return;250 }251252 shell.dataset.totpActive = "true";253 shell.style.setProperty(254 "--peach-totp-progress",255 `${Math.max(0, totpProgress())}%`256 );257258 onCleanup(() => {259 delete shell.dataset.totpActive;260 shell.style.removeProperty("--peach-totp-progress");261 });262 });263264 const titleSizeClass = createMemo(() => {265 const length = props.entry?.title.length ?? 0;266 if (length >= 28) return "peach-entry-name--xxl";267 if (length >= 20) return "peach-entry-name--xl";268 if (length >= 14) return "peach-entry-name--lg";269 return "";270 });271 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);272273 274275 function startEdit(field: EditableField, value: string) {276 setEditingField(field);277 setDraft(value);278 setIdentityHelperSuppressed(false);279 setShowGenerator(false);280 setShowHistory(false);281 setRevealedHistory(new Set<number>());282 setConfirmDelete(false);283 if (field === "url") {284 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);285 }286 }287288 289 290 291 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {292 const c = candidate.trim();293 if (!c) return true;294 if (primary.trim() === c) return true;295 return others.some((u) => u.trim() === c);296 }297298 function addCurrentSite(url: string) {299 if (!url) return;300 if (!draft().trim()) {301 setDraft(url);302 return;303 }304 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;305 setAdditionalUrlsDraft((arr) => [...arr, url]);306 }307308 async function saveField() {309 const field = editingField();310 const entry = props.entry;311 if (!field || !entry) return;312 313 let value = field === "password" ? draft() : draft().trim();314 if (field === "totp" && value.startsWith("otpauth://")) {315 const parsed = parseOtpauthURI(value);316 value = parsed?.secret ?? value;317 }318 if (field === "password" && entry.type !== "note" && !value) {319 return;320 }321 setSaving(true);322 try {323 const patch: Partial<VaultEntry> = { [field]: value };324 if (field === "url") {325 326 patch.additionalUrls = additionalUrlsDraft();327 }328 await sendMessage({329 type: "UPDATE_ENTRY",330 payload: { id: entry.id, patch },331 });332 triggerRing();333 setEditingField(null);334 setShowGenerator(false);335 setShowHistory(false);336 setRevealedHistory(new Set<number>());337 await props.onFieldSaved();338 } catch {339 340 } finally {341 setSaving(false);342 }343 }344345 function startEditTags() {346 setTagsDraft([...(props.entry?.tags ?? [])]);347 setTagInput("");348 setEditingTags(true);349 }350351 function addTag(value: string) {352 const n = value.toLowerCase().trim();353 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;354 setTagsDraft([...tagsDraft(), n]);355 setTagInput("");356 }357358 async function saveTags() {359 if (tagInput()) addTag(tagInput());360 const entry = props.entry;361 if (!entry) return;362 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;363 setEditingTags(false);364 setSaving(true);365 try {366 await sendMessage({367 type: "UPDATE_ENTRY",368 payload: { id: entry.id, patch: { tags: next } },369 });370 triggerRing();371 await props.onFieldSaved();372 } catch {373 374 } finally {375 setSaving(false);376 }377 }378379 function cancelEdit() {380 setEditingField(null);381 setDraft("");382 setShowGenerator(false);383 setShowHistory(false);384 setRevealedHistory(new Set<number>());385 }386387 388 389 390 391 392 393 394 395 396 async function addOtherIdentity(target: "username" | "email") {397 const entry = props.entry;398 if (!entry) return;399 const current = editingField();400 const draftValue = draft().trim();401 const savedUsername = entry.username ?? "";402 const savedEmail = entry.email ?? "";403404 if (current === "username" && target === "username") {405 406 407 if (savedUsername.includes("@") && !entry.email) {408 setSaving(true);409 try {410 await sendMessage({411 type: "UPDATE_ENTRY",412 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },413 });414 await props.onFieldSaved();415 } finally {416 setSaving(false);417 }418 }419 startEdit("username", "");420 return;421 }422423 if (current === "username" && target === "email") {424 if (draftValue && draftValue !== savedUsername) {425 setSaving(true);426 try {427 await sendMessage({428 type: "UPDATE_ENTRY",429 payload: { id: entry.id, patch: { username: draftValue } },430 });431 await props.onFieldSaved();432 } finally {433 setSaving(false);434 }435 }436 startEdit("email", "");437 return;438 }439440 if (current === "email" && target === "username") {441 if (draftValue && draftValue !== savedEmail) {442 setSaving(true);443 try {444 await sendMessage({445 type: "UPDATE_ENTRY",446 payload: { id: entry.id, patch: { email: draftValue } },447 });448 await props.onFieldSaved();449 } finally {450 setSaving(false);451 }452 }453 startEdit("username", "");454 return;455 }456 }457458 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {459 switch (field) {460 case "title":461 return entry.title;462 case "url":463 return entry.url ?? "";464 case "username":465 return entry.username ?? "";466 case "email":467 return entry.email ?? "";468 case "password":469 return entry.password ?? "";470 case "notes":471 return entry.notes ?? "";472 case "totp":473 return entry.totp ?? "";474 }475 }476477 function handleEditorFocusOut(478 field: Exclude<EditableField, null>,479 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }480 ) {481 const nextTarget = event.relatedTarget;482 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;483484 485 486 487 if (editingField() !== field) return;488489 const entry = props.entry;490 if (!entry) return;491492 const currentValue = fieldValue(entry, field);493 const nextValue = field === "password" ? draft() : draft().trim();494 if (nextValue === currentValue) {495 cancelEdit();496 }497 }498499 500501 async function copyField(text: string, label: string, button?: HTMLButtonElement) {502 const ok = await popupCopy(text, label);503 if (ok) {504 const entry = props.entry;505 if (entry?.canary?.webhookUrl) {506 const trigger =507 label === "password"508 ? "password_copied"509 : label === "username"510 ? "username_copied"511 : undefined;512 if (trigger) {513 void sendMessage({514 type: "FIRE_CANARY_PING",515 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },516 });517 }518 }519 triggerRing();520 triggerPipPulse();521 setCopied(null);522 requestAnimationFrame(() => setCopied(label));523 if (button) {524 button.classList.remove("is-copied", "is-retreat");525 void button.offsetWidth;526 const drain = document.createElement("span");527 drain.className = "peach-copy-drain";528 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";529 drain.appendChild(bar);530 button.appendChild(drain);531 window.setTimeout(() => {532 button.classList.remove("is-copied");533 button.classList.add("is-retreat");534 drain.remove();535 window.setTimeout(() => button.classList.remove("is-retreat"), 160);536 }, 1800);537 }538 window.setTimeout(() => setCopied(null), 1800);539 }540 }541542 543544 createEffect(() => {545 if (!props.entry) return;546 const handler = (e: KeyboardEvent) => {547 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;548 if (inInput) return;549 switch (e.key) {550 case "c": e.preventDefault();551 if (props.entry?.password) void copyField(props.entry.password, "password"); break;552 case "u": e.preventDefault();553 if (props.entry?.username) void copyField(props.entry.username, "username"); break;554 case "Escape": e.preventDefault(); props.onEscape?.(); break;555 }556 };557 window.addEventListener("keydown", handler);558 onCleanup(() => window.removeEventListener("keydown", handler));559 });560561 562563 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {564 requestAnimationFrame(() => { el.focus(); el.select?.(); });565 }566567 function onEditKeyDown(e: KeyboardEvent) {568 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {569 e.preventDefault();570 void saveField();571 }572 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }573 }574575 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {576 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;577 if (e.key === "ArrowDown" && listEl) {578 e.preventDefault();579 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();580 return;581 }582 onEditKeyDown(e);583 }584585 return (586 <>587 <div588 class="peach-ghost"589 aria-hidden="true"590 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;"591 >592 {props.entry?.title || "empty"}593 </div>594595 <div class="peach-stage" style="position:relative;">596 <Show when={props.entry} fallback={<EmptyVaultState />}>597 {(entry) => (598 <>599 {}600 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>601 <div class="peach-breach-badge">602 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">603 <path d="M6 1L11 10H1L6 1z" />604 <line x1="6" y1="5" x2="6" y2="7.5" />605 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />606 </svg>607 <span style="flex:1">found in a known data breach</span>608 </div>609 </Show>610611 {}612 <div class="peach-entry-header">613 <div class="peach-entry-header-name">614 <Show615 when={editingField() === "title"}616 fallback={617 <button618 class="peach-entry-name-button"619 title="Click to edit title"620 onClick={() => startEdit("title", entry().title)}621 >622 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>623 <span class="peach-entry-name-text">624 <Show625 when={useAnimatedTitle()}626 fallback={entry().title}627 >628 <For each={entry().title.split("")}>629 {(ch, i) => (630 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>631 {ch === " " ? "\u00A0" : ch}632 </span>633 )}634 </For>635 </Show>636 </span>637 </h1>638 </button>639 }640 >641 <div642 class="peach-inline-title-wrap"643 onFocusOut={(event) => handleEditorFocusOut("title", event)}644 >645 <input646 class="peach-inline-title-input"647 value={draft()}648 onInput={(e) => setDraft(e.currentTarget.value)}649 onKeyDown={onEditKeyDown}650 ref={autoFocus}651 disabled={saving()}652 />653 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />654 </div>655 </Show>656 </div>657658 {}659 <Show when={editingField() !== "title"}>660 <Show661 when={confirmDelete()}662 fallback={663 <button664 class="peach-entry-trash-btn"665 onClick={() => setConfirmDelete(true)}666 aria-label="Move to trash"667 title="Delete entry"668 >669 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">670 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />671 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />672 </svg>673 </button>674 }675 >676 <div class="peach-entry-trash-confirm">677 <span class="peach-entry-trash-confirm-label">trash?</span>678 <button679 class="peach-btn peach-btn--sm peach-btn--danger"680 style="font-size:10px;padding:2px 8px;min-height:22px;"681 onClick={() => { setConfirmDelete(false); props.onDelete(); }}682 >683 yes684 </button>685 <button686 class="peach-btn peach-btn--ghost peach-btn--sm"687 style="font-size:10px;padding:2px 8px;min-height:22px;"688 onClick={() => setConfirmDelete(false)}689 >690 no691 </button>692 </div>693 </Show>694 </Show>695 </div>696697 {}698 <Show when={entry().type !== "note" && entry().type !== "card"}>699 <div class="peach-domain" style="position:relative;z-index:1;">700 <Show701 when={editingField() === "url"}702 fallback={703 <div704 class="peach-domain-row peach-inline-clickable"705 title="Click to edit URL"706 onClick={() => startEdit("url", entry().url ?? "")}707 >708 <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />709 <span class="peach-domain-value">710 {displayDomain() || (entry().url ? entry().url : "add a website url")}711 </span>712 <span class="peach-inline-edit-hint">edit</span>713 </div>714 }715 >716 <div717 class="peach-inline-field-wrap peach-inline-field-wrap--url"718 onFocusOut={(event) => handleEditorFocusOut("url", event)}719 >720 <div class="peach-inline-field-row">721 <input722 class="peach-inline-input"723 type="url"724 placeholder="https://"725 value={draft()}726 onInput={(e) => setDraft(e.currentTarget.value)}727 onKeyDown={onEditKeyDown}728 ref={autoFocus}729 disabled={saving()}730 />731 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />732 </div>733 <Show734 when={735 detectHomograph(draft()) ||736 additionalUrlsDraft().some((u) => detectHomograph(u))737 }738 >739 {}740 <p class="peach-homograph-warning">741 ⚠ This URL contains non-ASCII or punycode characters.742 Verify it's the site you intend.743 </p>744 </Show>745 <div class="peach-additional-urls">746 <Show when={additionalUrlsDraft().length > 0}>747 <span class="peach-label peach-additional-urls-label">also matches</span>748 </Show>749 <For each={additionalUrlsDraft()}>750 {(value, i) => (751 <div class="peach-additional-url-row">752 <input753 class="peach-inline-input"754 type="url"755 placeholder="https://"756 value={value}757 onInput={(e) => setAdditionalUrlsDraft((arr) => {758 const next = [...arr];759 next[i()] = e.currentTarget.value;760 return next;761 })}762 disabled={saving()}763 />764 <button765 class="peach-additional-url-remove"766 type="button"767 aria-label="Remove URL"768 onMouseDown={(e) => e.preventDefault()}769 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}770 disabled={saving()}771 >×</button>772 </div>773 )}774 </For>775 <button776 class="peach-add-field-link peach-additional-urls-add"777 type="button"778 onMouseDown={(e) => e.preventDefault()}779 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}780 disabled={saving()}781 >782 + add another url783 </button>784 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>785 <button786 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"787 type="button"788 title={props.currentTabUrl}789 onMouseDown={(e) => e.preventDefault()}790 onClick={() => addCurrentSite(props.currentTabUrl!)}791 disabled={saving()}792 >793 + add current site794 </button>795 </Show>796 </div>797 </div>798 </Show>799 </div>800 </Show>801802 {}803 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>804 <div805 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"806 onClick={startEditTags}807 title="Click to edit tags"808 >809 <For each={entry().tags!}>810 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}811 </For>812 </div>813 </Show>814815 {}816 <Show when={entry().type === "note"}>817 <div class="peach-entry-note-toolbar-row">818 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />819 </div>820 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">821 <NoteEditorInline822 entryKey={entry().id}823 initialContent={entry().notes ?? ""}824 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}825 onBlur={saveNoteInline}826 autoFocus={false}827 />828 </section>829 </Show>830831 {}832 <Show when={entry().type === "card"}>833 <section class="peach-fields" style="position:relative;z-index:1;">834 {}835 <Show when={entry().last4}>836 <div class="peach-field-card" style="padding-bottom:4px;">837 <span class="peach-label">838 <Show when={entry().network && entry().network !== "unknown"}>839 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>840 </Show>841 {entry().cardNickname || `•••• ${entry().last4}`}842 </span>843 </div>844 </Show>845846 {}847 <div class="peach-field-card">848 <span class="peach-label">cardholder name</span>849 <div class="peach-field-value-row">850 <span class="peach-field-value">{entry().cardholderName || "—"}</span>851 <Show when={entry().cardholderName}>852 <button853 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}854 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}855 aria-label="Copy cardholder name"856 >857 <CopyGlyph /><span class="peach-copy-sheen" />858 </button>859 </Show>860 </div>861 </div>862863 {}864 <div class="peach-field-card">865 <span class="peach-label">card number</span>866 <div class="peach-field-value-row">867 <span class={`peach-field-value peach-card-number`}>868 <Show when={showCardNumber()} fallback={869 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>870 }>871 <span>{entry().cardNumber ?? ""}</span>872 </Show>873 </span>874 <div class="peach-field-actions">875 <button876 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"877 onClick={() => setShowCardNumber(!showCardNumber())}878 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}879 >880 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>881 <EyeClosedGlyph />882 </Show>883 </button>884 <Show when={entry().cardNumber}>885 <button886 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}887 onClick={(e) => {888 const cardNum = entry().cardNumber!;889 if (entry().canary?.webhookUrl) {890 void sendMessage({891 type: "FIRE_CANARY_PING",892 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },893 });894 }895 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);896 }}897 aria-label="Copy card number"898 >899 <CopyGlyph /><span class="peach-copy-sheen" />900 </button>901 </Show>902 </div>903 </div>904 </div>905906 {}907 <Show when={entry().expMonth || entry().expYear}>908 <div class="peach-field-card">909 <span class="peach-label">expiry</span>910 <div class="peach-field-value-row">911 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>912 <button913 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}914 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}915 aria-label="Copy expiry"916 >917 <CopyGlyph /><span class="peach-copy-sheen" />918 </button>919 </div>920 </div>921 </Show>922923 {}924 <Show when={entry().cvc}>925 <div class="peach-field-card">926 <span class="peach-label">CVC</span>927 <div class="peach-field-value-row">928 <span class="peach-field-value">929 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>930 <span>{entry().cvc}</span>931 </Show>932 </span>933 <div class="peach-field-actions">934 <button935 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"936 onClick={() => setShowCardCvc(!showCardCvc())}937 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}938 >939 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>940 <EyeClosedGlyph />941 </Show>942 </button>943 <button944 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}945 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}946 aria-label="Copy CVC"947 >948 <CopyGlyph /><span class="peach-copy-sheen" />949 </button>950 </div>951 </div>952 </div>953 </Show>954 </section>955 </Show>956957 {}958 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>959 <section class="peach-fields" style="position:relative;z-index:1;">960961 {962963}964 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>965 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>966 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>967 <Show968 when={editingField() === "username"}969 fallback={970 <div class="peach-field-value-row">971 <span972 class="peach-field-value peach-inline-clickable"973 title="Click to edit"974 onClick={() => startEdit("username", entry().username ?? "")}975 >976 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}977 </span>978 <Show when={entry().username}>979 <button980 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}981 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}982 aria-label="Copy username"983 >984 <CopyGlyph /><span class="peach-copy-sheen" />985 </button>986 </Show>987 </div>988 }989 >990 <div991 class="peach-inline-field-wrap"992 onFocusOut={(event) => handleEditorFocusOut("username", event)}993 >994 <input995 class="peach-inline-input"996 value={draft()}997 onInput={(e) => setDraft(e.currentTarget.value)}998 onKeyDown={(e) => identityKeyDown(e, "username")}999 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}1000 disabled={saving()}1001 autocomplete="off"1002 />1003 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1004 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>1005 <IdentitySuggestList1006 listRef={(el) => (usernameIdentityEl = el)}1007 inputRef={() => usernameInlineInput}1008 suggestions={identitySuggestions()}1009 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1010 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1011 />1012 </Show>1013 {101410151016}1017 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>1018 <button1019 class="peach-add-field-btn"1020 type="button"1021 onMouseDown={(e) => e.preventDefault()}1022 onClick={() => {1023 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1024 const hasSeparateEmail = !!entry().email;1025 if (isEmailShape && !hasSeparateEmail) {1026 void addOtherIdentity("username");1027 } else {1028 void addOtherIdentity("email");1029 }1030 }}1031 >1032 {(() => {1033 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1034 const hasSeparateEmail = !!entry().email;1035 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";1036 })()}1037 </button>1038 </Show>1039 </div>1040 </Show>1041 </div>1042 </Show>10431044 {1045}1046 <Show when={entry().email || editingField() === "email"}>1047 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1048 <span class="peach-label">email</span>1049 <Show1050 when={editingField() === "email"}1051 fallback={1052 <div class="peach-field-value-row">1053 <span1054 class="peach-field-value peach-inline-clickable"1055 title="Click to edit"1056 onClick={() => startEdit("email", entry().email ?? "")}1057 >1058 {entry().email}1059 </span>1060 <Show when={entry().email}>1061 <button1062 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1063 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1064 aria-label="Copy email"1065 >1066 <CopyGlyph /><span class="peach-copy-sheen" />1067 </button>1068 </Show>1069 </div>1070 }1071 >1072 <div1073 class="peach-inline-field-wrap"1074 onFocusOut={(event) => handleEditorFocusOut("email", event)}1075 >1076 <input1077 class="peach-inline-input"1078 value={draft()}1079 onInput={(e) => setDraft(e.currentTarget.value)}1080 onKeyDown={(e) => identityKeyDown(e, "email")}1081 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1082 disabled={saving()}1083 autocomplete="email"1084 type="email"1085 />1086 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1087 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1088 <IdentitySuggestList1089 listRef={(el) => (emailIdentityEl = el)}1090 inputRef={() => emailInlineInput}1091 suggestions={identitySuggestions()}1092 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1093 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1094 />1095 </Show>1096 <Show when={!entry().username || entry().username?.includes("@")}>1097 <button1098 class="peach-add-field-btn"1099 type="button"1100 onMouseDown={(e) => e.preventDefault()}1101 onClick={() => void addOtherIdentity("username")}1102 >1103 + add username1104 </button>1105 </Show>1106 </div>1107 </Show>1108 </div>1109 </Show>11101111 {}1112 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1113 <span class="peach-label">password</span>1114 <Show1115 when={editingField() === "password"}1116 fallback={1117 <div class="peach-field-value-row">1118 <span1119 class="peach-password-value"1120 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1121 style="cursor:pointer;"1122 title="Click to edit password"1123 onClick={() => startEdit("password", entry().password ?? "")}1124 >1125 <Show1126 when={showPassword()}1127 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1128 >1129 <span class="peach-password-text">{entry().password ?? ""}</span>1130 </Show>1131 </span>1132 <div class="peach-field-actions">1133 <button1134 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1135 onClick={() => setShowPassword(!showPassword())}1136 aria-label="Reveal password"1137 >1138 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1139 <EyeClosedGlyph />1140 </Show>1141 </button>1142 <button1143 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1144 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1145 aria-label="Copy password"1146 >1147 <CopyGlyph /><span class="peach-copy-sheen" />1148 </button>1149 </div>1150 </div>1151 }1152 >1153 {}1154 <div1155 class="peach-inline-field-wrap"1156 onFocusOut={(event) => handleEditorFocusOut("password", event)}1157 >1158 <div class="peach-password-edit-row">1159 <input1160 class="peach-inline-input"1161 type={showPassword() ? "text" : "password"}1162 placeholder="paste or type a password"1163 value={draft()}1164 onInput={(e) => setDraft(e.currentTarget.value)}1165 onKeyDown={(e) => {1166 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1167 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1168 }}1169 ref={autoFocus}1170 disabled={saving()}1171 autocomplete="new-password"1172 />1173 <button1174 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1175 type="button"1176 onClick={() => setShowPassword(!showPassword())}1177 aria-label={showPassword() ? "Hide" : "Show"}1178 >1179 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1180 <EyeClosedGlyph />1181 </Show>1182 </button>1183 </div>1184 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1185 <div class="peach-password-edit-links">1186 <button1187 class="peach-generate-toggle-link"1188 type="button"1189 onClick={() => { setShowGenerator(!showGenerator()); if (!showGenerator()) setShowHistory(false); }}1190 >1191 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1192 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1193 <path d="M9.5 2.5H11V1" />1194 </svg>1195 {showGenerator() ? "hide generator" : "generate"}1196 </button>1197 <button1198 class="peach-generate-toggle-link"1199 type="button"1200 onClick={() => { setShowHistory(!showHistory()); if (!showHistory()) setShowGenerator(false); }}1201 >1202 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1203 <path d="M6.5 1a5.5 5.5 0 1 0 5.5 5.5" />1204 <path d="M6.5 3.5V6.5L8.5 8" />1205 <path d="M1 1.5V4H3.5" />1206 </svg>1207 {showHistory()1208 ? "hide history"1209 : (entry().passwordHistory?.length ? `history (${entry().passwordHistory!.length})` : "history")}1210 </button>1211 </div>1212 <Show when={showGenerator()}>1213 <GeneratorDrawer1214 open={true}1215 compact={true}1216 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1217 />1218 </Show>1219 <Show when={showHistory()}>1220 <div class="peach-password-history">1221 <Show when={(entry().passwordHistory?.length ?? 0) === 0}>1222 <div class="peach-history-empty">1223 No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.1224 </div>1225 </Show>1226 <For each={entry().passwordHistory ?? []}>1227 {(item, i) => (1228 <div class="peach-history-row">1229 <div class="peach-history-main">1230 <span class="peach-history-pw" classList={{ "is-revealed": revealedHistory().has(i()) }}>1231 <Show1232 when={revealedHistory().has(i())}1233 fallback={<span class="peach-password-mask">••••••••••••</span>}1234 >1235 {item.password}1236 </Show>1237 </span>1238 <span class="peach-history-age">{timeSince(item.changedAt)}</span>1239 </div>1240 <div class="peach-history-actions">1241 <button1242 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1243 type="button"1244 title={revealedHistory().has(i()) ? "Hide" : "Reveal"}1245 onClick={() => setRevealedHistory((prev) => {1246 const next = new Set(prev);1247 if (next.has(i())) next.delete(i()); else next.add(i());1248 return next;1249 })}1250 >1251 <Show when={revealedHistory().has(i())} fallback={<EyeOpenGlyph />}>1252 <EyeClosedGlyph />1253 </Show>1254 </button>1255 <button1256 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === `history-${i()}` ? "is-copied" : ""}`}1257 type="button"1258 title="Copy"1259 onClick={(e) => void copyField(item.password, `history-${i()}`, e.currentTarget as HTMLButtonElement)}1260 >1261 <CopyGlyph /><span class="peach-copy-sheen" />1262 </button>1263 <button1264 class="peach-btn peach-btn--ghost peach-btn--sm peach-history-use"1265 type="button"1266 title="Use this password again"1267 onClick={() => { setDraft(item.password); setShowPassword(true); setShowHistory(false); }}1268 >1269 use1270 </button>1271 </div>1272 </div>1273 )}1274 </For>1275 </div>1276 </Show>1277 </div>1278 </Show>1279 </div>12801281 </section>1282 </Show>12831284 {}1285 <Show when={entry().type !== "note" && entry().type !== "card"}>1286 <Show1287 when={entry().notes || editingField() === "notes"}1288 fallback={1289 <button1290 class="peach-add-field-link peach-entry-add-link"1291 onClick={() => startEdit("notes", "")}1292 >1293 + notes1294 </button>1295 }1296 >1297 <div style="position:relative;z-index:1;margin-top:6px;">1298 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1299 <Show1300 when={editingField() === "notes"}1301 fallback={1302 <div1303 class="peach-note-content peach-inline-clickable"1304 style="cursor:text;white-space:pre-wrap;"1305 onClick={() => startEdit("notes", entry().notes ?? "")}1306 >1307 {entry().notes}1308 </div>1309 }1310 >1311 <div1312 class="peach-inline-field-wrap"1313 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1314 >1315 <textarea1316 class="peach-inline-textarea"1317 value={draft()}1318 onInput={(e) => setDraft(e.currentTarget.value)}1319 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1320 ref={(el) => requestAnimationFrame(() => el?.focus())}1321 rows={4}1322 disabled={saving()}1323 />1324 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1325 </div>1326 </Show>1327 </div>1328 </Show>1329 </Show>13301331 {}1332 <Show1333 when={editingTags()}1334 fallback={1335 <Show when={!entry().tags || entry().tags!.length === 0}>1336 <button1337 class="peach-add-field-link peach-entry-add-link"1338 onClick={startEditTags}1339 >1340 + tags1341 </button>1342 </Show>1343 }1344 >1345 <div1346 class="peach-inline-tag-editor"1347 onFocusOut={(e) => {1348 const next = e.relatedTarget;1349 if (next instanceof Node && e.currentTarget.contains(next)) return;1350 void saveTags();1351 }}1352 >1353 <span class="peach-label">tags</span>1354 <div class="peach-tag-input-wrap">1355 <For each={tagsDraft()}>1356 {(tag) => (1357 <span class="peach-tag-chip">1358 {tag}1359 <button1360 class="peach-tag-chip-remove"1361 onMouseDown={(e) => e.preventDefault()}1362 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1363 aria-label={`Remove ${tag}`}1364 >×</button>1365 </span>1366 )}1367 </For>1368 <input1369 class="peach-tag-input"1370 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1371 value={tagInput()}1372 onInput={(e) => setTagInput(e.currentTarget.value)}1373 onKeyDown={(e) => {1374 if (e.key === "Enter" || e.key === ",") {1375 e.preventDefault();1376 addTag(tagInput());1377 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1378 setTagsDraft(tagsDraft().slice(0, -1));1379 } else if (e.key === "Escape") {1380 e.preventDefault();1381 setEditingTags(false);1382 setTagInput("");1383 }1384 }}1385 ref={(el) => requestAnimationFrame(() => el?.focus())}1386 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1387 />1388 </div>1389 </div>1390 </Show>13911392 {}1393 <Show when={entry().type === "login"}>1394 <Show1395 when={totpState() || editingField() === "totp"}1396 fallback={1397 <button1398 class="peach-add-field-link peach-entry-add-link"1399 onClick={() => startEdit("totp", "")}1400 >1401 + 2FA1402 </button>1403 }1404 >1405 <section class="peach-totp-band">1406 <Show1407 when={editingField() === "totp"}1408 fallback={1409 <Show when={totpState()}>1410 {(state) => (1411 <>1412 <div1413 class="peach-totp-main"1414 onClick={() => {1415 1416 1417 if (totpClickTimer) return;1418 totpClickTimer = setTimeout(() => {1419 totpClickTimer = null;1420 void copyField(state().code.replace(" ", ""), "totp");1421 }, 220);1422 }}1423 onDblClick={() => {1424 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1425 startEdit("totp", entry().totp ?? "");1426 }}1427 title="Click to copy · double-click to edit"1428 >1429 <span class="peach-label">2-factor</span>1430 <div class="peach-totp-code-row">1431 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1432 <span class="peach-totp-code-text">1433 {state().code.slice(0, 3)} {state().code.slice(3)}1434 </span>1435 </p>1436 </div>1437 </div>1438 <div class="peach-totp-meta">1439 <span class="peach-label">expires</span>1440 <div class="peach-totp-countdown">{state().remaining}s</div>1441 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1442 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1443 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1444 stroke-dasharray="106.8"1445 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1446 />1447 </svg>1448 </div>1449 </>1450 )}1451 </Show>1452 }1453 >1454 <div1455 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1456 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1457 >1458 <span class="peach-label">2-factor</span>1459 <input1460 class="peach-inline-input"1461 value={draft()}1462 onInput={(e) => setDraft(e.currentTarget.value)}1463 onKeyDown={onEditKeyDown}1464 ref={autoFocus}1465 disabled={saving()}1466 placeholder="otpauth://... or base32 secret"1467 autocomplete="off"1468 />1469 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1470 </div>1471 </Show>1472 </section>1473 </Show>1474 </Show>14751476 {}1477 <Show when={entry().type !== "note" && entry().type !== "card"}>1478 <button1479 class="peach-add-field-link peach-entry-add-link"1480 onClick={() => setShowAdvanced((v) => !v)}1481 aria-expanded={showAdvanced()}1482 >1483 {showAdvanced() ? "− Advanced" : "+ Advanced"}1484 </button>1485 <Show when={showAdvanced()}>1486 <Show when={!isPasskeyOnly(entry())}>1487 <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1488 </Show>14891490 <Show when={entry().passkey}>1491 {(passkey) => {1492 const currentBrowser = detectBrowser();1493 const crossBrowser =1494 passkey().browser &&1495 currentBrowser !== "unknown" &&1496 passkey().browser !== currentBrowser;1497 const algorithmLabel =1498 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1499 const formatDate = (ts?: number) =>1500 ts ? new Date(ts).toLocaleDateString() : "Never";1501 return (1502 <section1503 class="peach-fields peach-passkey-section"1504 style="position:relative;z-index:1;margin-top:6px;"1505 >1506 <div class="peach-field-card">1507 <span class="peach-label">passkey · {passkey().rpId}</span>1508 <div class="peach-field-value-row">1509 <span class="peach-field-value">{passkey().userName}</span>1510 <span class="peach-passkey-badge">passkey</span>1511 </div>1512 </div>15131514 <div class="peach-field-card">1515 <span class="peach-label">algorithm</span>1516 <div class="peach-field-value-row">1517 <span class="peach-field-value">{algorithmLabel}</span>1518 </div>1519 </div>15201521 <div class="peach-field-card">1522 <span class="peach-label">activity</span>1523 <div class="peach-field-value-row peach-passkey-activity">1524 <span class="peach-field-value">1525 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1526 </span>1527 <span class="peach-passkey-meta">1528 created {formatDate(passkey().createdAt)}1529 </span>1530 <span class="peach-passkey-meta">1531 last used {formatDate(passkey().lastUsedAt)}1532 </span>1533 </div>1534 </div>15351536 <Show when={crossBrowser}>1537 <div class="peach-info-notice peach-info-notice--warn">1538 {`This passkey was created on ${1539 passkey().browser === "chrome" ? "Chrome" : "Firefox"1540 }. To use it on ${1541 currentBrowser === "chrome" ? "Chrome" : "Firefox"1542 }, re-register it on this device.`}1543 </div>1544 </Show>15451546 <div class="peach-info-notice">1547 {passkey().browser === "firefox"1548 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1549 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1550 </div>15511552 <button1553 class="peach-add-field-link peach-entry-add-link"1554 style="color:rgba(220,38,38,0.7);"1555 onClick={async () => {1556 const id = entry().id;1557 await sendMessage({1558 type: "UPDATE_ENTRY",1559 payload: { id, patch: { passkey: undefined } },1560 });1561 await props.onFieldSaved();1562 }}1563 >1564 − remove passkey1565 </button>1566 </section>1567 );1568 }}1569 </Show>1570 </Show>1571 </Show>15721573 {}1574 <div style="height:4px;" />1575 </>1576 )}1577 </Show>1578 </div>15791580 </>1581 );1582}1583158415851586function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1587 return (1588 <div class="peach-inline-actions">1589 <button1590 class="peach-inline-save-btn"1591 onClick={props.onSave}1592 disabled={props.saving}1593 aria-label="Save"1594 title="Save (Enter)"1595 >1596 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1597 <path d="M1 4.5L4 7.5L10 1.5" />1598 </svg>1599 </button>1600 <button1601 class="peach-inline-cancel-btn"1602 onClick={props.onCancel}1603 disabled={props.saving}1604 aria-label="Cancel"1605 title="Cancel (Esc)"1606 >1607 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1608 <path d="M1 1L8 8M8 1L1 8" />1609 </svg>1610 </button>1611 </div>1612 );1613}16141615function EmptyVaultState() {1616 return (1617 <>1618 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1619 <div class="peach-entry-name">1620 <span class="peach-entry-name-text">1621 <span class="peach-entry-name-char" style="--char-index:0">e</span>1622 <span class="peach-entry-name-char" style="--char-index:1">m</span>1623 <span class="peach-entry-name-char" style="--char-index:2">p</span>1624 <span class="peach-entry-name-char" style="--char-index:3">t</span>1625 <span class="peach-entry-name-char" style="--char-index:4">y</span>1626 </span>1627 </div>1628 </div>1629 <div class="peach-empty-copy">1630 <span class="peach-label">vault</span>1631 <p>Your vault is ready for its first entry.</p>1632 </div>1633 </>1634 );1635}16361637function CopyGlyph() {1638 return (1639 <span class="peach-copy-icon">1640 <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">1641 <rect x="4" y="4" width="8" height="9" rx="1.5" />1642 <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" />1643 </svg>1644 </span>1645 );1646}16471648function EyeClosedGlyph() {1649 return (1650 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1651 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1652 <path d="M3.5 5.5L2.5 7.5" />1653 <path d="M7 6.5V8.5" />1654 <path d="M10.5 5.5L11.5 7.5" />1655 </svg>1656 );1657}16581659function EyeOpenGlyph() {1660 return (1661 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1662 <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" />1663 <circle cx="7" cy="5" r="1.75" />1664 </svg>1665 );1666}16671668function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1669 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1670 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1671 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1672 const [testResult, setTestResult] = createSignal<string | null>(null);1673 const [testing, setTesting] = createSignal(false);16741675 1676 1677 1678 1679 1680 1681 const isProGated = () => !props.isPro && !props.entry.canary;16821683 async function save() {1684 const url = webhookUrl().trim();1685 const nextLabel = label().trim() || undefined;1686 const canary = enabled() && url1687 ? {1688 webhookUrl: url,1689 label: nextLabel,1690 lastPinged: props.entry.canary?.lastPinged,1691 pingCount: props.entry.canary?.pingCount,1692 }1693 : undefined;1694 await sendMessage({1695 type: "UPDATE_ENTRY",1696 payload: { id: props.entry.id, patch: { canary } },1697 });1698 await props.onSaved();1699 }17001701 async function testPing() {1702 const url = webhookUrl().trim();1703 if (!url) return;1704 setTesting(true);1705 setTestResult(null);1706 const res = await sendMessage({1707 type: "FIRE_CANARY_PING",1708 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1709 }) as { ok: boolean; error?: string };1710 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1711 setTesting(false);1712 }17131714 return (1715 <div class="peach-canary-section">1716 <div class="peach-field-value-row">1717 <span class="peach-field-value" style="font-size:12px;">1718 canary alert1719 <Show when={isProGated()}>1720 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1721 </Show>1722 </span>1723 <button1724 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1725 onClick={() => {1726 if (isProGated()) {1727 1728 1729 1730 props.onProGate?.("canary_entry");1731 return;1732 }1733 setEnabled(!enabled());1734 void save();1735 }}1736 aria-label="Toggle canary"1737 aria-disabled={isProGated() ? "true" : "false"}1738 >1739 <span class="peach-toggle-knob" />1740 </button>1741 </div>1742 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1743 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1744 </p>1745 <Show when={enabled()}>1746 <div class="peach-inline-field-wrap" style="margin-top:8px;">1747 <input1748 class="peach-inline-input"1749 placeholder="https://hooks.zapier.com/..."1750 value={webhookUrl()}1751 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1752 onBlur={() => void save()}1753 />1754 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1755 When triggered, the entry title is sent to this URL. Use an endpoint you control.1756 </p>1757 <input1758 class="peach-inline-input"1759 placeholder="label (optional, e.g. AWS decoy)"1760 value={label()}1761 onInput={(e) => setLabel(e.currentTarget.value)}1762 onBlur={() => void save()}1763 />1764 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1765 <button1766 class="peach-btn peach-btn--ghost peach-btn--sm"1767 onClick={() => void testPing()}1768 disabled={testing() || !webhookUrl().trim()}1769 >1770 {testing() ? "sending..." : "test ping"}1771 </button>1772 <Show when={testResult()}>1773 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1774 </Show>1775 </div>1776 <Show when={props.entry.canary?.lastPinged}>1777 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1778 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1779 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1780 </p>1781 </Show>1782 </div>1783 </Show>1784 </div>1785 );1786}