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 const input = draft().toLowerCase();183 return allIdentities()184 .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))185 .slice(0, 8);186 });187188 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);189 onCleanup(() => window.clearInterval(interval));190191 const displayDomain = createMemo(() =>192 props.entry?.url ? getDisplayDomain(props.entry.url) : ""193 );194195 196 197 198 199 200 let lastEntryId: string | null = null;201 createEffect(() => {202 const id = props.entry?.id ?? null;203 if (id === lastEntryId) return;204 lastEntryId = id;205 setConfirmDelete(false);206 setEditingField(null);207 setEditingTags(false);208 setShowAdvanced(false);209 });210211 const totpState = createMemo(() => {212 void totpTick();213 if (!props.entry?.totp) return null;214 return generateTOTP(props.entry.totp);215 });216217 let prevTotpCode: string | undefined;218 createEffect(() => {219 const state = totpState();220 if (!state) { prevTotpCode = undefined; return; }221 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {222 setTotpHot(true);223 window.setTimeout(() => setTotpHot(false), 3000);224 }225 prevTotpCode = state.code;226 });227228 const totpProgress = createMemo(() => {229 const state = totpState();230 if (!state) return 0;231 return ((state.remaining - 1) / 29) * 100;232 });233234 createEffect(() => {235 const shell = document.getElementById("hd-shell");236 if (!shell) return;237238 const state = totpState();239 if (!state) {240 delete shell.dataset.totpActive;241 shell.style.removeProperty("--peach-totp-progress");242 return;243 }244245 shell.dataset.totpActive = "true";246 shell.style.setProperty(247 "--peach-totp-progress",248 `${Math.max(0, totpProgress())}%`249 );250251 onCleanup(() => {252 delete shell.dataset.totpActive;253 shell.style.removeProperty("--peach-totp-progress");254 });255 });256257 const titleSizeClass = createMemo(() => {258 const length = props.entry?.title.length ?? 0;259 if (length >= 28) return "peach-entry-name--xxl";260 if (length >= 20) return "peach-entry-name--xl";261 if (length >= 14) return "peach-entry-name--lg";262 return "";263 });264 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);265266 267268 function startEdit(field: EditableField, value: string) {269 setEditingField(field);270 setDraft(value);271 setIdentityHelperSuppressed(false);272 setShowGenerator(false);273 setShowHistory(false);274 setRevealedHistory(new Set<number>());275 setConfirmDelete(false);276 if (field === "url") {277 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);278 }279 }280281 282 283 284 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {285 const c = candidate.trim();286 if (!c) return true;287 if (primary.trim() === c) return true;288 return others.some((u) => u.trim() === c);289 }290291 function addCurrentSite(url: string) {292 if (!url) return;293 if (!draft().trim()) {294 setDraft(url);295 return;296 }297 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;298 setAdditionalUrlsDraft((arr) => [...arr, url]);299 }300301 async function saveField() {302 const field = editingField();303 const entry = props.entry;304 if (!field || !entry) return;305 306 let value = field === "password" ? draft() : draft().trim();307 if (field === "totp" && value.startsWith("otpauth://")) {308 const parsed = parseOtpauthURI(value);309 value = parsed?.secret ?? value;310 }311 if (field === "password" && entry.type !== "note" && !value) {312 return;313 }314 setSaving(true);315 try {316 const patch: Partial<VaultEntry> = { [field]: value };317 if (field === "url") {318 319 patch.additionalUrls = additionalUrlsDraft();320 }321 await sendMessage({322 type: "UPDATE_ENTRY",323 payload: { id: entry.id, patch },324 });325 triggerRing();326 setEditingField(null);327 setShowGenerator(false);328 setShowHistory(false);329 setRevealedHistory(new Set<number>());330 await props.onFieldSaved();331 } catch {332 333 } finally {334 setSaving(false);335 }336 }337338 function startEditTags() {339 setTagsDraft([...(props.entry?.tags ?? [])]);340 setTagInput("");341 setEditingTags(true);342 }343344 function addTag(value: string) {345 const n = value.toLowerCase().trim();346 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;347 setTagsDraft([...tagsDraft(), n]);348 setTagInput("");349 }350351 async function saveTags() {352 if (tagInput()) addTag(tagInput());353 const entry = props.entry;354 if (!entry) return;355 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;356 setEditingTags(false);357 setSaving(true);358 try {359 await sendMessage({360 type: "UPDATE_ENTRY",361 payload: { id: entry.id, patch: { tags: next } },362 });363 triggerRing();364 await props.onFieldSaved();365 } catch {366 367 } finally {368 setSaving(false);369 }370 }371372 function cancelEdit() {373 setEditingField(null);374 setDraft("");375 setShowGenerator(false);376 setShowHistory(false);377 setRevealedHistory(new Set<number>());378 }379380 381 382 383 384 385 386 387 388 389 async function addOtherIdentity(target: "username" | "email") {390 const entry = props.entry;391 if (!entry) return;392 const current = editingField();393 const draftValue = draft().trim();394 const savedUsername = entry.username ?? "";395 const savedEmail = entry.email ?? "";396397 if (current === "username" && target === "username") {398 399 400 if (savedUsername.includes("@") && !entry.email) {401 setSaving(true);402 try {403 await sendMessage({404 type: "UPDATE_ENTRY",405 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },406 });407 await props.onFieldSaved();408 } finally {409 setSaving(false);410 }411 }412 startEdit("username", "");413 return;414 }415416 if (current === "username" && target === "email") {417 if (draftValue && draftValue !== savedUsername) {418 setSaving(true);419 try {420 await sendMessage({421 type: "UPDATE_ENTRY",422 payload: { id: entry.id, patch: { username: draftValue } },423 });424 await props.onFieldSaved();425 } finally {426 setSaving(false);427 }428 }429 startEdit("email", "");430 return;431 }432433 if (current === "email" && target === "username") {434 if (draftValue && draftValue !== savedEmail) {435 setSaving(true);436 try {437 await sendMessage({438 type: "UPDATE_ENTRY",439 payload: { id: entry.id, patch: { email: draftValue } },440 });441 await props.onFieldSaved();442 } finally {443 setSaving(false);444 }445 }446 startEdit("username", "");447 return;448 }449 }450451 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {452 switch (field) {453 case "title":454 return entry.title;455 case "url":456 return entry.url ?? "";457 case "username":458 return entry.username ?? "";459 case "email":460 return entry.email ?? "";461 case "password":462 return entry.password ?? "";463 case "notes":464 return entry.notes ?? "";465 case "totp":466 return entry.totp ?? "";467 }468 }469470 function handleEditorFocusOut(471 field: Exclude<EditableField, null>,472 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }473 ) {474 const nextTarget = event.relatedTarget;475 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;476477 478 479 480 if (editingField() !== field) return;481482 const entry = props.entry;483 if (!entry) return;484485 const currentValue = fieldValue(entry, field);486 const nextValue = field === "password" ? draft() : draft().trim();487 if (nextValue === currentValue) {488 cancelEdit();489 }490 }491492 493494 async function copyField(text: string, label: string, button?: HTMLButtonElement) {495 const ok = await popupCopy(text, label);496 if (ok) {497 const entry = props.entry;498 if (entry?.canary?.webhookUrl) {499 const trigger =500 label === "password"501 ? "password_copied"502 : label === "username"503 ? "username_copied"504 : undefined;505 if (trigger) {506 void sendMessage({507 type: "FIRE_CANARY_PING",508 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },509 });510 }511 }512 triggerRing();513 triggerPipPulse();514 setCopied(null);515 requestAnimationFrame(() => setCopied(label));516 if (button) {517 button.classList.remove("is-copied", "is-retreat");518 void button.offsetWidth;519 const drain = document.createElement("span");520 drain.className = "peach-copy-drain";521 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";522 drain.appendChild(bar);523 button.appendChild(drain);524 window.setTimeout(() => {525 button.classList.remove("is-copied");526 button.classList.add("is-retreat");527 drain.remove();528 window.setTimeout(() => button.classList.remove("is-retreat"), 160);529 }, 1800);530 }531 window.setTimeout(() => setCopied(null), 1800);532 }533 }534535 536537 createEffect(() => {538 if (!props.entry) return;539 const handler = (e: KeyboardEvent) => {540 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;541 if (inInput) return;542 switch (e.key) {543 case "c": e.preventDefault();544 if (props.entry?.password) void copyField(props.entry.password, "password"); break;545 case "u": e.preventDefault();546 if (props.entry?.username) void copyField(props.entry.username, "username"); break;547 case "Escape": e.preventDefault(); props.onEscape?.(); break;548 }549 };550 window.addEventListener("keydown", handler);551 onCleanup(() => window.removeEventListener("keydown", handler));552 });553554 555556 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {557 requestAnimationFrame(() => { el.focus(); el.select?.(); });558 }559560 function onEditKeyDown(e: KeyboardEvent) {561 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {562 e.preventDefault();563 void saveField();564 }565 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }566 }567568 function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {569 const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;570 if (e.key === "ArrowDown" && listEl) {571 e.preventDefault();572 listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();573 return;574 }575 onEditKeyDown(e);576 }577578 return (579 <>580 <div581 class="peach-ghost"582 aria-hidden="true"583 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;"584 >585 {props.entry?.title || "empty"}586 </div>587588 <div class="peach-stage" style="position:relative;">589 <Show when={props.entry} fallback={<EmptyVaultState />}>590 {(entry) => (591 <>592 {}593 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>594 <div class="peach-breach-badge">595 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">596 <path d="M6 1L11 10H1L6 1z" />597 <line x1="6" y1="5" x2="6" y2="7.5" />598 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />599 </svg>600 <span style="flex:1">found in a known data breach</span>601 </div>602 </Show>603604 {}605 <div class="peach-entry-header">606 <div class="peach-entry-header-name">607 <Show608 when={editingField() === "title"}609 fallback={610 <button611 class="peach-entry-name-button"612 title="Click to edit title"613 onClick={() => startEdit("title", entry().title)}614 >615 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>616 <span class="peach-entry-name-text">617 <Show618 when={useAnimatedTitle()}619 fallback={entry().title}620 >621 <For each={entry().title.split("")}>622 {(ch, i) => (623 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>624 {ch === " " ? "\u00A0" : ch}625 </span>626 )}627 </For>628 </Show>629 </span>630 </h1>631 </button>632 }633 >634 <div635 class="peach-inline-title-wrap"636 onFocusOut={(event) => handleEditorFocusOut("title", event)}637 >638 <input639 class="peach-inline-title-input"640 value={draft()}641 onInput={(e) => setDraft(e.currentTarget.value)}642 onKeyDown={onEditKeyDown}643 ref={autoFocus}644 disabled={saving()}645 />646 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />647 </div>648 </Show>649 </div>650651 {}652 <Show when={editingField() !== "title"}>653 <Show654 when={confirmDelete()}655 fallback={656 <button657 class="peach-entry-trash-btn"658 onClick={() => setConfirmDelete(true)}659 aria-label="Move to trash"660 title="Delete entry"661 >662 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">663 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />664 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />665 </svg>666 </button>667 }668 >669 <div class="peach-entry-trash-confirm">670 <span class="peach-entry-trash-confirm-label">trash?</span>671 <button672 class="peach-btn peach-btn--sm peach-btn--danger"673 style="font-size:10px;padding:2px 8px;min-height:22px;"674 onClick={() => { setConfirmDelete(false); props.onDelete(); }}675 >676 yes677 </button>678 <button679 class="peach-btn peach-btn--ghost peach-btn--sm"680 style="font-size:10px;padding:2px 8px;min-height:22px;"681 onClick={() => setConfirmDelete(false)}682 >683 no684 </button>685 </div>686 </Show>687 </Show>688 </div>689690 {}691 <Show when={entry().type !== "note" && entry().type !== "card"}>692 <div class="peach-domain" style="position:relative;z-index:1;">693 <Show694 when={editingField() === "url"}695 fallback={696 <div697 class="peach-domain-row peach-inline-clickable"698 title="Click to edit URL"699 onClick={() => startEdit("url", entry().url ?? "")}700 >701 <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />702 <span class="peach-domain-value">703 {displayDomain() || (entry().url ? entry().url : "add a website url")}704 </span>705 <span class="peach-inline-edit-hint">edit</span>706 </div>707 }708 >709 <div710 class="peach-inline-field-wrap peach-inline-field-wrap--url"711 onFocusOut={(event) => handleEditorFocusOut("url", event)}712 >713 <div class="peach-inline-field-row">714 <input715 class="peach-inline-input"716 type="url"717 placeholder="https://"718 value={draft()}719 onInput={(e) => setDraft(e.currentTarget.value)}720 onKeyDown={onEditKeyDown}721 ref={autoFocus}722 disabled={saving()}723 />724 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />725 </div>726 <Show727 when={728 detectHomograph(draft()) ||729 additionalUrlsDraft().some((u) => detectHomograph(u))730 }731 >732 {}733 <p class="peach-homograph-warning">734 ⚠ This URL contains non-ASCII or punycode characters.735 Verify it's the site you intend.736 </p>737 </Show>738 <div class="peach-additional-urls">739 <Show when={additionalUrlsDraft().length > 0}>740 <span class="peach-label peach-additional-urls-label">also matches</span>741 </Show>742 <For each={additionalUrlsDraft()}>743 {(value, i) => (744 <div class="peach-additional-url-row">745 <input746 class="peach-inline-input"747 type="url"748 placeholder="https://"749 value={value}750 onInput={(e) => setAdditionalUrlsDraft((arr) => {751 const next = [...arr];752 next[i()] = e.currentTarget.value;753 return next;754 })}755 disabled={saving()}756 />757 <button758 class="peach-additional-url-remove"759 type="button"760 aria-label="Remove URL"761 onMouseDown={(e) => e.preventDefault()}762 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}763 disabled={saving()}764 >×</button>765 </div>766 )}767 </For>768 <button769 class="peach-add-field-link peach-additional-urls-add"770 type="button"771 onMouseDown={(e) => e.preventDefault()}772 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}773 disabled={saving()}774 >775 + add another url776 </button>777 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>778 <button779 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"780 type="button"781 title={props.currentTabUrl}782 onMouseDown={(e) => e.preventDefault()}783 onClick={() => addCurrentSite(props.currentTabUrl!)}784 disabled={saving()}785 >786 + add current site787 </button>788 </Show>789 </div>790 </div>791 </Show>792 </div>793 </Show>794795 {}796 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>797 <div798 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"799 onClick={startEditTags}800 title="Click to edit tags"801 >802 <For each={entry().tags!}>803 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}804 </For>805 </div>806 </Show>807808 {}809 <Show when={entry().type === "note"}>810 <div class="peach-entry-note-toolbar-row">811 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />812 </div>813 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">814 <NoteEditorInline815 entryKey={entry().id}816 initialContent={entry().notes ?? ""}817 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}818 onBlur={saveNoteInline}819 autoFocus={false}820 />821 </section>822 </Show>823824 {}825 <Show when={entry().type === "card"}>826 <section class="peach-fields" style="position:relative;z-index:1;">827 {}828 <Show when={entry().last4}>829 <div class="peach-field-card" style="padding-bottom:4px;">830 <span class="peach-label">831 <Show when={entry().network && entry().network !== "unknown"}>832 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>833 </Show>834 {entry().cardNickname || `•••• ${entry().last4}`}835 </span>836 </div>837 </Show>838839 {}840 <div class="peach-field-card">841 <span class="peach-label">cardholder name</span>842 <div class="peach-field-value-row">843 <span class="peach-field-value">{entry().cardholderName || "—"}</span>844 <Show when={entry().cardholderName}>845 <button846 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}847 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}848 aria-label="Copy cardholder name"849 >850 <CopyGlyph /><span class="peach-copy-sheen" />851 </button>852 </Show>853 </div>854 </div>855856 {}857 <div class="peach-field-card">858 <span class="peach-label">card number</span>859 <div class="peach-field-value-row">860 <span class={`peach-field-value peach-card-number`}>861 <Show when={showCardNumber()} fallback={862 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>863 }>864 <span>{entry().cardNumber ?? ""}</span>865 </Show>866 </span>867 <div class="peach-field-actions">868 <button869 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"870 onClick={() => setShowCardNumber(!showCardNumber())}871 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}872 >873 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>874 <EyeClosedGlyph />875 </Show>876 </button>877 <Show when={entry().cardNumber}>878 <button879 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}880 onClick={(e) => {881 const cardNum = entry().cardNumber!;882 if (entry().canary?.webhookUrl) {883 void sendMessage({884 type: "FIRE_CANARY_PING",885 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },886 });887 }888 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);889 }}890 aria-label="Copy card number"891 >892 <CopyGlyph /><span class="peach-copy-sheen" />893 </button>894 </Show>895 </div>896 </div>897 </div>898899 {}900 <Show when={entry().expMonth || entry().expYear}>901 <div class="peach-field-card">902 <span class="peach-label">expiry</span>903 <div class="peach-field-value-row">904 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>905 <button906 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}907 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}908 aria-label="Copy expiry"909 >910 <CopyGlyph /><span class="peach-copy-sheen" />911 </button>912 </div>913 </div>914 </Show>915916 {}917 <Show when={entry().cvc}>918 <div class="peach-field-card">919 <span class="peach-label">CVC</span>920 <div class="peach-field-value-row">921 <span class="peach-field-value">922 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>923 <span>{entry().cvc}</span>924 </Show>925 </span>926 <div class="peach-field-actions">927 <button928 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"929 onClick={() => setShowCardCvc(!showCardCvc())}930 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}931 >932 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>933 <EyeClosedGlyph />934 </Show>935 </button>936 <button937 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}938 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}939 aria-label="Copy CVC"940 >941 <CopyGlyph /><span class="peach-copy-sheen" />942 </button>943 </div>944 </div>945 </div>946 </Show>947 </section>948 </Show>949950 {}951 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>952 <section class="peach-fields" style="position:relative;z-index:1;">953954 {955956}957 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>958 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>959 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>960 <Show961 when={editingField() === "username"}962 fallback={963 <div class="peach-field-value-row">964 <span965 class="peach-field-value peach-inline-clickable"966 title="Click to edit"967 onClick={() => startEdit("username", entry().username ?? "")}968 >969 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}970 </span>971 <Show when={entry().username}>972 <button973 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}974 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}975 aria-label="Copy username"976 >977 <CopyGlyph /><span class="peach-copy-sheen" />978 </button>979 </Show>980 </div>981 }982 >983 <div984 class="peach-inline-field-wrap"985 onFocusOut={(event) => handleEditorFocusOut("username", event)}986 >987 <input988 class="peach-inline-input"989 value={draft()}990 onInput={(e) => setDraft(e.currentTarget.value)}991 onKeyDown={(e) => identityKeyDown(e, "username")}992 ref={(el) => { usernameInlineInput = el; autoFocus(el); }}993 disabled={saving()}994 autocomplete="off"995 />996 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />997 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>998 <IdentitySuggestList999 listRef={(el) => (usernameIdentityEl = el)}1000 inputRef={() => usernameInlineInput}1001 suggestions={identitySuggestions()}1002 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1003 onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1004 />1005 </Show>1006 {100710081009}1010 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>1011 <button1012 class="peach-add-field-btn"1013 type="button"1014 onMouseDown={(e) => e.preventDefault()}1015 onClick={() => {1016 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1017 const hasSeparateEmail = !!entry().email;1018 if (isEmailShape && !hasSeparateEmail) {1019 void addOtherIdentity("username");1020 } else {1021 void addOtherIdentity("email");1022 }1023 }}1024 >1025 {(() => {1026 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1027 const hasSeparateEmail = !!entry().email;1028 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";1029 })()}1030 </button>1031 </Show>1032 </div>1033 </Show>1034 </div>1035 </Show>10361037 {1038}1039 <Show when={entry().email || editingField() === "email"}>1040 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1041 <span class="peach-label">email</span>1042 <Show1043 when={editingField() === "email"}1044 fallback={1045 <div class="peach-field-value-row">1046 <span1047 class="peach-field-value peach-inline-clickable"1048 title="Click to edit"1049 onClick={() => startEdit("email", entry().email ?? "")}1050 >1051 {entry().email}1052 </span>1053 <Show when={entry().email}>1054 <button1055 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1056 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1057 aria-label="Copy email"1058 >1059 <CopyGlyph /><span class="peach-copy-sheen" />1060 </button>1061 </Show>1062 </div>1063 }1064 >1065 <div1066 class="peach-inline-field-wrap"1067 onFocusOut={(event) => handleEditorFocusOut("email", event)}1068 >1069 <input1070 class="peach-inline-input"1071 value={draft()}1072 onInput={(e) => setDraft(e.currentTarget.value)}1073 onKeyDown={(e) => identityKeyDown(e, "email")}1074 ref={(el) => { emailInlineInput = el; autoFocus(el); }}1075 disabled={saving()}1076 autocomplete="email"1077 type="email"1078 />1079 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1080 <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1081 <IdentitySuggestList1082 listRef={(el) => (emailIdentityEl = el)}1083 inputRef={() => emailInlineInput}1084 suggestions={identitySuggestions()}1085 onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1086 onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1087 />1088 </Show>1089 <Show when={!entry().username || entry().username?.includes("@")}>1090 <button1091 class="peach-add-field-btn"1092 type="button"1093 onMouseDown={(e) => e.preventDefault()}1094 onClick={() => void addOtherIdentity("username")}1095 >1096 + add username1097 </button>1098 </Show>1099 </div>1100 </Show>1101 </div>1102 </Show>11031104 {}1105 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1106 <span class="peach-label">password</span>1107 <Show1108 when={editingField() === "password"}1109 fallback={1110 <div class="peach-field-value-row">1111 <span1112 class="peach-password-value"1113 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1114 style="cursor:pointer;"1115 title="Click to edit password"1116 onClick={() => startEdit("password", entry().password ?? "")}1117 >1118 <Show1119 when={showPassword()}1120 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1121 >1122 <span class="peach-password-text">{entry().password ?? ""}</span>1123 </Show>1124 </span>1125 <div class="peach-field-actions">1126 <button1127 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1128 onClick={() => setShowPassword(!showPassword())}1129 aria-label="Reveal password"1130 >1131 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1132 <EyeClosedGlyph />1133 </Show>1134 </button>1135 <button1136 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1137 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1138 aria-label="Copy password"1139 >1140 <CopyGlyph /><span class="peach-copy-sheen" />1141 </button>1142 </div>1143 </div>1144 }1145 >1146 {}1147 <div1148 class="peach-inline-field-wrap"1149 onFocusOut={(event) => handleEditorFocusOut("password", event)}1150 >1151 <div class="peach-password-edit-row">1152 <input1153 class="peach-inline-input"1154 type={showPassword() ? "text" : "password"}1155 placeholder="paste or type a password"1156 value={draft()}1157 onInput={(e) => setDraft(e.currentTarget.value)}1158 onKeyDown={(e) => {1159 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1160 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1161 }}1162 ref={autoFocus}1163 disabled={saving()}1164 autocomplete="new-password"1165 />1166 <button1167 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1168 type="button"1169 onClick={() => setShowPassword(!showPassword())}1170 aria-label={showPassword() ? "Hide" : "Show"}1171 >1172 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1173 <EyeClosedGlyph />1174 </Show>1175 </button>1176 </div>1177 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1178 <div class="peach-password-edit-links">1179 <button1180 class="peach-generate-toggle-link"1181 type="button"1182 onClick={() => { setShowGenerator(!showGenerator()); if (!showGenerator()) setShowHistory(false); }}1183 >1184 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1185 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1186 <path d="M9.5 2.5H11V1" />1187 </svg>1188 {showGenerator() ? "hide generator" : "generate"}1189 </button>1190 <button1191 class="peach-generate-toggle-link"1192 type="button"1193 onClick={() => { setShowHistory(!showHistory()); if (!showHistory()) setShowGenerator(false); }}1194 >1195 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1196 <path d="M6.5 1a5.5 5.5 0 1 0 5.5 5.5" />1197 <path d="M6.5 3.5V6.5L8.5 8" />1198 <path d="M1 1.5V4H3.5" />1199 </svg>1200 {showHistory()1201 ? "hide history"1202 : (entry().passwordHistory?.length ? `history (${entry().passwordHistory!.length})` : "history")}1203 </button>1204 </div>1205 <Show when={showGenerator()}>1206 <GeneratorDrawer1207 open={true}1208 compact={true}1209 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1210 />1211 </Show>1212 <Show when={showHistory()}>1213 <div class="peach-password-history">1214 <Show when={(entry().passwordHistory?.length ?? 0) === 0}>1215 <div class="peach-history-empty">1216 No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.1217 </div>1218 </Show>1219 <For each={entry().passwordHistory ?? []}>1220 {(item, i) => (1221 <div class="peach-history-row">1222 <div class="peach-history-main">1223 <span class="peach-history-pw" classList={{ "is-revealed": revealedHistory().has(i()) }}>1224 <Show1225 when={revealedHistory().has(i())}1226 fallback={<span class="peach-password-mask">••••••••••••</span>}1227 >1228 {item.password}1229 </Show>1230 </span>1231 <span class="peach-history-age">{timeSince(item.changedAt)}</span>1232 </div>1233 <div class="peach-history-actions">1234 <button1235 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1236 type="button"1237 title={revealedHistory().has(i()) ? "Hide" : "Reveal"}1238 onClick={() => setRevealedHistory((prev) => {1239 const next = new Set(prev);1240 if (next.has(i())) next.delete(i()); else next.add(i());1241 return next;1242 })}1243 >1244 <Show when={revealedHistory().has(i())} fallback={<EyeOpenGlyph />}>1245 <EyeClosedGlyph />1246 </Show>1247 </button>1248 <button1249 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === `history-${i()}` ? "is-copied" : ""}`}1250 type="button"1251 title="Copy"1252 onClick={(e) => void copyField(item.password, `history-${i()}`, e.currentTarget as HTMLButtonElement)}1253 >1254 <CopyGlyph /><span class="peach-copy-sheen" />1255 </button>1256 <button1257 class="peach-btn peach-btn--ghost peach-btn--sm peach-history-use"1258 type="button"1259 title="Use this password again"1260 onClick={() => { setDraft(item.password); setShowPassword(true); setShowHistory(false); }}1261 >1262 use1263 </button>1264 </div>1265 </div>1266 )}1267 </For>1268 </div>1269 </Show>1270 </div>1271 </Show>1272 </div>12731274 </section>1275 </Show>12761277 {}1278 <Show when={entry().type !== "note" && entry().type !== "card"}>1279 <Show1280 when={entry().notes || editingField() === "notes"}1281 fallback={1282 <button1283 class="peach-add-field-link peach-entry-add-link"1284 onClick={() => startEdit("notes", "")}1285 >1286 + notes1287 </button>1288 }1289 >1290 <div style="position:relative;z-index:1;margin-top:6px;">1291 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1292 <Show1293 when={editingField() === "notes"}1294 fallback={1295 <div1296 class="peach-note-content peach-inline-clickable"1297 style="cursor:text;white-space:pre-wrap;"1298 onClick={() => startEdit("notes", entry().notes ?? "")}1299 >1300 {entry().notes}1301 </div>1302 }1303 >1304 <div1305 class="peach-inline-field-wrap"1306 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1307 >1308 <textarea1309 class="peach-inline-textarea"1310 value={draft()}1311 onInput={(e) => setDraft(e.currentTarget.value)}1312 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1313 ref={(el) => requestAnimationFrame(() => el?.focus())}1314 rows={4}1315 disabled={saving()}1316 />1317 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1318 </div>1319 </Show>1320 </div>1321 </Show>1322 </Show>13231324 {}1325 <Show1326 when={editingTags()}1327 fallback={1328 <Show when={!entry().tags || entry().tags!.length === 0}>1329 <button1330 class="peach-add-field-link peach-entry-add-link"1331 onClick={startEditTags}1332 >1333 + tags1334 </button>1335 </Show>1336 }1337 >1338 <div1339 class="peach-inline-tag-editor"1340 onFocusOut={(e) => {1341 const next = e.relatedTarget;1342 if (next instanceof Node && e.currentTarget.contains(next)) return;1343 void saveTags();1344 }}1345 >1346 <span class="peach-label">tags</span>1347 <div class="peach-tag-input-wrap">1348 <For each={tagsDraft()}>1349 {(tag) => (1350 <span class="peach-tag-chip">1351 {tag}1352 <button1353 class="peach-tag-chip-remove"1354 onMouseDown={(e) => e.preventDefault()}1355 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1356 aria-label={`Remove ${tag}`}1357 >×</button>1358 </span>1359 )}1360 </For>1361 <input1362 class="peach-tag-input"1363 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1364 value={tagInput()}1365 onInput={(e) => setTagInput(e.currentTarget.value)}1366 onKeyDown={(e) => {1367 if (e.key === "Enter" || e.key === ",") {1368 e.preventDefault();1369 addTag(tagInput());1370 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1371 setTagsDraft(tagsDraft().slice(0, -1));1372 } else if (e.key === "Escape") {1373 e.preventDefault();1374 setEditingTags(false);1375 setTagInput("");1376 }1377 }}1378 ref={(el) => requestAnimationFrame(() => el?.focus())}1379 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1380 />1381 </div>1382 </div>1383 </Show>13841385 {}1386 <Show when={entry().type === "login"}>1387 <Show1388 when={totpState() || editingField() === "totp"}1389 fallback={1390 <button1391 class="peach-add-field-link peach-entry-add-link"1392 onClick={() => startEdit("totp", "")}1393 >1394 + 2FA1395 </button>1396 }1397 >1398 <section class="peach-totp-band">1399 <Show1400 when={editingField() === "totp"}1401 fallback={1402 <Show when={totpState()}>1403 {(state) => (1404 <>1405 <div1406 class="peach-totp-main"1407 onClick={() => {1408 1409 1410 if (totpClickTimer) return;1411 totpClickTimer = setTimeout(() => {1412 totpClickTimer = null;1413 void copyField(state().code.replace(" ", ""), "totp");1414 }, 220);1415 }}1416 onDblClick={() => {1417 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1418 startEdit("totp", entry().totp ?? "");1419 }}1420 title="Click to copy · double-click to edit"1421 >1422 <span class="peach-label">2-factor</span>1423 <div class="peach-totp-code-row">1424 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1425 <span class="peach-totp-code-text">1426 {state().code.slice(0, 3)} {state().code.slice(3)}1427 </span>1428 </p>1429 </div>1430 </div>1431 <div class="peach-totp-meta">1432 <span class="peach-label">expires</span>1433 <div class="peach-totp-countdown">{state().remaining}s</div>1434 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1435 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1436 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1437 stroke-dasharray="106.8"1438 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1439 />1440 </svg>1441 </div>1442 </>1443 )}1444 </Show>1445 }1446 >1447 <div1448 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1449 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1450 >1451 <span class="peach-label">2-factor</span>1452 <input1453 class="peach-inline-input"1454 value={draft()}1455 onInput={(e) => setDraft(e.currentTarget.value)}1456 onKeyDown={onEditKeyDown}1457 ref={autoFocus}1458 disabled={saving()}1459 placeholder="otpauth://... or base32 secret"1460 autocomplete="off"1461 />1462 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1463 </div>1464 </Show>1465 </section>1466 </Show>1467 </Show>14681469 {}1470 <Show when={entry().type === "login"}>1471 <button1472 class="peach-add-field-link peach-entry-add-link"1473 onClick={() => setShowAdvanced((v) => !v)}1474 aria-expanded={showAdvanced()}1475 >1476 {showAdvanced() ? "− Advanced" : "+ Advanced"}1477 </button>1478 <Show when={showAdvanced()}>1479 <Show when={!isPasskeyOnly(entry())}>1480 <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1481 </Show>14821483 <Show when={entry().passkey}>1484 {(passkey) => {1485 const currentBrowser = detectBrowser();1486 const crossBrowser =1487 passkey().browser &&1488 currentBrowser !== "unknown" &&1489 passkey().browser !== currentBrowser;1490 const algorithmLabel =1491 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1492 const formatDate = (ts?: number) =>1493 ts ? new Date(ts).toLocaleDateString() : "Never";1494 return (1495 <section1496 class="peach-fields peach-passkey-section"1497 style="position:relative;z-index:1;margin-top:6px;"1498 >1499 <div class="peach-field-card">1500 <span class="peach-label">passkey · {passkey().rpId}</span>1501 <div class="peach-field-value-row">1502 <span class="peach-field-value">{passkey().userName}</span>1503 <span class="peach-passkey-badge">passkey</span>1504 </div>1505 </div>15061507 <div class="peach-field-card">1508 <span class="peach-label">algorithm</span>1509 <div class="peach-field-value-row">1510 <span class="peach-field-value">{algorithmLabel}</span>1511 </div>1512 </div>15131514 <div class="peach-field-card">1515 <span class="peach-label">activity</span>1516 <div class="peach-field-value-row peach-passkey-activity">1517 <span class="peach-field-value">1518 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1519 </span>1520 <span class="peach-passkey-meta">1521 created {formatDate(passkey().createdAt)}1522 </span>1523 <span class="peach-passkey-meta">1524 last used {formatDate(passkey().lastUsedAt)}1525 </span>1526 </div>1527 </div>15281529 <Show when={crossBrowser}>1530 <div class="peach-info-notice peach-info-notice--warn">1531 {`This passkey was created on ${1532 passkey().browser === "chrome" ? "Chrome" : "Firefox"1533 }. To use it on ${1534 currentBrowser === "chrome" ? "Chrome" : "Firefox"1535 }, re-register it on this device.`}1536 </div>1537 </Show>15381539 <div class="peach-info-notice">1540 {passkey().browser === "firefox"1541 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1542 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1543 </div>15441545 <button1546 class="peach-add-field-link peach-entry-add-link"1547 style="color:rgba(220,38,38,0.7);"1548 onClick={async () => {1549 const id = entry().id;1550 await sendMessage({1551 type: "UPDATE_ENTRY",1552 payload: { id, patch: { passkey: undefined } },1553 });1554 await props.onFieldSaved();1555 }}1556 >1557 − remove passkey1558 </button>1559 </section>1560 );1561 }}1562 </Show>1563 </Show>1564 </Show>15651566 {}1567 <div style="height:4px;" />1568 </>1569 )}1570 </Show>1571 </div>15721573 </>1574 );1575}1576157715781579function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1580 return (1581 <div class="peach-inline-actions">1582 <button1583 class="peach-inline-save-btn"1584 onClick={props.onSave}1585 disabled={props.saving}1586 aria-label="Save"1587 title="Save (Enter)"1588 >1589 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1590 <path d="M1 4.5L4 7.5L10 1.5" />1591 </svg>1592 </button>1593 <button1594 class="peach-inline-cancel-btn"1595 onClick={props.onCancel}1596 disabled={props.saving}1597 aria-label="Cancel"1598 title="Cancel (Esc)"1599 >1600 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1601 <path d="M1 1L8 8M8 1L1 8" />1602 </svg>1603 </button>1604 </div>1605 );1606}16071608function EmptyVaultState() {1609 return (1610 <>1611 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1612 <div class="peach-entry-name">1613 <span class="peach-entry-name-text">1614 <span class="peach-entry-name-char" style="--char-index:0">e</span>1615 <span class="peach-entry-name-char" style="--char-index:1">m</span>1616 <span class="peach-entry-name-char" style="--char-index:2">p</span>1617 <span class="peach-entry-name-char" style="--char-index:3">t</span>1618 <span class="peach-entry-name-char" style="--char-index:4">y</span>1619 </span>1620 </div>1621 </div>1622 <div class="peach-empty-copy">1623 <span class="peach-label">vault</span>1624 <p>Your vault is ready for its first entry.</p>1625 </div>1626 </>1627 );1628}16291630function CopyGlyph() {1631 return (1632 <span class="peach-copy-icon">1633 <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">1634 <rect x="4" y="4" width="8" height="9" rx="1.5" />1635 <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" />1636 </svg>1637 </span>1638 );1639}16401641function EyeClosedGlyph() {1642 return (1643 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1644 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1645 <path d="M3.5 5.5L2.5 7.5" />1646 <path d="M7 6.5V8.5" />1647 <path d="M10.5 5.5L11.5 7.5" />1648 </svg>1649 );1650}16511652function EyeOpenGlyph() {1653 return (1654 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1655 <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" />1656 <circle cx="7" cy="5" r="1.75" />1657 </svg>1658 );1659}16601661function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1662 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1663 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1664 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1665 const [testResult, setTestResult] = createSignal<string | null>(null);1666 const [testing, setTesting] = createSignal(false);16671668 1669 1670 1671 1672 1673 1674 const isProGated = () => !props.isPro && !props.entry.canary;16751676 async function save() {1677 const url = webhookUrl().trim();1678 const nextLabel = label().trim() || undefined;1679 const canary = enabled() && url1680 ? {1681 webhookUrl: url,1682 label: nextLabel,1683 lastPinged: props.entry.canary?.lastPinged,1684 pingCount: props.entry.canary?.pingCount,1685 }1686 : undefined;1687 await sendMessage({1688 type: "UPDATE_ENTRY",1689 payload: { id: props.entry.id, patch: { canary } },1690 });1691 await props.onSaved();1692 }16931694 async function testPing() {1695 const url = webhookUrl().trim();1696 if (!url) return;1697 setTesting(true);1698 setTestResult(null);1699 const res = await sendMessage({1700 type: "FIRE_CANARY_PING",1701 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1702 }) as { ok: boolean; error?: string };1703 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1704 setTesting(false);1705 }17061707 return (1708 <div class="peach-canary-section">1709 <div class="peach-field-value-row">1710 <span class="peach-field-value" style="font-size:12px;">1711 canary alert1712 <Show when={isProGated()}>1713 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1714 </Show>1715 </span>1716 <button1717 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1718 onClick={() => {1719 if (isProGated()) {1720 1721 1722 1723 props.onProGate?.("canary_entry");1724 return;1725 }1726 setEnabled(!enabled());1727 void save();1728 }}1729 aria-label="Toggle canary"1730 aria-disabled={isProGated() ? "true" : "false"}1731 >1732 <span class="peach-toggle-knob" />1733 </button>1734 </div>1735 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1736 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1737 </p>1738 <Show when={enabled()}>1739 <div class="peach-inline-field-wrap" style="margin-top:8px;">1740 <input1741 class="peach-inline-input"1742 placeholder="https://hooks.zapier.com/..."1743 value={webhookUrl()}1744 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1745 onBlur={() => void save()}1746 />1747 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1748 When triggered, the entry title is sent to this URL. Use an endpoint you control.1749 </p>1750 <input1751 class="peach-inline-input"1752 placeholder="label (optional, e.g. AWS decoy)"1753 value={label()}1754 onInput={(e) => setLabel(e.currentTarget.value)}1755 onBlur={() => void save()}1756 />1757 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1758 <button1759 class="peach-btn peach-btn--ghost peach-btn--sm"1760 onClick={() => void testPing()}1761 disabled={testing() || !webhookUrl().trim()}1762 >1763 {testing() ? "sending..." : "test ping"}1764 </button>1765 <Show when={testResult()}>1766 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1767 </Show>1768 </div>1769 <Show when={props.entry.canary?.lastPinged}>1770 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1771 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1772 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1773 </p>1774 </Show>1775 </div>1776 </Show>1777 </div>1778 );1779}