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 44 keyboardEnabled?: boolean;45 onEscape?: () => void;46 entries?: VaultEntry[];47 4849 isPro?: boolean;50 5152 onProGate?: (feature: ProFeature) => void;53}54555657function DomainFavicon(props: { domain: string; title: string }) {58 const src = props.domain ? faviconUrl(props.domain) : null;59 const [isReal, setIsReal] = createSignal(false);60 const label = () => titleInitials(props.title || props.domain);61 const bg = () => titleBackground(props.title || props.domain);62 const chipStyle = () =>63 `display:inline-flex;align-items:center;justify-content:center;` +64 `width:16px;height:16px;border-radius:3px;background:${bg()};` +65 `font-family:"IBM Plex Mono",monospace;font-size:0.4375rem;font-weight:600;` +66 `letter-spacing:0.04em;color:rgba(240,232,216,0.92);flex-shrink:0;margin-right:5px;`;67 return (68 <Show69 when={isReal()}70 fallback={71 <>72 <span style={chipStyle()} aria-hidden="true">{label()}</span>73 {src && (74 <img75 src={src}76 style="display:none"77 onError={() => {}}78 onLoad={async (e) => {79 const img = e.currentTarget as HTMLImageElement;80 if (img.naturalWidth <= 16) return;81 const sig = faviconImgSig(img);82 if (sig !== null) {83 const gSig = await globeFaviconSig();84 if (gSig !== null && sig === gSig) return;85 }86 setIsReal(true);87 }}88 />89 )}90 </>91 }92 >93 <img src={src!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />94 </Show>95 );96}9798export function EntryView(props: EntryProps) {99 const [editingField, setEditingField] = createSignal<EditableField>(null);100 const [draft, setDraft] = createSignal("");101 const [saving, setSaving] = createSignal(false);102 const [showPassword, setShowPassword] = createSignal(false);103 const [showGenerator, setShowGenerator] = createSignal(false);104 const [showHistory, setShowHistory] = createSignal(false);105 const [revealedHistory, setRevealedHistory] = createSignal<Set<number>>(new Set());106 const [copied, setCopied] = createSignal<string | null>(null);107 108 109 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);110 const [editingTags, setEditingTags] = createSignal(false);111 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);112 const [tagInput, setTagInput] = createSignal("");113 const [showCardNumber, setShowCardNumber] = createSignal(false);114 const [showCardCvc, setShowCardCvc] = createSignal(false);115 const [totpTick, setTotpTick] = createSignal(0);116 const [confirmDelete, setConfirmDelete] = createSignal(false);117 const [totpHot, setTotpHot] = createSignal(false);118 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);119 120 const [showAdvanced, setShowAdvanced] = createSignal(false);121 const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);122 let usernameInlineInput: HTMLInputElement | undefined;123 let emailInlineInput: HTMLInputElement | undefined;124 let usernameIdentityEl: HTMLDivElement | undefined;125 let emailIdentityEl: HTMLDivElement | undefined;126 let lastSavedNote: string | null = null;127 let totpClickTimer: ReturnType<typeof setTimeout> | null = null;128 onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });129130 function noteExec(cmd: string, val?: string) {131 document.execCommand(cmd, false, val);132 noteEditorEl()?.focus();133 }134135 async function saveNoteInline(html: string) {136 const entry = props.entry;137 if (!entry) return;138 const currentStored = entry.notes ?? "";139 if (html === currentStored) return;140 if (lastSavedNote !== null && html === lastSavedNote) return;141 lastSavedNote = html;142 setSaving(true);143 try {144 await sendMessage({145 type: "UPDATE_ENTRY",146 payload: { id: entry.id, patch: { notes: html } },147 });148 triggerRing();149 await props.onFieldSaved();150 } catch {151 lastSavedNote = null;152 } finally {153 setSaving(false);154 }155 }156157 const allIdentities = createMemo(() => {158 const emailMap = new Map<string, number>();159 const usernameMap = new Map<string, number>();160 for (const e of props.entries ?? []) {161 const uname = e.username?.trim() ?? "";162 const em = e.email?.trim() ?? "";163 if (uname) {164 if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);165 else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);166 }167 if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);168 }169 const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];170 const seen = new Set<string>();171 for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {172 if (seen.has(v)) continue;173 seen.add(v);174 const ec = emailMap.get(v) ?? 0;175 const uc = usernameMap.get(v) ?? 0;176 result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });177 }178 return result.sort((a, b) => b.count - a.count);179 });180181 const identitySuggestions = createMemo(() => {182 const field = editingField();183 if (field !== "username" && field !== "email") return [];184 return allIdentities()185 186 187 188 .filter(s => s.type === field)189 .slice(0, 8);190 });191192 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);193 onCleanup(() => window.clearInterval(interval));194195 const displayDomain = createMemo(() =>196 props.entry?.url ? getDisplayDomain(props.entry.url) : ""197 );198199 200 201 202 203 204 let lastEntryId: string | null = null;205 createEffect(() => {206 const id = props.entry?.id ?? null;207 if (id === lastEntryId) return;208 lastEntryId = id;209 setConfirmDelete(false);210 setEditingField(null);211 setEditingTags(false);212 setShowAdvanced(false);213 });214215 const totpState = createMemo(() => {216 void totpTick();217 if (!props.entry?.totp) return null;218 return generateTOTP(219 props.entry.totp,220 props.entry.totpAlgorithm,221 props.entry.totpDigits,222 props.entry.totpPeriod,223 );224 });225226 let prevTotpCode: string | undefined;227 createEffect(() => {228 const state = totpState();229 if (!state) { prevTotpCode = undefined; return; }230 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {231 setTotpHot(true);232 window.setTimeout(() => setTotpHot(false), 3000);233 }234 prevTotpCode = state.code;235 });236237 const totpProgress = createMemo(() => {238 const state = totpState();239 if (!state) return 0;240 return ((state.remaining - 1) / 29) * 100;241 });242243 createEffect(() => {244 const shell = document.getElementById("hd-shell");245 if (!shell) return;246247 const state = totpState();248 if (!state) {249 delete shell.dataset.totpActive;250 shell.style.removeProperty("--peach-totp-progress");251 return;252 }253254 shell.dataset.totpActive = "true";255 shell.style.setProperty(256 "--peach-totp-progress",257 `${Math.max(0, totpProgress())}%`258 );259260 onCleanup(() => {261 delete shell.dataset.totpActive;262 shell.style.removeProperty("--peach-totp-progress");263 });264 });265266 const titleSizeClass = createMemo(() => {267 const length = props.entry?.title.length ?? 0;268 if (length >= 28) return "peach-entry-name--xxl";269 if (length >= 20) return "peach-entry-name--xl";270 if (length >= 14) return "peach-entry-name--lg";271 return "";272 });273 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);274275 276277 function startEdit(field: EditableField, value: string) {278 setEditingField(field);279 setDraft(value);280 setIdentityHelperSuppressed(false);281 setShowGenerator(false);282 setShowHistory(false);283 setRevealedHistory(new Set<number>());284 setConfirmDelete(false);285 if (field === "url") {286 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);287 }288 }289290 291 292 293 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {294 const c = candidate.trim();295 if (!c) return true;296 if (primary.trim() === c) return true;297 return others.some((u) => u.trim() === c);298 }299300 function addCurrentSite(url: string) {301 if (!url) return;302 if (!draft().trim()) {303 setDraft(url);304 return;305 }306 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;307 setAdditionalUrlsDraft((arr) => [...arr, url]);308 }309310 async function saveField() {311 const field = editingField();312 const entry = props.entry;313 if (!field || !entry) return;314 315 let value = field === "password" ? draft() : draft().trim();316 if (field === "totp" && value.startsWith("otpauth://")) {317 const parsed = parseOtpauthURI(value);318 value = parsed?.secret ?? value;319 }320 if (field === "password" && entry.type !== "note" && !value) {321 return;322 }323 setSaving(true);324 try {325 const patch: Partial<VaultEntry> = { [field]: value };326 if (field === "url") {327 328 patch.additionalUrls = additionalUrlsDraft();329 }330 await sendMessage({331 type: "UPDATE_ENTRY",332 payload: { id: entry.id, patch },333 });334 triggerRing();335 setEditingField(null);336 setShowGenerator(false);337 setShowHistory(false);338 setRevealedHistory(new Set<number>());339 await props.onFieldSaved();340 } catch {341 342 } finally {343 setSaving(false);344 }345 }346347 function startEditTags() {348 setTagsDraft([...(props.entry?.tags ?? [])]);349 setTagInput("");350 setEditingTags(true);351 }352353 function addTag(value: string) {354 const n = value.toLowerCase().trim();355 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;356 setTagsDraft([...tagsDraft(), n]);357 setTagInput("");358 }359360 async function saveTags() {361 if (tagInput()) addTag(tagInput());362 const entry = props.entry;363 if (!entry) return;364 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;365 setEditingTags(false);366 setSaving(true);367 try {368 await sendMessage({369 type: "UPDATE_ENTRY",370 payload: { id: entry.id, patch: { tags: next } },371 });372 triggerRing();373 await props.onFieldSaved();374 } catch {375 376 } finally {377 setSaving(false);378 }379 }380381 function cancelEdit() {382 setEditingField(null);383 setDraft("");384 setShowGenerator(false);385 setShowHistory(false);386 setRevealedHistory(new Set<number>());387 }388389 390 391 392 393 394 395 396 397 398 async function addOtherIdentity(target: "username" | "email") {399 const entry = props.entry;400 if (!entry) return;401 const current = editingField();402 const draftValue = draft().trim();403 const savedUsername = entry.username ?? "";404 const savedEmail = entry.email ?? "";405406 if (current === "username" && target === "username") {407 408 409 if (savedUsername.includes("@") && !entry.email) {410 setSaving(true);411 try {412 await sendMessage({413 type: "UPDATE_ENTRY",414 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },415 });416 await props.onFieldSaved();417 } finally {418 setSaving(false);419 }420 }421 startEdit("username", "");422 return;423 }424425 if (current === "username" && target === "email") {426 if (draftValue && draftValue !== savedUsername) {427 setSaving(true);428 try {429 await sendMessage({430 type: "UPDATE_ENTRY",431 payload: { id: entry.id, patch: { username: draftValue } },432 });433 await props.onFieldSaved();434 } finally {435 setSaving(false);436 }437 }438 startEdit("email", "");439 return;440 }441442 if (current === "email" && target === "username") {443 if (draftValue && draftValue !== savedEmail) {444 setSaving(true);445 try {446 await sendMessage({447 type: "UPDATE_ENTRY",448 payload: { id: entry.id, patch: { email: draftValue } },449 });450 await props.onFieldSaved();451 } finally {452 setSaving(false);453 }454 }455 startEdit("username", "");456 return;457 }458 }459460 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {461 switch (field) {462 case "title":463 return entry.title;464 case "url":465 return entry.url ?? "";466 case "username":467 return entry.username ?? "";468 case "email":469 return entry.email ?? "";470 case "password":471 return entry.password ?? "";472 case "notes":473 return entry.notes ?? "";474 case "totp":475 return entry.totp ?? "";476 }477 }478479 function handleEditorFocusOut(480 field: Exclude<EditableField, null>,481 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }482 ) {483 const nextTarget = event.relatedTarget;484 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;485486 487 488 489 if (editingField() !== field) return;490491 const entry = props.entry;492 if (!entry) return;493494 const currentValue = fieldValue(entry, field);495 const nextValue = field === "password" ? draft() : draft().trim();496 if (nextValue === currentValue) {497 cancelEdit();498 }499 }500501 502503 async function copyField(text: string, label: string, button?: HTMLButtonElement) {504 const entry = props.entry;505 const ok = await popupCopy(506 text,507 label,508 entry?.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,509 );510 if (ok) {511 triggerRing();512 triggerPipPulse();513 setCopied(null);514 requestAnimationFrame(() => setCopied(label));515 if (button) {516 button.classList.remove("is-copied", "is-retreat");517 void button.offsetWidth;518 const drain = document.createElement("span");519 drain.className = "peach-copy-drain";520 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";521 drain.appendChild(bar);522 button.appendChild(drain);523 window.setTimeout(() => {524 button.classList.remove("is-copied");525 button.classList.add("is-retreat");526 drain.remove();527 window.setTimeout(() => button.classList.remove("is-retreat"), 160);528 }, 1800);529 }530 window.setTimeout(() => setCopied(null), 1800);531 }532 }533534535 536 537 createEffect(() => {538 if (!props.entry || props.keyboardEnabled === false) return;539 const handler = (e: KeyboardEvent) => {540 if (props.keyboardEnabled === false) return;541 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;542 if (inInput || e.ctrlKey || e.metaKey || e.altKey) return;543 switch (e.key) {544 case "c": e.preventDefault();545 if (props.entry?.password) void copyField(props.entry.password, "password"); break;546 case "u": e.preventDefault();547 if (props.entry?.username) void copyField(props.entry.username, "username"); break;548 case "Escape": e.preventDefault(); props.onEscape?.(); break;549 }550 };551 window.addEventListener("keydown", handler);552 onCleanup(() => window.removeEventListener("keydown", handler));553 });554555 556557 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {558 requestAnimationFrame(() => { el.focus(); el.select?.(); });559 }560561 function onEditKeyDown(e: KeyboardEvent) {562 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {563 e.preventDefault();564 void saveField();565 }566 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }567 }568569 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {570 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;571 if (e.key === "ArrowDown" && listEl) {572 e.preventDefault();573 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();574 return;575 }576 onEditKeyDown(e);577 }578579 return (580 <>581 <div582 class="peach-ghost"583 aria-hidden="true"584 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;"585 >586 {props.entry?.title || "empty"}587 </div>588589 <div class="peach-stage" style="position:relative;">590 <Show when={props.entry} fallback={<EmptyVaultState />}>591 {(entry) => (592 <>593 {}594 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>595 <div class="peach-breach-badge">596 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">597 <path d="M6 1L11 10H1L6 1z" />598 <line x1="6" y1="5" x2="6" y2="7.5" />599 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />600 </svg>601 <span style="flex:1">found in a known data breach</span>602 </div>603 </Show>604605 {}606 <div class="peach-entry-header">607 <div class="peach-entry-header-name">608 <Show609 when={editingField() === "title"}610 fallback={611 <button612 class="peach-entry-name-button"613 title="Click to edit title"614 onClick={() => startEdit("title", entry().title)}615 >616 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>617 <span class="peach-entry-name-text">618 <Show619 when={useAnimatedTitle()}620 fallback={entry().title}621 >622 <For each={entry().title.split("")}>623 {(ch, i) => (624 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>625 {ch === " " ? "\u00A0" : ch}626 </span>627 )}628 </For>629 </Show>630 </span>631 </h1>632 </button>633 }634 >635 <div636 class="peach-inline-title-wrap"637 onFocusOut={(event) => handleEditorFocusOut("title", event)}638 >639 <input640 class="peach-inline-title-input"641 value={draft()}642 onInput={(e) => setDraft(e.currentTarget.value)}643 onKeyDown={onEditKeyDown}644 ref={autoFocus}645 disabled={saving()}646 />647 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />648 </div>649 </Show>650 </div>651652 {}653 <Show when={editingField() !== "title"}>654 <Show655 when={confirmDelete()}656 fallback={657 <button658 class="peach-entry-trash-btn"659 onClick={() => setConfirmDelete(true)}660 aria-label="Move to trash"661 title="Delete entry"662 >663 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">664 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />665 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />666 </svg>667 </button>668 }669 >670 <div class="peach-entry-trash-confirm">671 <span class="peach-entry-trash-confirm-label">trash?</span>672 <button673 class="peach-btn peach-btn--sm peach-btn--danger"674 style="font-size:10px;padding:2px 8px;min-height:22px;"675 onClick={() => { setConfirmDelete(false); props.onDelete(); }}676 >677 yes678 </button>679 <button680 class="peach-btn peach-btn--ghost peach-btn--sm"681 style="font-size:10px;padding:2px 8px;min-height:22px;"682 onClick={() => setConfirmDelete(false)}683 >684 no685 </button>686 </div>687 </Show>688 </Show>689 </div>690691 {}692 <Show when={entry().type !== "note" && entry().type !== "card"}>693 <div class="peach-domain" style="position:relative;z-index:1;">694 <Show695 when={editingField() === "url"}696 fallback={697 <div698 class="peach-domain-row peach-inline-clickable"699 title="Click to edit URL"700 onClick={() => startEdit("url", entry().url ?? "")}701 >702 <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />703 <span class="peach-domain-value">704 {displayDomain() || (entry().url ? entry().url : "add a website url")}705 </span>706 <span class="peach-inline-edit-hint">edit</span>707 </div>708 }709 >710 <div711 class="peach-inline-field-wrap peach-inline-field-wrap--url"712 onFocusOut={(event) => handleEditorFocusOut("url", event)}713 >714 <div class="peach-inline-field-row">715 <input716 class="peach-inline-input"717 type="url"718 placeholder="https://"719 value={draft()}720 onInput={(e) => setDraft(e.currentTarget.value)}721 onKeyDown={onEditKeyDown}722 ref={autoFocus}723 disabled={saving()}724 />725 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />726 </div>727 <Show728 when={729 detectHomograph(draft()) ||730 additionalUrlsDraft().some((u) => detectHomograph(u))731 }732 >733 {}734 <p class="peach-homograph-warning">735 ⚠ This URL contains non-ASCII or punycode characters.736 Verify it's the site you intend.737 </p>738 </Show>739 <div class="peach-additional-urls">740 <Show when={additionalUrlsDraft().length > 0}>741 <span class="peach-label peach-additional-urls-label">also matches</span>742 </Show>743 <For each={additionalUrlsDraft()}>744 {(value, i) => (745 <div class="peach-additional-url-row">746 <input747 class="peach-inline-input"748 type="url"749 placeholder="https://"750 value={value}751 onInput={(e) => setAdditionalUrlsDraft((arr) => {752 const next = [...arr];753 next[i()] = e.currentTarget.value;754 return next;755 })}756 disabled={saving()}757 />758 <button759 class="peach-additional-url-remove"760 type="button"761 aria-label="Remove URL"762 onMouseDown={(e) => e.preventDefault()}763 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}764 disabled={saving()}765 >×</button>766 </div>767 )}768 </For>769 <button770 class="peach-add-field-link peach-additional-urls-add"771 type="button"772 onMouseDown={(e) => e.preventDefault()}773 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}774 disabled={saving()}775 >776 + add another url777 </button>778 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>779 <button780 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"781 type="button"782 title={props.currentTabUrl}783 onMouseDown={(e) => e.preventDefault()}784 onClick={() => addCurrentSite(props.currentTabUrl!)}785 disabled={saving()}786 >787 + add current site788 </button>789 </Show>790 </div>791 </div>792 </Show>793 </div>794 </Show>795796 {}797 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>798 <div799 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"800 onClick={startEditTags}801 title="Click to edit tags"802 >803 <For each={entry().tags!}>804 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}805 </For>806 </div>807 </Show>808809 {}810 <Show when={entry().type === "note"}>811 <div class="peach-entry-note-toolbar-row">812 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />813 </div>814 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">815 <NoteEditorInline816 entryKey={entry().id}817 initialContent={entry().notes ?? ""}818 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}819 onBlur={saveNoteInline}820 autoFocus={false}821 />822 </section>823 </Show>824825 {}826 <Show when={entry().type === "card"}>827 <section class="peach-fields" style="position:relative;z-index:1;">828 {}829 <Show when={entry().last4}>830 <div class="peach-field-card" style="padding-bottom:4px;">831 <span class="peach-label">832 <Show when={entry().network && entry().network !== "unknown"}>833 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>834 </Show>835 {entry().cardNickname || `•••• ${entry().last4}`}836 </span>837 </div>838 </Show>839840 {}841 <div class="peach-field-card">842 <span class="peach-label">cardholder name</span>843 <div class="peach-field-value-row">844 <span class="peach-field-value">{entry().cardholderName || "—"}</span>845 <Show when={entry().cardholderName}>846 <button847 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}848 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}849 aria-label="Copy cardholder name"850 >851 <CopyGlyph /><span class="peach-copy-sheen" />852 </button>853 </Show>854 </div>855 </div>856857 {}858 <div class="peach-field-card">859 <span class="peach-label">card number</span>860 <div class="peach-field-value-row">861 <span class={`peach-field-value peach-card-number`}>862 <Show when={showCardNumber()} fallback={863 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>864 }>865 <span>{entry().cardNumber ?? ""}</span>866 </Show>867 </span>868 <div class="peach-field-actions">869 <button870 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"871 onClick={() => setShowCardNumber(!showCardNumber())}872 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}873 >874 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>875 <EyeClosedGlyph />876 </Show>877 </button>878 <Show when={entry().cardNumber}>879 <button880 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}881 onClick={(e) => {882 const cardNum = entry().cardNumber!;883 if (entry().canary?.webhookUrl) {884 void sendMessage({885 type: "FIRE_CANARY_PING",886 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },887 });888 }889 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);890 }}891 aria-label="Copy card number"892 >893 <CopyGlyph /><span class="peach-copy-sheen" />894 </button>895 </Show>896 </div>897 </div>898 </div>899900 {}901 <Show when={entry().expMonth || entry().expYear}>902 <div class="peach-field-card">903 <span class="peach-label">expiry</span>904 <div class="peach-field-value-row">905 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>906 <button907 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}908 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}909 aria-label="Copy expiry"910 >911 <CopyGlyph /><span class="peach-copy-sheen" />912 </button>913 </div>914 </div>915 </Show>916917 {}918 <Show when={entry().cvc}>919 <div class="peach-field-card">920 <span class="peach-label">CVC</span>921 <div class="peach-field-value-row">922 <span class="peach-field-value">923 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>924 <span>{entry().cvc}</span>925 </Show>926 </span>927 <div class="peach-field-actions">928 <button929 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"930 onClick={() => setShowCardCvc(!showCardCvc())}931 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}932 >933 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>934 <EyeClosedGlyph />935 </Show>936 </button>937 <button938 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}939 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}940 aria-label="Copy CVC"941 >942 <CopyGlyph /><span class="peach-copy-sheen" />943 </button>944 </div>945 </div>946 </div>947 </Show>948 </section>949 </Show>950951 {}952 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>953 <section class="peach-fields" style="position:relative;z-index:1;">954955 {956957}958 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>959 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>960 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>961 <Show962 when={editingField() === "username"}963 fallback={964 <div class="peach-field-value-row">965 <span966 class="peach-field-value peach-inline-clickable"967 title="Click to edit"968 onClick={() => startEdit("username", entry().username ?? "")}969 >970 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}971 </span>972 <Show when={entry().username}>973 <button974 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}975 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}976 aria-label="Copy username"977 >978 <CopyGlyph /><span class="peach-copy-sheen" />979 </button>980 </Show>981 </div>982 }983 >984 <div985 class="peach-inline-field-wrap"986 onFocusOut={(event) => handleEditorFocusOut("username", event)}987 >988 <input989 class="peach-inline-input"990 value={draft()}991 onInput={(e) => setDraft(e.currentTarget.value)}992 onKeyDown={(e) => identityKeyDown(e, "username")}993 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}994 disabled={saving()}995 autocomplete="off"996 />997 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />998 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>999 <IdentitySuggestList1000 listRef={(el) => (usernameIdentityEl = el)}1001 inputRef={() => usernameInlineInput}1002 suggestions={identitySuggestions()}1003 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1004 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1005 />1006 </Show>1007 {100810091010}1011 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>1012 <button1013 class="peach-add-field-btn"1014 type="button"1015 onMouseDown={(e) => e.preventDefault()}1016 onClick={() => {1017 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1018 const hasSeparateEmail = !!entry().email;1019 if (isEmailShape && !hasSeparateEmail) {1020 void addOtherIdentity("username");1021 } else {1022 void addOtherIdentity("email");1023 }1024 }}1025 >1026 {(() => {1027 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1028 const hasSeparateEmail = !!entry().email;1029 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";1030 })()}1031 </button>1032 </Show>1033 </div>1034 </Show>1035 </div>1036 </Show>10371038 {1039}1040 <Show when={entry().email || editingField() === "email"}>1041 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1042 <span class="peach-label">email</span>1043 <Show1044 when={editingField() === "email"}1045 fallback={1046 <div class="peach-field-value-row">1047 <span1048 class="peach-field-value peach-inline-clickable"1049 title="Click to edit"1050 onClick={() => startEdit("email", entry().email ?? "")}1051 >1052 {entry().email}1053 </span>1054 <Show when={entry().email}>1055 <button1056 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1057 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1058 aria-label="Copy email"1059 >1060 <CopyGlyph /><span class="peach-copy-sheen" />1061 </button>1062 </Show>1063 </div>1064 }1065 >1066 <div1067 class="peach-inline-field-wrap"1068 onFocusOut={(event) => handleEditorFocusOut("email", event)}1069 >1070 <input1071 class="peach-inline-input"1072 value={draft()}1073 onInput={(e) => setDraft(e.currentTarget.value)}1074 onKeyDown={(e) => identityKeyDown(e, "email")}1075 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1076 disabled={saving()}1077 autocomplete="email"1078 type="email"1079 />1080 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1081 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1082 <IdentitySuggestList1083 listRef={(el) => (emailIdentityEl = el)}1084 inputRef={() => emailInlineInput}1085 suggestions={identitySuggestions()}1086 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1087 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1088 />1089 </Show>1090 <Show when={!entry().username || entry().username?.includes("@")}>1091 <button1092 class="peach-add-field-btn"1093 type="button"1094 onMouseDown={(e) => e.preventDefault()}1095 onClick={() => void addOtherIdentity("username")}1096 >1097 + add username1098 </button>1099 </Show>1100 </div>1101 </Show>1102 </div>1103 </Show>11041105 {}1106 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1107 <span class="peach-label">password</span>1108 <Show1109 when={editingField() === "password"}1110 fallback={1111 <div class="peach-field-value-row">1112 <span1113 class="peach-password-value"1114 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1115 style="cursor:pointer;"1116 title="Click to edit password"1117 onClick={() => startEdit("password", entry().password ?? "")}1118 >1119 <Show1120 when={showPassword()}1121 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1122 >1123 <span class="peach-password-text">{entry().password ?? ""}</span>1124 </Show>1125 </span>1126 <div class="peach-field-actions">1127 <button1128 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1129 onClick={() => setShowPassword(!showPassword())}1130 aria-label="Reveal password"1131 >1132 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1133 <EyeClosedGlyph />1134 </Show>1135 </button>1136 <button1137 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1138 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1139 aria-label="Copy password"1140 >1141 <CopyGlyph /><span class="peach-copy-sheen" />1142 </button>1143 </div>1144 </div>1145 }1146 >1147 {}1148 <div1149 class="peach-inline-field-wrap"1150 onFocusOut={(event) => handleEditorFocusOut("password", event)}1151 >1152 <div class="peach-password-edit-row">1153 <input1154 class="peach-inline-input"1155 type={showPassword() ? "text" : "password"}1156 placeholder="paste or type a password"1157 value={draft()}1158 onInput={(e) => setDraft(e.currentTarget.value)}1159 onKeyDown={(e) => {1160 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1161 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1162 }}1163 ref={autoFocus}1164 disabled={saving()}1165 autocomplete="new-password"1166 />1167 <button1168 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1169 type="button"1170 onClick={() => setShowPassword(!showPassword())}1171 aria-label={showPassword() ? "Hide" : "Show"}1172 >1173 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1174 <EyeClosedGlyph />1175 </Show>1176 </button>1177 </div>1178 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1179 <div class="peach-password-edit-links">1180 <button1181 class="peach-generate-toggle-link"1182 type="button"1183 onClick={() => { setShowGenerator(!showGenerator()); if (!showGenerator()) setShowHistory(false); }}1184 >1185 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1186 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1187 <path d="M9.5 2.5H11V1" />1188 </svg>1189 {showGenerator() ? "hide generator" : "generate"}1190 </button>1191 <button1192 class="peach-generate-toggle-link"1193 type="button"1194 onClick={() => { setShowHistory(!showHistory()); if (!showHistory()) setShowGenerator(false); }}1195 >1196 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1197 <path d="M6.5 1a5.5 5.5 0 1 0 5.5 5.5" />1198 <path d="M6.5 3.5V6.5L8.5 8" />1199 <path d="M1 1.5V4H3.5" />1200 </svg>1201 {showHistory()1202 ? "hide history"1203 : (entry().passwordHistory?.length ? `history (${entry().passwordHistory!.length})` : "history")}1204 </button>1205 </div>1206 <Show when={showGenerator()}>1207 <GeneratorDrawer1208 open={true}1209 compact={true}1210 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1211 />1212 </Show>1213 <Show when={showHistory()}>1214 <div class="peach-password-history">1215 <Show when={(entry().passwordHistory?.length ?? 0) === 0}>1216 <div class="peach-history-empty">1217 No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.1218 </div>1219 </Show>1220 <For each={entry().passwordHistory ?? []}>1221 {(item, i) => (1222 <div class="peach-history-row">1223 <div class="peach-history-main">1224 <span class="peach-history-pw" classList={{ "is-revealed": revealedHistory().has(i()) }}>1225 <Show1226 when={revealedHistory().has(i())}1227 fallback={<span class="peach-password-mask">••••••••••••</span>}1228 >1229 {item.password}1230 </Show>1231 </span>1232 <span class="peach-history-age">{timeSince(item.changedAt)}</span>1233 </div>1234 <div class="peach-history-actions">1235 <button1236 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1237 type="button"1238 title={revealedHistory().has(i()) ? "Hide" : "Reveal"}1239 onClick={() => setRevealedHistory((prev) => {1240 const next = new Set(prev);1241 if (next.has(i())) next.delete(i()); else next.add(i());1242 return next;1243 })}1244 >1245 <Show when={revealedHistory().has(i())} fallback={<EyeOpenGlyph />}>1246 <EyeClosedGlyph />1247 </Show>1248 </button>1249 <button1250 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === `history-${i()}` ? "is-copied" : ""}`}1251 type="button"1252 title="Copy"1253 onClick={(e) => void copyField(item.password, `history-${i()}`, e.currentTarget as HTMLButtonElement)}1254 >1255 <CopyGlyph /><span class="peach-copy-sheen" />1256 </button>1257 <button1258 class="peach-btn peach-btn--ghost peach-btn--sm peach-history-use"1259 type="button"1260 title="Use this password again"1261 onClick={() => { setDraft(item.password); setShowPassword(true); setShowHistory(false); }}1262 >1263 use1264 </button>1265 </div>1266 </div>1267 )}1268 </For>1269 </div>1270 </Show>1271 </div>1272 </Show>1273 </div>12741275 </section>1276 </Show>12771278 {}1279 <Show when={entry().type !== "note" && entry().type !== "card"}>1280 <Show1281 when={entry().notes || editingField() === "notes"}1282 fallback={1283 <button1284 class="peach-add-field-link peach-entry-add-link"1285 onClick={() => startEdit("notes", "")}1286 >1287 + notes1288 </button>1289 }1290 >1291 <div style="position:relative;z-index:1;margin-top:6px;">1292 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1293 <Show1294 when={editingField() === "notes"}1295 fallback={1296 <div1297 class="peach-note-content peach-inline-clickable"1298 style="cursor:text;white-space:pre-wrap;"1299 onClick={() => startEdit("notes", entry().notes ?? "")}1300 >1301 {entry().notes}1302 </div>1303 }1304 >1305 <div1306 class="peach-inline-field-wrap"1307 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1308 >1309 <textarea1310 class="peach-inline-textarea"1311 value={draft()}1312 onInput={(e) => setDraft(e.currentTarget.value)}1313 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1314 ref={(el) => requestAnimationFrame(() => el?.focus())}1315 rows={4}1316 disabled={saving()}1317 />1318 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1319 </div>1320 </Show>1321 </div>1322 </Show>1323 </Show>13241325 {}1326 <Show1327 when={editingTags()}1328 fallback={1329 <Show when={!entry().tags || entry().tags!.length === 0}>1330 <button1331 class="peach-add-field-link peach-entry-add-link"1332 onClick={startEditTags}1333 >1334 + tags1335 </button>1336 </Show>1337 }1338 >1339 <div1340 class="peach-inline-tag-editor"1341 onFocusOut={(e) => {1342 const next = e.relatedTarget;1343 if (next instanceof Node && e.currentTarget.contains(next)) return;1344 void saveTags();1345 }}1346 >1347 <span class="peach-label">tags</span>1348 <div class="peach-tag-input-wrap">1349 <For each={tagsDraft()}>1350 {(tag) => (1351 <span class="peach-tag-chip">1352 {tag}1353 <button1354 class="peach-tag-chip-remove"1355 onMouseDown={(e) => e.preventDefault()}1356 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1357 aria-label={`Remove ${tag}`}1358 >×</button>1359 </span>1360 )}1361 </For>1362 <input1363 class="peach-tag-input"1364 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1365 value={tagInput()}1366 onInput={(e) => setTagInput(e.currentTarget.value)}1367 onKeyDown={(e) => {1368 if (e.key === "Enter" || e.key === ",") {1369 e.preventDefault();1370 addTag(tagInput());1371 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1372 setTagsDraft(tagsDraft().slice(0, -1));1373 } else if (e.key === "Escape") {1374 e.preventDefault();1375 setEditingTags(false);1376 setTagInput("");1377 }1378 }}1379 ref={(el) => requestAnimationFrame(() => el?.focus())}1380 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1381 />1382 </div>1383 </div>1384 </Show>13851386 {}1387 <Show when={entry().type === "login"}>1388 <Show1389 when={totpState() || editingField() === "totp"}1390 fallback={1391 <button1392 class="peach-add-field-link peach-entry-add-link"1393 onClick={() => startEdit("totp", "")}1394 >1395 + 2FA1396 </button>1397 }1398 >1399 <section class="peach-totp-band">1400 <Show1401 when={editingField() === "totp"}1402 fallback={1403 <Show when={totpState()}>1404 {(state) => (1405 <>1406 <div1407 class="peach-totp-main"1408 onClick={() => {1409 1410 1411 if (totpClickTimer) return;1412 totpClickTimer = setTimeout(() => {1413 totpClickTimer = null;1414 void copyField(state().code.replace(" ", ""), "totp");1415 }, 220);1416 }}1417 onDblClick={() => {1418 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1419 startEdit("totp", entry().totp ?? "");1420 }}1421 title="Click to copy · double-click to edit"1422 >1423 <span class="peach-label">2-factor</span>1424 <div class="peach-totp-code-row">1425 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1426 <span class="peach-totp-code-text">1427 {state().code.slice(0, 3)} {state().code.slice(3)}1428 </span>1429 </p>1430 </div>1431 </div>1432 <div class="peach-totp-meta">1433 <span class="peach-label">expires</span>1434 <div class="peach-totp-countdown">{state().remaining}s</div>1435 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1436 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1437 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1438 stroke-dasharray="106.8"1439 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1440 />1441 </svg>1442 </div>1443 </>1444 )}1445 </Show>1446 }1447 >1448 <div1449 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1450 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1451 >1452 <span class="peach-label">2-factor</span>1453 <input1454 class="peach-inline-input"1455 value={draft()}1456 onInput={(e) => setDraft(e.currentTarget.value)}1457 onKeyDown={onEditKeyDown}1458 ref={autoFocus}1459 disabled={saving()}1460 placeholder="otpauth://... or base32 secret"1461 autocomplete="off"1462 />1463 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1464 </div>1465 </Show>1466 </section>1467 </Show>1468 </Show>14691470 {}1471 <Show when={entry().type !== "note" && entry().type !== "card"}>1472 <button1473 class="peach-add-field-link peach-entry-add-link"1474 onClick={() => setShowAdvanced((v) => !v)}1475 aria-expanded={showAdvanced()}1476 >1477 {showAdvanced() ? "− Advanced" : "+ Advanced"}1478 </button>1479 <Show when={showAdvanced()}>1480 <Show when={!isPasskeyOnly(entry())}>1481 <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1482 </Show>14831484 <Show when={entry().passkey}>1485 {(passkey) => {1486 const currentBrowser = detectBrowser();1487 const crossBrowser =1488 passkey().browser &&1489 currentBrowser !== "unknown" &&1490 passkey().browser !== currentBrowser;1491 const algorithmLabel =1492 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1493 const formatDate = (ts?: number) =>1494 ts ? new Date(ts).toLocaleDateString() : "Never";1495 return (1496 <section1497 class="peach-fields peach-passkey-section"1498 style="position:relative;z-index:1;margin-top:6px;"1499 >1500 <div class="peach-field-card">1501 <span class="peach-label">passkey · {passkey().rpId}</span>1502 <div class="peach-field-value-row">1503 <span class="peach-field-value">{passkey().userName}</span>1504 <span class="peach-passkey-badge">passkey</span>1505 </div>1506 </div>15071508 <div class="peach-field-card">1509 <span class="peach-label">algorithm</span>1510 <div class="peach-field-value-row">1511 <span class="peach-field-value">{algorithmLabel}</span>1512 </div>1513 </div>15141515 <div class="peach-field-card">1516 <span class="peach-label">activity</span>1517 <div class="peach-field-value-row peach-passkey-activity">1518 <span class="peach-field-value">1519 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1520 </span>1521 <span class="peach-passkey-meta">1522 created {formatDate(passkey().createdAt)}1523 </span>1524 <span class="peach-passkey-meta">1525 last used {formatDate(passkey().lastUsedAt)}1526 </span>1527 </div>1528 </div>15291530 <Show when={crossBrowser}>1531 <div class="peach-info-notice peach-info-notice--warn">1532 {`This passkey was created on ${1533 passkey().browser === "chrome" ? "Chrome" : "Firefox"1534 }. To use it on ${1535 currentBrowser === "chrome" ? "Chrome" : "Firefox"1536 }, re-register it on this device.`}1537 </div>1538 </Show>15391540 <div class="peach-info-notice">1541 {passkey().browser === "firefox"1542 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1543 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1544 </div>15451546 <button1547 class="peach-add-field-link peach-entry-add-link"1548 style="color:rgba(220,38,38,0.7);"1549 onClick={async () => {1550 const id = entry().id;1551 await sendMessage({1552 type: "UPDATE_ENTRY",1553 payload: { id, patch: { passkey: undefined } },1554 });1555 await props.onFieldSaved();1556 }}1557 >1558 − remove passkey1559 </button>1560 </section>1561 );1562 }}1563 </Show>1564 </Show>1565 </Show>15661567 {}1568 <div style="height:4px;" />1569 </>1570 )}1571 </Show>1572 </div>15731574 </>1575 );1576}1577157815791580function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1581 return (1582 <div class="peach-inline-actions">1583 <button1584 class="peach-inline-save-btn"1585 onClick={props.onSave}1586 disabled={props.saving}1587 aria-label="Save"1588 title="Save (Enter)"1589 >1590 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1591 <path d="M1 4.5L4 7.5L10 1.5" />1592 </svg>1593 </button>1594 <button1595 class="peach-inline-cancel-btn"1596 onClick={props.onCancel}1597 disabled={props.saving}1598 aria-label="Cancel"1599 title="Cancel (Esc)"1600 >1601 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1602 <path d="M1 1L8 8M8 1L1 8" />1603 </svg>1604 </button>1605 </div>1606 );1607}16081609function EmptyVaultState() {1610 return (1611 <>1612 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1613 <div class="peach-entry-name">1614 <span class="peach-entry-name-text">1615 <span class="peach-entry-name-char" style="--char-index:0">e</span>1616 <span class="peach-entry-name-char" style="--char-index:1">m</span>1617 <span class="peach-entry-name-char" style="--char-index:2">p</span>1618 <span class="peach-entry-name-char" style="--char-index:3">t</span>1619 <span class="peach-entry-name-char" style="--char-index:4">y</span>1620 </span>1621 </div>1622 </div>1623 <div class="peach-empty-copy">1624 <span class="peach-label">vault</span>1625 <p>Your vault is ready for its first entry.</p>1626 </div>1627 </>1628 );1629}16301631function CopyGlyph() {1632 return (1633 <span class="peach-copy-icon">1634 <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">1635 <rect x="4" y="4" width="8" height="9" rx="1.5" />1636 <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" />1637 </svg>1638 </span>1639 );1640}16411642function EyeClosedGlyph() {1643 return (1644 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1645 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1646 <path d="M3.5 5.5L2.5 7.5" />1647 <path d="M7 6.5V8.5" />1648 <path d="M10.5 5.5L11.5 7.5" />1649 </svg>1650 );1651}16521653function EyeOpenGlyph() {1654 return (1655 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1656 <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" />1657 <circle cx="7" cy="5" r="1.75" />1658 </svg>1659 );1660}16611662function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1663 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1664 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1665 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1666 const [testResult, setTestResult] = createSignal<string | null>(null);1667 const [testing, setTesting] = createSignal(false);16681669 1670 1671 1672 1673 1674 1675 const isProGated = () => !props.isPro && !props.entry.canary;16761677 async function save() {1678 const url = webhookUrl().trim();1679 const nextLabel = label().trim() || undefined;1680 const canary = enabled() && url1681 ? {1682 webhookUrl: url,1683 label: nextLabel,1684 lastPinged: props.entry.canary?.lastPinged,1685 pingCount: props.entry.canary?.pingCount,1686 }1687 : undefined;1688 await sendMessage({1689 type: "UPDATE_ENTRY",1690 payload: { id: props.entry.id, patch: { canary } },1691 });1692 await props.onSaved();1693 }16941695 async function testPing() {1696 const url = webhookUrl().trim();1697 if (!url) return;1698 setTesting(true);1699 setTestResult(null);1700 const res = await sendMessage({1701 type: "FIRE_CANARY_PING",1702 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1703 }) as { ok: boolean; error?: string };1704 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1705 setTesting(false);1706 }17071708 return (1709 <div class="peach-canary-section">1710 <div class="peach-field-value-row">1711 <span class="peach-field-value" style="font-size:12px;">1712 canary alert1713 <Show when={isProGated()}>1714 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1715 </Show>1716 </span>1717 <button1718 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1719 onClick={() => {1720 if (isProGated()) {1721 1722 1723 1724 props.onProGate?.("canary_entry");1725 return;1726 }1727 setEnabled(!enabled());1728 void save();1729 }}1730 aria-label="Toggle canary"1731 aria-disabled={isProGated() ? "true" : "false"}1732 >1733 <span class="peach-toggle-knob" />1734 </button>1735 </div>1736 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1737 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1738 </p>1739 <Show when={enabled()}>1740 <div class="peach-inline-field-wrap" style="margin-top:8px;">1741 <input1742 class="peach-inline-input"1743 placeholder="https://hooks.zapier.com/..."1744 value={webhookUrl()}1745 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1746 onBlur={() => void save()}1747 />1748 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1749 When triggered, the entry title is sent to this URL. Use an endpoint you control.1750 </p>1751 <input1752 class="peach-inline-input"1753 placeholder="label (optional, e.g. AWS decoy)"1754 value={label()}1755 onInput={(e) => setLabel(e.currentTarget.value)}1756 onBlur={() => void save()}1757 />1758 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1759 <button1760 class="peach-btn peach-btn--ghost peach-btn--sm"1761 onClick={() => void testPing()}1762 disabled={testing() || !webhookUrl().trim()}1763 >1764 {testing() ? "sending..." : "test ping"}1765 </button>1766 <Show when={testResult()}>1767 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1768 </Show>1769 </div>1770 <Show when={props.entry.canary?.lastPinged}>1771 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1772 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1773 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1774 </p>1775 </Show>1776 </div>1777 </Show>1778 </div>1779 );1780}