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(props.entry.totp);217 });218219 let prevTotpCode: string | undefined;220 createEffect(() => {221 const state = totpState();222 if (!state) { prevTotpCode = undefined; return; }223 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {224 setTotpHot(true);225 window.setTimeout(() => setTotpHot(false), 3000);226 }227 prevTotpCode = state.code;228 });229230 const totpProgress = createMemo(() => {231 const state = totpState();232 if (!state) return 0;233 return ((state.remaining - 1) / 29) * 100;234 });235236 createEffect(() => {237 const shell = document.getElementById("hd-shell");238 if (!shell) return;239240 const state = totpState();241 if (!state) {242 delete shell.dataset.totpActive;243 shell.style.removeProperty("--peach-totp-progress");244 return;245 }246247 shell.dataset.totpActive = "true";248 shell.style.setProperty(249 "--peach-totp-progress",250 `${Math.max(0, totpProgress())}%`251 );252253 onCleanup(() => {254 delete shell.dataset.totpActive;255 shell.style.removeProperty("--peach-totp-progress");256 });257 });258259 const titleSizeClass = createMemo(() => {260 const length = props.entry?.title.length ?? 0;261 if (length >= 28) return "peach-entry-name--xxl";262 if (length >= 20) return "peach-entry-name--xl";263 if (length >= 14) return "peach-entry-name--lg";264 return "";265 });266 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);267268 269270 function startEdit(field: EditableField, value: string) {271 setEditingField(field);272 setDraft(value);273 setIdentityHelperSuppressed(false);274 setShowGenerator(false);275 setShowHistory(false);276 setRevealedHistory(new Set<number>());277 setConfirmDelete(false);278 if (field === "url") {279 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);280 }281 }282283 284 285 286 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {287 const c = candidate.trim();288 if (!c) return true;289 if (primary.trim() === c) return true;290 return others.some((u) => u.trim() === c);291 }292293 function addCurrentSite(url: string) {294 if (!url) return;295 if (!draft().trim()) {296 setDraft(url);297 return;298 }299 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;300 setAdditionalUrlsDraft((arr) => [...arr, url]);301 }302303 async function saveField() {304 const field = editingField();305 const entry = props.entry;306 if (!field || !entry) return;307 308 let value = field === "password" ? draft() : draft().trim();309 if (field === "totp" && value.startsWith("otpauth://")) {310 const parsed = parseOtpauthURI(value);311 value = parsed?.secret ?? value;312 }313 if (field === "password" && entry.type !== "note" && !value) {314 return;315 }316 setSaving(true);317 try {318 const patch: Partial<VaultEntry> = { [field]: value };319 if (field === "url") {320 321 patch.additionalUrls = additionalUrlsDraft();322 }323 await sendMessage({324 type: "UPDATE_ENTRY",325 payload: { id: entry.id, patch },326 });327 triggerRing();328 setEditingField(null);329 setShowGenerator(false);330 setShowHistory(false);331 setRevealedHistory(new Set<number>());332 await props.onFieldSaved();333 } catch {334 335 } finally {336 setSaving(false);337 }338 }339340 function startEditTags() {341 setTagsDraft([...(props.entry?.tags ?? [])]);342 setTagInput("");343 setEditingTags(true);344 }345346 function addTag(value: string) {347 const n = value.toLowerCase().trim();348 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;349 setTagsDraft([...tagsDraft(), n]);350 setTagInput("");351 }352353 async function saveTags() {354 if (tagInput()) addTag(tagInput());355 const entry = props.entry;356 if (!entry) return;357 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;358 setEditingTags(false);359 setSaving(true);360 try {361 await sendMessage({362 type: "UPDATE_ENTRY",363 payload: { id: entry.id, patch: { tags: next } },364 });365 triggerRing();366 await props.onFieldSaved();367 } catch {368 369 } finally {370 setSaving(false);371 }372 }373374 function cancelEdit() {375 setEditingField(null);376 setDraft("");377 setShowGenerator(false);378 setShowHistory(false);379 setRevealedHistory(new Set<number>());380 }381382 383 384 385 386 387 388 389 390 391 async function addOtherIdentity(target: "username" | "email") {392 const entry = props.entry;393 if (!entry) return;394 const current = editingField();395 const draftValue = draft().trim();396 const savedUsername = entry.username ?? "";397 const savedEmail = entry.email ?? "";398399 if (current === "username" && target === "username") {400 401 402 if (savedUsername.includes("@") && !entry.email) {403 setSaving(true);404 try {405 await sendMessage({406 type: "UPDATE_ENTRY",407 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },408 });409 await props.onFieldSaved();410 } finally {411 setSaving(false);412 }413 }414 startEdit("username", "");415 return;416 }417418 if (current === "username" && target === "email") {419 if (draftValue && draftValue !== savedUsername) {420 setSaving(true);421 try {422 await sendMessage({423 type: "UPDATE_ENTRY",424 payload: { id: entry.id, patch: { username: draftValue } },425 });426 await props.onFieldSaved();427 } finally {428 setSaving(false);429 }430 }431 startEdit("email", "");432 return;433 }434435 if (current === "email" && target === "username") {436 if (draftValue && draftValue !== savedEmail) {437 setSaving(true);438 try {439 await sendMessage({440 type: "UPDATE_ENTRY",441 payload: { id: entry.id, patch: { email: draftValue } },442 });443 await props.onFieldSaved();444 } finally {445 setSaving(false);446 }447 }448 startEdit("username", "");449 return;450 }451 }452453 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {454 switch (field) {455 case "title":456 return entry.title;457 case "url":458 return entry.url ?? "";459 case "username":460 return entry.username ?? "";461 case "email":462 return entry.email ?? "";463 case "password":464 return entry.password ?? "";465 case "notes":466 return entry.notes ?? "";467 case "totp":468 return entry.totp ?? "";469 }470 }471472 function handleEditorFocusOut(473 field: Exclude<EditableField, null>,474 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }475 ) {476 const nextTarget = event.relatedTarget;477 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;478479 480 481 482 if (editingField() !== field) return;483484 const entry = props.entry;485 if (!entry) return;486487 const currentValue = fieldValue(entry, field);488 const nextValue = field === "password" ? draft() : draft().trim();489 if (nextValue === currentValue) {490 cancelEdit();491 }492 }493494 495496 async function copyField(text: string, label: string, button?: HTMLButtonElement) {497 const ok = await popupCopy(text, label);498 if (ok) {499 const entry = props.entry;500 if (entry?.canary?.webhookUrl) {501 const trigger =502 label === "password"503 ? "password_copied"504 : label === "username"505 ? "username_copied"506 : undefined;507 if (trigger) {508 void sendMessage({509 type: "FIRE_CANARY_PING",510 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },511 });512 }513 }514 triggerRing();515 triggerPipPulse();516 setCopied(null);517 requestAnimationFrame(() => setCopied(label));518 if (button) {519 button.classList.remove("is-copied", "is-retreat");520 void button.offsetWidth;521 const drain = document.createElement("span");522 drain.className = "peach-copy-drain";523 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";524 drain.appendChild(bar);525 button.appendChild(drain);526 window.setTimeout(() => {527 button.classList.remove("is-copied");528 button.classList.add("is-retreat");529 drain.remove();530 window.setTimeout(() => button.classList.remove("is-retreat"), 160);531 }, 1800);532 }533 window.setTimeout(() => setCopied(null), 1800);534 }535 }536537 538539 createEffect(() => {540 if (!props.entry) return;541 const handler = (e: KeyboardEvent) => {542 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;543 if (inInput) return;544 switch (e.key) {545 case "c": e.preventDefault();546 if (props.entry?.password) void copyField(props.entry.password, "password"); break;547 case "u": e.preventDefault();548 if (props.entry?.username) void copyField(props.entry.username, "username"); break;549 case "Escape": e.preventDefault(); props.onEscape?.(); break;550 }551 };552 window.addEventListener("keydown", handler);553 onCleanup(() => window.removeEventListener("keydown", handler));554 });555556 557558 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {559 requestAnimationFrame(() => { el.focus(); el.select?.(); });560 }561562 function onEditKeyDown(e: KeyboardEvent) {563 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {564 e.preventDefault();565 void saveField();566 }567 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }568 }569570 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {571 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;572 if (e.key === "ArrowDown" && listEl) {573 e.preventDefault();574 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();575 return;576 }577 onEditKeyDown(e);578 }579580 return (581 <>582 <div583 class="peach-ghost"584 aria-hidden="true"585 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;"586 >587 {props.entry?.title || "empty"}588 </div>589590 <div class="peach-stage" style="position:relative;">591 <Show when={props.entry} fallback={<EmptyVaultState />}>592 {(entry) => (593 <>594 {}595 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>596 <div class="peach-breach-badge">597 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">598 <path d="M6 1L11 10H1L6 1z" />599 <line x1="6" y1="5" x2="6" y2="7.5" />600 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />601 </svg>602 <span style="flex:1">found in a known data breach</span>603 </div>604 </Show>605606 {}607 <div class="peach-entry-header">608 <div class="peach-entry-header-name">609 <Show610 when={editingField() === "title"}611 fallback={612 <button613 class="peach-entry-name-button"614 title="Click to edit title"615 onClick={() => startEdit("title", entry().title)}616 >617 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>618 <span class="peach-entry-name-text">619 <Show620 when={useAnimatedTitle()}621 fallback={entry().title}622 >623 <For each={entry().title.split("")}>624 {(ch, i) => (625 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>626 {ch === " " ? "\u00A0" : ch}627 </span>628 )}629 </For>630 </Show>631 </span>632 </h1>633 </button>634 }635 >636 <div637 class="peach-inline-title-wrap"638 onFocusOut={(event) => handleEditorFocusOut("title", event)}639 >640 <input641 class="peach-inline-title-input"642 value={draft()}643 onInput={(e) => setDraft(e.currentTarget.value)}644 onKeyDown={onEditKeyDown}645 ref={autoFocus}646 disabled={saving()}647 />648 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />649 </div>650 </Show>651 </div>652653 {}654 <Show when={editingField() !== "title"}>655 <Show656 when={confirmDelete()}657 fallback={658 <button659 class="peach-entry-trash-btn"660 onClick={() => setConfirmDelete(true)}661 aria-label="Move to trash"662 title="Delete entry"663 >664 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">665 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />666 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />667 </svg>668 </button>669 }670 >671 <div class="peach-entry-trash-confirm">672 <span class="peach-entry-trash-confirm-label">trash?</span>673 <button674 class="peach-btn peach-btn--sm peach-btn--danger"675 style="font-size:10px;padding:2px 8px;min-height:22px;"676 onClick={() => { setConfirmDelete(false); props.onDelete(); }}677 >678 yes679 </button>680 <button681 class="peach-btn peach-btn--ghost peach-btn--sm"682 style="font-size:10px;padding:2px 8px;min-height:22px;"683 onClick={() => setConfirmDelete(false)}684 >685 no686 </button>687 </div>688 </Show>689 </Show>690 </div>691692 {}693 <Show when={entry().type !== "note" && entry().type !== "card"}>694 <div class="peach-domain" style="position:relative;z-index:1;">695 <Show696 when={editingField() === "url"}697 fallback={698 <div699 class="peach-domain-row peach-inline-clickable"700 title="Click to edit URL"701 onClick={() => startEdit("url", entry().url ?? "")}702 >703 <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />704 <span class="peach-domain-value">705 {displayDomain() || (entry().url ? entry().url : "add a website url")}706 </span>707 <span class="peach-inline-edit-hint">edit</span>708 </div>709 }710 >711 <div712 class="peach-inline-field-wrap peach-inline-field-wrap--url"713 onFocusOut={(event) => handleEditorFocusOut("url", event)}714 >715 <div class="peach-inline-field-row">716 <input717 class="peach-inline-input"718 type="url"719 placeholder="https://"720 value={draft()}721 onInput={(e) => setDraft(e.currentTarget.value)}722 onKeyDown={onEditKeyDown}723 ref={autoFocus}724 disabled={saving()}725 />726 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />727 </div>728 <Show729 when={730 detectHomograph(draft()) ||731 additionalUrlsDraft().some((u) => detectHomograph(u))732 }733 >734 {}735 <p class="peach-homograph-warning">736 ⚠ This URL contains non-ASCII or punycode characters.737 Verify it's the site you intend.738 </p>739 </Show>740 <div class="peach-additional-urls">741 <Show when={additionalUrlsDraft().length > 0}>742 <span class="peach-label peach-additional-urls-label">also matches</span>743 </Show>744 <For each={additionalUrlsDraft()}>745 {(value, i) => (746 <div class="peach-additional-url-row">747 <input748 class="peach-inline-input"749 type="url"750 placeholder="https://"751 value={value}752 onInput={(e) => setAdditionalUrlsDraft((arr) => {753 const next = [...arr];754 next[i()] = e.currentTarget.value;755 return next;756 })}757 disabled={saving()}758 />759 <button760 class="peach-additional-url-remove"761 type="button"762 aria-label="Remove URL"763 onMouseDown={(e) => e.preventDefault()}764 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}765 disabled={saving()}766 >×</button>767 </div>768 )}769 </For>770 <button771 class="peach-add-field-link peach-additional-urls-add"772 type="button"773 onMouseDown={(e) => e.preventDefault()}774 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}775 disabled={saving()}776 >777 + add another url778 </button>779 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>780 <button781 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"782 type="button"783 title={props.currentTabUrl}784 onMouseDown={(e) => e.preventDefault()}785 onClick={() => addCurrentSite(props.currentTabUrl!)}786 disabled={saving()}787 >788 + add current site789 </button>790 </Show>791 </div>792 </div>793 </Show>794 </div>795 </Show>796797 {}798 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>799 <div800 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"801 onClick={startEditTags}802 title="Click to edit tags"803 >804 <For each={entry().tags!}>805 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}806 </For>807 </div>808 </Show>809810 {}811 <Show when={entry().type === "note"}>812 <div class="peach-entry-note-toolbar-row">813 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />814 </div>815 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">816 <NoteEditorInline817 entryKey={entry().id}818 initialContent={entry().notes ?? ""}819 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}820 onBlur={saveNoteInline}821 autoFocus={false}822 />823 </section>824 </Show>825826 {}827 <Show when={entry().type === "card"}>828 <section class="peach-fields" style="position:relative;z-index:1;">829 {}830 <Show when={entry().last4}>831 <div class="peach-field-card" style="padding-bottom:4px;">832 <span class="peach-label">833 <Show when={entry().network && entry().network !== "unknown"}>834 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>835 </Show>836 {entry().cardNickname || `•••• ${entry().last4}`}837 </span>838 </div>839 </Show>840841 {}842 <div class="peach-field-card">843 <span class="peach-label">cardholder name</span>844 <div class="peach-field-value-row">845 <span class="peach-field-value">{entry().cardholderName || "—"}</span>846 <Show when={entry().cardholderName}>847 <button848 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}849 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}850 aria-label="Copy cardholder name"851 >852 <CopyGlyph /><span class="peach-copy-sheen" />853 </button>854 </Show>855 </div>856 </div>857858 {}859 <div class="peach-field-card">860 <span class="peach-label">card number</span>861 <div class="peach-field-value-row">862 <span class={`peach-field-value peach-card-number`}>863 <Show when={showCardNumber()} fallback={864 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>865 }>866 <span>{entry().cardNumber ?? ""}</span>867 </Show>868 </span>869 <div class="peach-field-actions">870 <button871 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"872 onClick={() => setShowCardNumber(!showCardNumber())}873 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}874 >875 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>876 <EyeClosedGlyph />877 </Show>878 </button>879 <Show when={entry().cardNumber}>880 <button881 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}882 onClick={(e) => {883 const cardNum = entry().cardNumber!;884 if (entry().canary?.webhookUrl) {885 void sendMessage({886 type: "FIRE_CANARY_PING",887 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },888 });889 }890 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);891 }}892 aria-label="Copy card number"893 >894 <CopyGlyph /><span class="peach-copy-sheen" />895 </button>896 </Show>897 </div>898 </div>899 </div>900901 {}902 <Show when={entry().expMonth || entry().expYear}>903 <div class="peach-field-card">904 <span class="peach-label">expiry</span>905 <div class="peach-field-value-row">906 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>907 <button908 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}909 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}910 aria-label="Copy expiry"911 >912 <CopyGlyph /><span class="peach-copy-sheen" />913 </button>914 </div>915 </div>916 </Show>917918 {}919 <Show when={entry().cvc}>920 <div class="peach-field-card">921 <span class="peach-label">CVC</span>922 <div class="peach-field-value-row">923 <span class="peach-field-value">924 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>925 <span>{entry().cvc}</span>926 </Show>927 </span>928 <div class="peach-field-actions">929 <button930 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"931 onClick={() => setShowCardCvc(!showCardCvc())}932 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}933 >934 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>935 <EyeClosedGlyph />936 </Show>937 </button>938 <button939 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}940 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}941 aria-label="Copy CVC"942 >943 <CopyGlyph /><span class="peach-copy-sheen" />944 </button>945 </div>946 </div>947 </div>948 </Show>949 </section>950 </Show>951952 {}953 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>954 <section class="peach-fields" style="position:relative;z-index:1;">955956 {957958}959 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>960 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>961 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>962 <Show963 when={editingField() === "username"}964 fallback={965 <div class="peach-field-value-row">966 <span967 class="peach-field-value peach-inline-clickable"968 title="Click to edit"969 onClick={() => startEdit("username", entry().username ?? "")}970 >971 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}972 </span>973 <Show when={entry().username}>974 <button975 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}976 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}977 aria-label="Copy username"978 >979 <CopyGlyph /><span class="peach-copy-sheen" />980 </button>981 </Show>982 </div>983 }984 >985 <div986 class="peach-inline-field-wrap"987 onFocusOut={(event) => handleEditorFocusOut("username", event)}988 >989 <input990 class="peach-inline-input"991 value={draft()}992 onInput={(e) => setDraft(e.currentTarget.value)}993 onKeyDown={(e) => identityKeyDown(e, "username")}994 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}995 disabled={saving()}996 autocomplete="off"997 />998 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />999 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>1000 <IdentitySuggestList1001 listRef={(el) => (usernameIdentityEl = el)}1002 inputRef={() => usernameInlineInput}1003 suggestions={identitySuggestions()}1004 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1005 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1006 />1007 </Show>1008 {100910101011}1012 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>1013 <button1014 class="peach-add-field-btn"1015 type="button"1016 onMouseDown={(e) => e.preventDefault()}1017 onClick={() => {1018 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1019 const hasSeparateEmail = !!entry().email;1020 if (isEmailShape && !hasSeparateEmail) {1021 void addOtherIdentity("username");1022 } else {1023 void addOtherIdentity("email");1024 }1025 }}1026 >1027 {(() => {1028 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1029 const hasSeparateEmail = !!entry().email;1030 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";1031 })()}1032 </button>1033 </Show>1034 </div>1035 </Show>1036 </div>1037 </Show>10381039 {1040}1041 <Show when={entry().email || editingField() === "email"}>1042 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1043 <span class="peach-label">email</span>1044 <Show1045 when={editingField() === "email"}1046 fallback={1047 <div class="peach-field-value-row">1048 <span1049 class="peach-field-value peach-inline-clickable"1050 title="Click to edit"1051 onClick={() => startEdit("email", entry().email ?? "")}1052 >1053 {entry().email}1054 </span>1055 <Show when={entry().email}>1056 <button1057 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1058 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1059 aria-label="Copy email"1060 >1061 <CopyGlyph /><span class="peach-copy-sheen" />1062 </button>1063 </Show>1064 </div>1065 }1066 >1067 <div1068 class="peach-inline-field-wrap"1069 onFocusOut={(event) => handleEditorFocusOut("email", event)}1070 >1071 <input1072 class="peach-inline-input"1073 value={draft()}1074 onInput={(e) => setDraft(e.currentTarget.value)}1075 onKeyDown={(e) => identityKeyDown(e, "email")}1076 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1077 disabled={saving()}1078 autocomplete="email"1079 type="email"1080 />1081 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1082 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1083 <IdentitySuggestList1084 listRef={(el) => (emailIdentityEl = el)}1085 inputRef={() => emailInlineInput}1086 suggestions={identitySuggestions()}1087 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1088 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1089 />1090 </Show>1091 <Show when={!entry().username || entry().username?.includes("@")}>1092 <button1093 class="peach-add-field-btn"1094 type="button"1095 onMouseDown={(e) => e.preventDefault()}1096 onClick={() => void addOtherIdentity("username")}1097 >1098 + add username1099 </button>1100 </Show>1101 </div>1102 </Show>1103 </div>1104 </Show>11051106 {}1107 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1108 <span class="peach-label">password</span>1109 <Show1110 when={editingField() === "password"}1111 fallback={1112 <div class="peach-field-value-row">1113 <span1114 class="peach-password-value"1115 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1116 style="cursor:pointer;"1117 title="Click to edit password"1118 onClick={() => startEdit("password", entry().password ?? "")}1119 >1120 <Show1121 when={showPassword()}1122 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1123 >1124 <span class="peach-password-text">{entry().password ?? ""}</span>1125 </Show>1126 </span>1127 <div class="peach-field-actions">1128 <button1129 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1130 onClick={() => setShowPassword(!showPassword())}1131 aria-label="Reveal password"1132 >1133 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1134 <EyeClosedGlyph />1135 </Show>1136 </button>1137 <button1138 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1139 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1140 aria-label="Copy password"1141 >1142 <CopyGlyph /><span class="peach-copy-sheen" />1143 </button>1144 </div>1145 </div>1146 }1147 >1148 {}1149 <div1150 class="peach-inline-field-wrap"1151 onFocusOut={(event) => handleEditorFocusOut("password", event)}1152 >1153 <div class="peach-password-edit-row">1154 <input1155 class="peach-inline-input"1156 type={showPassword() ? "text" : "password"}1157 placeholder="paste or type a password"1158 value={draft()}1159 onInput={(e) => setDraft(e.currentTarget.value)}1160 onKeyDown={(e) => {1161 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1162 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1163 }}1164 ref={autoFocus}1165 disabled={saving()}1166 autocomplete="new-password"1167 />1168 <button1169 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1170 type="button"1171 onClick={() => setShowPassword(!showPassword())}1172 aria-label={showPassword() ? "Hide" : "Show"}1173 >1174 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1175 <EyeClosedGlyph />1176 </Show>1177 </button>1178 </div>1179 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1180 <div class="peach-password-edit-links">1181 <button1182 class="peach-generate-toggle-link"1183 type="button"1184 onClick={() => { setShowGenerator(!showGenerator()); if (!showGenerator()) setShowHistory(false); }}1185 >1186 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1187 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1188 <path d="M9.5 2.5H11V1" />1189 </svg>1190 {showGenerator() ? "hide generator" : "generate"}1191 </button>1192 <button1193 class="peach-generate-toggle-link"1194 type="button"1195 onClick={() => { setShowHistory(!showHistory()); if (!showHistory()) setShowGenerator(false); }}1196 >1197 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1198 <path d="M6.5 1a5.5 5.5 0 1 0 5.5 5.5" />1199 <path d="M6.5 3.5V6.5L8.5 8" />1200 <path d="M1 1.5V4H3.5" />1201 </svg>1202 {showHistory()1203 ? "hide history"1204 : (entry().passwordHistory?.length ? `history (${entry().passwordHistory!.length})` : "history")}1205 </button>1206 </div>1207 <Show when={showGenerator()}>1208 <GeneratorDrawer1209 open={true}1210 compact={true}1211 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1212 />1213 </Show>1214 <Show when={showHistory()}>1215 <div class="peach-password-history">1216 <Show when={(entry().passwordHistory?.length ?? 0) === 0}>1217 <div class="peach-history-empty">1218 No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.1219 </div>1220 </Show>1221 <For each={entry().passwordHistory ?? []}>1222 {(item, i) => (1223 <div class="peach-history-row">1224 <div class="peach-history-main">1225 <span class="peach-history-pw" classList={{ "is-revealed": revealedHistory().has(i()) }}>1226 <Show1227 when={revealedHistory().has(i())}1228 fallback={<span class="peach-password-mask">••••••••••••</span>}1229 >1230 {item.password}1231 </Show>1232 </span>1233 <span class="peach-history-age">{timeSince(item.changedAt)}</span>1234 </div>1235 <div class="peach-history-actions">1236 <button1237 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1238 type="button"1239 title={revealedHistory().has(i()) ? "Hide" : "Reveal"}1240 onClick={() => setRevealedHistory((prev) => {1241 const next = new Set(prev);1242 if (next.has(i())) next.delete(i()); else next.add(i());1243 return next;1244 })}1245 >1246 <Show when={revealedHistory().has(i())} fallback={<EyeOpenGlyph />}>1247 <EyeClosedGlyph />1248 </Show>1249 </button>1250 <button1251 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === `history-${i()}` ? "is-copied" : ""}`}1252 type="button"1253 title="Copy"1254 onClick={(e) => void copyField(item.password, `history-${i()}`, e.currentTarget as HTMLButtonElement)}1255 >1256 <CopyGlyph /><span class="peach-copy-sheen" />1257 </button>1258 <button1259 class="peach-btn peach-btn--ghost peach-btn--sm peach-history-use"1260 type="button"1261 title="Use this password again"1262 onClick={() => { setDraft(item.password); setShowPassword(true); setShowHistory(false); }}1263 >1264 use1265 </button>1266 </div>1267 </div>1268 )}1269 </For>1270 </div>1271 </Show>1272 </div>1273 </Show>1274 </div>12751276 </section>1277 </Show>12781279 {}1280 <Show when={entry().type !== "note" && entry().type !== "card"}>1281 <Show1282 when={entry().notes || editingField() === "notes"}1283 fallback={1284 <button1285 class="peach-add-field-link peach-entry-add-link"1286 onClick={() => startEdit("notes", "")}1287 >1288 + notes1289 </button>1290 }1291 >1292 <div style="position:relative;z-index:1;margin-top:6px;">1293 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1294 <Show1295 when={editingField() === "notes"}1296 fallback={1297 <div1298 class="peach-note-content peach-inline-clickable"1299 style="cursor:text;white-space:pre-wrap;"1300 onClick={() => startEdit("notes", entry().notes ?? "")}1301 >1302 {entry().notes}1303 </div>1304 }1305 >1306 <div1307 class="peach-inline-field-wrap"1308 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1309 >1310 <textarea1311 class="peach-inline-textarea"1312 value={draft()}1313 onInput={(e) => setDraft(e.currentTarget.value)}1314 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1315 ref={(el) => requestAnimationFrame(() => el?.focus())}1316 rows={4}1317 disabled={saving()}1318 />1319 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1320 </div>1321 </Show>1322 </div>1323 </Show>1324 </Show>13251326 {}1327 <Show1328 when={editingTags()}1329 fallback={1330 <Show when={!entry().tags || entry().tags!.length === 0}>1331 <button1332 class="peach-add-field-link peach-entry-add-link"1333 onClick={startEditTags}1334 >1335 + tags1336 </button>1337 </Show>1338 }1339 >1340 <div1341 class="peach-inline-tag-editor"1342 onFocusOut={(e) => {1343 const next = e.relatedTarget;1344 if (next instanceof Node && e.currentTarget.contains(next)) return;1345 void saveTags();1346 }}1347 >1348 <span class="peach-label">tags</span>1349 <div class="peach-tag-input-wrap">1350 <For each={tagsDraft()}>1351 {(tag) => (1352 <span class="peach-tag-chip">1353 {tag}1354 <button1355 class="peach-tag-chip-remove"1356 onMouseDown={(e) => e.preventDefault()}1357 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1358 aria-label={`Remove ${tag}`}1359 >×</button>1360 </span>1361 )}1362 </For>1363 <input1364 class="peach-tag-input"1365 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1366 value={tagInput()}1367 onInput={(e) => setTagInput(e.currentTarget.value)}1368 onKeyDown={(e) => {1369 if (e.key === "Enter" || e.key === ",") {1370 e.preventDefault();1371 addTag(tagInput());1372 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1373 setTagsDraft(tagsDraft().slice(0, -1));1374 } else if (e.key === "Escape") {1375 e.preventDefault();1376 setEditingTags(false);1377 setTagInput("");1378 }1379 }}1380 ref={(el) => requestAnimationFrame(() => el?.focus())}1381 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1382 />1383 </div>1384 </div>1385 </Show>13861387 {}1388 <Show when={entry().type === "login"}>1389 <Show1390 when={totpState() || editingField() === "totp"}1391 fallback={1392 <button1393 class="peach-add-field-link peach-entry-add-link"1394 onClick={() => startEdit("totp", "")}1395 >1396 + 2FA1397 </button>1398 }1399 >1400 <section class="peach-totp-band">1401 <Show1402 when={editingField() === "totp"}1403 fallback={1404 <Show when={totpState()}>1405 {(state) => (1406 <>1407 <div1408 class="peach-totp-main"1409 onClick={() => {1410 1411 1412 if (totpClickTimer) return;1413 totpClickTimer = setTimeout(() => {1414 totpClickTimer = null;1415 void copyField(state().code.replace(" ", ""), "totp");1416 }, 220);1417 }}1418 onDblClick={() => {1419 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1420 startEdit("totp", entry().totp ?? "");1421 }}1422 title="Click to copy · double-click to edit"1423 >1424 <span class="peach-label">2-factor</span>1425 <div class="peach-totp-code-row">1426 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1427 <span class="peach-totp-code-text">1428 {state().code.slice(0, 3)} {state().code.slice(3)}1429 </span>1430 </p>1431 </div>1432 </div>1433 <div class="peach-totp-meta">1434 <span class="peach-label">expires</span>1435 <div class="peach-totp-countdown">{state().remaining}s</div>1436 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1437 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1438 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1439 stroke-dasharray="106.8"1440 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1441 />1442 </svg>1443 </div>1444 </>1445 )}1446 </Show>1447 }1448 >1449 <div1450 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1451 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1452 >1453 <span class="peach-label">2-factor</span>1454 <input1455 class="peach-inline-input"1456 value={draft()}1457 onInput={(e) => setDraft(e.currentTarget.value)}1458 onKeyDown={onEditKeyDown}1459 ref={autoFocus}1460 disabled={saving()}1461 placeholder="otpauth://... or base32 secret"1462 autocomplete="off"1463 />1464 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1465 </div>1466 </Show>1467 </section>1468 </Show>1469 </Show>14701471 {}1472 <Show when={entry().type !== "note" && entry().type !== "card"}>1473 <button1474 class="peach-add-field-link peach-entry-add-link"1475 onClick={() => setShowAdvanced((v) => !v)}1476 aria-expanded={showAdvanced()}1477 >1478 {showAdvanced() ? "− Advanced" : "+ Advanced"}1479 </button>1480 <Show when={showAdvanced()}>1481 <Show when={!isPasskeyOnly(entry())}>1482 <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1483 </Show>14841485 <Show when={entry().passkey}>1486 {(passkey) => {1487 const currentBrowser = detectBrowser();1488 const crossBrowser =1489 passkey().browser &&1490 currentBrowser !== "unknown" &&1491 passkey().browser !== currentBrowser;1492 const algorithmLabel =1493 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1494 const formatDate = (ts?: number) =>1495 ts ? new Date(ts).toLocaleDateString() : "Never";1496 return (1497 <section1498 class="peach-fields peach-passkey-section"1499 style="position:relative;z-index:1;margin-top:6px;"1500 >1501 <div class="peach-field-card">1502 <span class="peach-label">passkey · {passkey().rpId}</span>1503 <div class="peach-field-value-row">1504 <span class="peach-field-value">{passkey().userName}</span>1505 <span class="peach-passkey-badge">passkey</span>1506 </div>1507 </div>15081509 <div class="peach-field-card">1510 <span class="peach-label">algorithm</span>1511 <div class="peach-field-value-row">1512 <span class="peach-field-value">{algorithmLabel}</span>1513 </div>1514 </div>15151516 <div class="peach-field-card">1517 <span class="peach-label">activity</span>1518 <div class="peach-field-value-row peach-passkey-activity">1519 <span class="peach-field-value">1520 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1521 </span>1522 <span class="peach-passkey-meta">1523 created {formatDate(passkey().createdAt)}1524 </span>1525 <span class="peach-passkey-meta">1526 last used {formatDate(passkey().lastUsedAt)}1527 </span>1528 </div>1529 </div>15301531 <Show when={crossBrowser}>1532 <div class="peach-info-notice peach-info-notice--warn">1533 {`This passkey was created on ${1534 passkey().browser === "chrome" ? "Chrome" : "Firefox"1535 }. To use it on ${1536 currentBrowser === "chrome" ? "Chrome" : "Firefox"1537 }, re-register it on this device.`}1538 </div>1539 </Show>15401541 <div class="peach-info-notice">1542 {passkey().browser === "firefox"1543 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1544 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1545 </div>15461547 <button1548 class="peach-add-field-link peach-entry-add-link"1549 style="color:rgba(220,38,38,0.7);"1550 onClick={async () => {1551 const id = entry().id;1552 await sendMessage({1553 type: "UPDATE_ENTRY",1554 payload: { id, patch: { passkey: undefined } },1555 });1556 await props.onFieldSaved();1557 }}1558 >1559 − remove passkey1560 </button>1561 </section>1562 );1563 }}1564 </Show>1565 </Show>1566 </Show>15671568 {}1569 <div style="height:4px;" />1570 </>1571 )}1572 </Show>1573 </div>15741575 </>1576 );1577}1578157915801581function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1582 return (1583 <div class="peach-inline-actions">1584 <button1585 class="peach-inline-save-btn"1586 onClick={props.onSave}1587 disabled={props.saving}1588 aria-label="Save"1589 title="Save (Enter)"1590 >1591 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1592 <path d="M1 4.5L4 7.5L10 1.5" />1593 </svg>1594 </button>1595 <button1596 class="peach-inline-cancel-btn"1597 onClick={props.onCancel}1598 disabled={props.saving}1599 aria-label="Cancel"1600 title="Cancel (Esc)"1601 >1602 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1603 <path d="M1 1L8 8M8 1L1 8" />1604 </svg>1605 </button>1606 </div>1607 );1608}16091610function EmptyVaultState() {1611 return (1612 <>1613 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1614 <div class="peach-entry-name">1615 <span class="peach-entry-name-text">1616 <span class="peach-entry-name-char" style="--char-index:0">e</span>1617 <span class="peach-entry-name-char" style="--char-index:1">m</span>1618 <span class="peach-entry-name-char" style="--char-index:2">p</span>1619 <span class="peach-entry-name-char" style="--char-index:3">t</span>1620 <span class="peach-entry-name-char" style="--char-index:4">y</span>1621 </span>1622 </div>1623 </div>1624 <div class="peach-empty-copy">1625 <span class="peach-label">vault</span>1626 <p>Your vault is ready for its first entry.</p>1627 </div>1628 </>1629 );1630}16311632function CopyGlyph() {1633 return (1634 <span class="peach-copy-icon">1635 <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">1636 <rect x="4" y="4" width="8" height="9" rx="1.5" />1637 <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" />1638 </svg>1639 </span>1640 );1641}16421643function EyeClosedGlyph() {1644 return (1645 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1646 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1647 <path d="M3.5 5.5L2.5 7.5" />1648 <path d="M7 6.5V8.5" />1649 <path d="M10.5 5.5L11.5 7.5" />1650 </svg>1651 );1652}16531654function EyeOpenGlyph() {1655 return (1656 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1657 <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" />1658 <circle cx="7" cy="5" r="1.75" />1659 </svg>1660 );1661}16621663function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1664 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1665 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1666 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1667 const [testResult, setTestResult] = createSignal<string | null>(null);1668 const [testing, setTesting] = createSignal(false);16691670 1671 1672 1673 1674 1675 1676 const isProGated = () => !props.isPro && !props.entry.canary;16771678 async function save() {1679 const url = webhookUrl().trim();1680 const nextLabel = label().trim() || undefined;1681 const canary = enabled() && url1682 ? {1683 webhookUrl: url,1684 label: nextLabel,1685 lastPinged: props.entry.canary?.lastPinged,1686 pingCount: props.entry.canary?.pingCount,1687 }1688 : undefined;1689 await sendMessage({1690 type: "UPDATE_ENTRY",1691 payload: { id: props.entry.id, patch: { canary } },1692 });1693 await props.onSaved();1694 }16951696 async function testPing() {1697 const url = webhookUrl().trim();1698 if (!url) return;1699 setTesting(true);1700 setTestResult(null);1701 const res = await sendMessage({1702 type: "FIRE_CANARY_PING",1703 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1704 }) as { ok: boolean; error?: string };1705 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1706 setTesting(false);1707 }17081709 return (1710 <div class="peach-canary-section">1711 <div class="peach-field-value-row">1712 <span class="peach-field-value" style="font-size:12px;">1713 canary alert1714 <Show when={isProGated()}>1715 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1716 </Show>1717 </span>1718 <button1719 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1720 onClick={() => {1721 if (isProGated()) {1722 1723 1724 1725 props.onProGate?.("canary_entry");1726 return;1727 }1728 setEnabled(!enabled());1729 void save();1730 }}1731 aria-label="Toggle canary"1732 aria-disabled={isProGated() ? "true" : "false"}1733 >1734 <span class="peach-toggle-knob" />1735 </button>1736 </div>1737 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1738 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1739 </p>1740 <Show when={enabled()}>1741 <div class="peach-inline-field-wrap" style="margin-top:8px;">1742 <input1743 class="peach-inline-input"1744 placeholder="https://hooks.zapier.com/..."1745 value={webhookUrl()}1746 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1747 onBlur={() => void save()}1748 />1749 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1750 When triggered, the entry title is sent to this URL. Use an endpoint you control.1751 </p>1752 <input1753 class="peach-inline-input"1754 placeholder="label (optional, e.g. AWS decoy)"1755 value={label()}1756 onInput={(e) => setLabel(e.currentTarget.value)}1757 onBlur={() => void save()}1758 />1759 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1760 <button1761 class="peach-btn peach-btn--ghost peach-btn--sm"1762 onClick={() => void testPing()}1763 disabled={testing() || !webhookUrl().trim()}1764 >1765 {testing() ? "sending..." : "test ping"}1766 </button>1767 <Show when={testResult()}>1768 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1769 </Show>1770 </div>1771 <Show when={props.entry.canary?.lastPinged}>1772 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1773 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1774 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1775 </p>1776 </Show>1777 </div>1778 </Show>1779 </div>1780 );1781}