1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } 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";1213type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1415interface EntryProps {16 entry: VaultEntry | null;17 1819202122 currentTabUrl?: string;23 onDelete: () => void;24 25 onFieldSaved: () => void | Promise<void>;26 onEscape?: () => void;27}2829export function EntryView(props: EntryProps) {30 const [editingField, setEditingField] = createSignal<EditableField>(null);31 const [draft, setDraft] = createSignal("");32 const [saving, setSaving] = createSignal(false);33 const [showPassword, setShowPassword] = createSignal(false);34 const [showGenerator, setShowGenerator] = createSignal(false);35 const [copied, setCopied] = createSignal<string | null>(null);36 37 38 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);39 const [editingTags, setEditingTags] = createSignal(false);40 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);41 const [tagInput, setTagInput] = createSignal("");42 const [showCardNumber, setShowCardNumber] = createSignal(false);43 const [showCardCvc, setShowCardCvc] = createSignal(false);44 const [totpTick, setTotpTick] = createSignal(0);45 const [confirmDelete, setConfirmDelete] = createSignal(false);46 const [totpHot, setTotpHot] = createSignal(false);47 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);48 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);49 50 const [showAdvanced, setShowAdvanced] = createSignal(false);51 let lastSavedNote: string | null = null;5253 function noteExec(cmd: string, val?: string) {54 document.execCommand(cmd, false, val);55 noteEditorEl()?.focus();56 }5758 async function saveNoteInline(html: string) {59 const entry = props.entry;60 if (!entry) return;61 const currentStored = entry.notes ?? "";62 if (html === currentStored) return;63 if (lastSavedNote !== null && html === lastSavedNote) return;64 lastSavedNote = html;65 setSaving(true);66 try {67 await sendMessage({68 type: "UPDATE_ENTRY",69 payload: { id: entry.id, patch: { notes: html } },70 });71 triggerRing();72 await props.onFieldSaved();73 } catch {74 lastSavedNote = null;75 } finally {76 setSaving(false);77 }78 }7980 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);81 onCleanup(() => window.clearInterval(interval));8283 const displayDomain = createMemo(() =>84 props.entry?.url ? getDisplayDomain(props.entry.url) : ""85 );8687 createEffect(() => {88 const domain = displayDomain();89 setFaviconSrc(domain ? faviconUrl(domain) : null);90 });9192 93 94 createEffect(() => {95 void props.entry?.id;96 setConfirmDelete(false);97 setEditingField(null);98 setEditingTags(false);99 setShowAdvanced(false);100 });101102 const totpState = createMemo(() => {103 void totpTick();104 if (!props.entry?.totp) return null;105 return generateTOTP(props.entry.totp);106 });107108 let prevTotpCode: string | undefined;109 createEffect(() => {110 const state = totpState();111 if (!state) { prevTotpCode = undefined; return; }112 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {113 setTotpHot(true);114 window.setTimeout(() => setTotpHot(false), 3000);115 }116 prevTotpCode = state.code;117 });118119 const totpProgress = createMemo(() => {120 const state = totpState();121 if (!state) return 0;122 return ((state.remaining - 1) / 29) * 100;123 });124125 createEffect(() => {126 const shell = document.getElementById("hd-shell");127 if (!shell) return;128129 const state = totpState();130 if (!state) {131 delete shell.dataset.totpActive;132 shell.style.removeProperty("--peach-totp-progress");133 return;134 }135136 shell.dataset.totpActive = "true";137 shell.style.setProperty(138 "--peach-totp-progress",139 `${Math.max(0, totpProgress())}%`140 );141142 onCleanup(() => {143 delete shell.dataset.totpActive;144 shell.style.removeProperty("--peach-totp-progress");145 });146 });147148 const titleSizeClass = createMemo(() => {149 const length = props.entry?.title.length ?? 0;150 if (length >= 28) return "peach-entry-name--xxl";151 if (length >= 20) return "peach-entry-name--xl";152 if (length >= 14) return "peach-entry-name--lg";153 return "";154 });155 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);156157 158159 function startEdit(field: EditableField, value: string) {160 setEditingField(field);161 setDraft(value);162 setShowGenerator(false);163 setConfirmDelete(false);164 if (field === "url") {165 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);166 }167 }168169 170 171 172 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {173 const c = candidate.trim();174 if (!c) return true;175 if (primary.trim() === c) return true;176 return others.some((u) => u.trim() === c);177 }178179 function addCurrentSite(url: string) {180 if (!url) return;181 if (!draft().trim()) {182 setDraft(url);183 return;184 }185 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;186 setAdditionalUrlsDraft((arr) => [...arr, url]);187 }188189 async function saveField() {190 const field = editingField();191 const entry = props.entry;192 if (!field || !entry) return;193 194 let value = field === "password" ? draft() : draft().trim();195 if (field === "totp" && value.startsWith("otpauth://")) {196 const parsed = parseOtpauthURI(value);197 value = parsed?.secret ?? value;198 }199 if (field === "password" && entry.type !== "note" && !value) {200 return;201 }202 setSaving(true);203 try {204 const patch: Partial<VaultEntry> = { [field]: value };205 if (field === "url") {206 207 patch.additionalUrls = additionalUrlsDraft();208 }209 await sendMessage({210 type: "UPDATE_ENTRY",211 payload: { id: entry.id, patch },212 });213 triggerRing();214 setEditingField(null);215 setShowGenerator(false);216 await props.onFieldSaved();217 } catch {218 219 } finally {220 setSaving(false);221 }222 }223224 function startEditTags() {225 setTagsDraft([...(props.entry?.tags ?? [])]);226 setTagInput("");227 setEditingTags(true);228 }229230 function addTag(value: string) {231 const n = value.toLowerCase().trim();232 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;233 setTagsDraft([...tagsDraft(), n]);234 setTagInput("");235 }236237 async function saveTags() {238 if (tagInput()) addTag(tagInput());239 const entry = props.entry;240 if (!entry) return;241 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;242 setEditingTags(false);243 setSaving(true);244 try {245 await sendMessage({246 type: "UPDATE_ENTRY",247 payload: { id: entry.id, patch: { tags: next } },248 });249 triggerRing();250 await props.onFieldSaved();251 } catch {252 253 } finally {254 setSaving(false);255 }256 }257258 function cancelEdit() {259 setEditingField(null);260 setDraft("");261 setShowGenerator(false);262 }263264 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {265 switch (field) {266 case "title":267 return entry.title;268 case "url":269 return entry.url ?? "";270 case "username":271 return entry.username ?? "";272 case "password":273 return entry.password ?? "";274 case "notes":275 return entry.notes ?? "";276 case "totp":277 return entry.totp ?? "";278 }279 }280281 function handleEditorFocusOut(282 field: Exclude<EditableField, null>,283 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }284 ) {285 const nextTarget = event.relatedTarget;286 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;287288 const entry = props.entry;289 if (!entry) return;290291 const currentValue = fieldValue(entry, field);292 const nextValue = field === "password" ? draft() : draft().trim();293 if (nextValue === currentValue) {294 cancelEdit();295 }296 }297298 async function savePassword(password: string) {299 const entry = props.entry;300 if (!entry) return;301 setSaving(true);302 try {303 await sendMessage({304 type: "UPDATE_ENTRY",305 payload: { id: entry.id, patch: { password } },306 });307 triggerRing();308 setShowGenerator(false);309 await props.onFieldSaved();310 } catch {311 312 } finally {313 setSaving(false);314 }315 }316317 318319 async function copyField(text: string, label: string, button?: HTMLButtonElement) {320 const ok = await popupCopy(text, label);321 if (ok) {322 const entry = props.entry;323 if (entry?.canary?.webhookUrl) {324 const trigger =325 label === "password"326 ? "password_copied"327 : label === "username"328 ? "username_copied"329 : undefined;330 if (trigger) {331 void sendMessage({332 type: "FIRE_CANARY_PING",333 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },334 });335 }336 }337 triggerRing();338 triggerPipPulse();339 setCopied(null);340 requestAnimationFrame(() => setCopied(label));341 if (button) {342 button.classList.remove("is-copied", "is-retreat");343 void button.offsetWidth;344 const drain = document.createElement("span");345 drain.className = "peach-copy-drain";346 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";347 drain.appendChild(bar);348 button.appendChild(drain);349 window.setTimeout(() => {350 button.classList.remove("is-copied");351 button.classList.add("is-retreat");352 drain.remove();353 window.setTimeout(() => button.classList.remove("is-retreat"), 160);354 }, 1800);355 }356 window.setTimeout(() => setCopied(null), 1800);357 }358 }359360 361362 createEffect(() => {363 if (!props.entry) return;364 const handler = (e: KeyboardEvent) => {365 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;366 if (inInput) return;367 switch (e.key) {368 case "c": e.preventDefault();369 if (props.entry?.password) void copyField(props.entry.password, "password"); break;370 case "u": e.preventDefault();371 if (props.entry?.username) void copyField(props.entry.username, "username"); break;372 case "Escape": e.preventDefault(); props.onEscape?.(); break;373 }374 };375 window.addEventListener("keydown", handler);376 onCleanup(() => window.removeEventListener("keydown", handler));377 });378379 380381 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {382 requestAnimationFrame(() => { el.focus(); el.select?.(); });383 }384385 function onEditKeyDown(e: KeyboardEvent) {386 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {387 e.preventDefault();388 void saveField();389 }390 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }391 }392393 return (394 <>395 <div396 class="peach-ghost"397 aria-hidden="true"398 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:0.03;pointer-events:none;line-height:1;overflow:hidden;white-space:nowrap;z-index:0;"399 >400 {props.entry?.title || "empty"}401 </div>402403 <div class="peach-stage" style="position:relative;">404 <Show when={props.entry} fallback={<EmptyVaultState />}>405 {(entry) => (406 <>407 {}408 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>409 <div class="peach-breach-badge">410 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">411 <path d="M6 1L11 10H1L6 1z" />412 <line x1="6" y1="5" x2="6" y2="7.5" />413 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />414 </svg>415 <span style="flex:1">found in a known data breach</span>416 <button417 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"418 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}419 >420 Change password421 </button>422 </div>423 </Show>424425 {}426 <div class="peach-entry-header">427 <div class="peach-entry-header-name">428 <Show429 when={editingField() === "title"}430 fallback={431 <button432 class="peach-entry-name-button"433 title="Click to edit title"434 onClick={() => startEdit("title", entry().title)}435 >436 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>437 <span class="peach-entry-name-text">438 <Show439 when={useAnimatedTitle()}440 fallback={entry().title}441 >442 <For each={entry().title.split("")}>443 {(ch, i) => (444 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>445 {ch === " " ? "\u00A0" : ch}446 </span>447 )}448 </For>449 </Show>450 </span>451 </h1>452 </button>453 }454 >455 <div456 class="peach-inline-title-wrap"457 onFocusOut={(event) => handleEditorFocusOut("title", event)}458 >459 <input460 class="peach-inline-title-input"461 value={draft()}462 onInput={(e) => setDraft(e.currentTarget.value)}463 onKeyDown={onEditKeyDown}464 ref={autoFocus}465 disabled={saving()}466 />467 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />468 </div>469 </Show>470 </div>471472 {}473 <Show when={editingField() !== "title"}>474 <Show475 when={confirmDelete()}476 fallback={477 <button478 class="peach-entry-trash-btn"479 onClick={() => setConfirmDelete(true)}480 aria-label="Move to trash"481 title="Delete entry"482 >483 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">484 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />485 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />486 </svg>487 </button>488 }489 >490 <div class="peach-entry-trash-confirm">491 <span class="peach-entry-trash-confirm-label">trash?</span>492 <button493 class="peach-btn peach-btn--sm peach-btn--danger"494 style="font-size:10px;padding:2px 8px;min-height:22px;"495 onClick={() => { setConfirmDelete(false); props.onDelete(); }}496 >497 yes498 </button>499 <button500 class="peach-btn peach-btn--ghost peach-btn--sm"501 style="font-size:10px;padding:2px 8px;min-height:22px;"502 onClick={() => setConfirmDelete(false)}503 >504 no505 </button>506 </div>507 </Show>508 </Show>509 </div>510511 {}512 <Show when={entry().type !== "note" && entry().type !== "card"}>513 <div class="peach-domain" style="position:relative;z-index:1;">514 <Show515 when={editingField() === "url"}516 fallback={517 <div518 class="peach-domain-row peach-inline-clickable"519 title="Click to edit URL"520 onClick={() => startEdit("url", entry().url ?? "")}521 >522 <Show when={faviconSrc()}>523 <img524 src={faviconSrc()!}525 width="16"526 height="16"527 alt=""528 style="border-radius:3px;margin-right:5px;flex-shrink:0;"529 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}530 />531 </Show>532 <span class="peach-domain-value">533 {displayDomain() || (entry().url ? entry().url : "add a website url")}534 </span>535 <span class="peach-inline-edit-hint">edit</span>536 </div>537 }538 >539 <div540 class="peach-inline-field-wrap peach-inline-field-wrap--url"541 onFocusOut={(event) => handleEditorFocusOut("url", event)}542 >543 <div class="peach-inline-field-row">544 <input545 class="peach-inline-input"546 type="url"547 placeholder="https://"548 value={draft()}549 onInput={(e) => setDraft(e.currentTarget.value)}550 onKeyDown={onEditKeyDown}551 ref={autoFocus}552 disabled={saving()}553 />554 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />555 </div>556 <div class="peach-additional-urls">557 <Show when={additionalUrlsDraft().length > 0}>558 <span class="peach-label peach-additional-urls-label">also matches</span>559 </Show>560 <For each={additionalUrlsDraft()}>561 {(value, i) => (562 <div class="peach-additional-url-row">563 <input564 class="peach-inline-input"565 type="url"566 placeholder="https://"567 value={value}568 onInput={(e) => setAdditionalUrlsDraft((arr) => {569 const next = [...arr];570 next[i()] = e.currentTarget.value;571 return next;572 })}573 disabled={saving()}574 />575 <button576 class="peach-additional-url-remove"577 type="button"578 aria-label="Remove URL"579 onMouseDown={(e) => e.preventDefault()}580 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}581 disabled={saving()}582 >×</button>583 </div>584 )}585 </For>586 <button587 class="peach-add-field-link peach-additional-urls-add"588 type="button"589 onMouseDown={(e) => e.preventDefault()}590 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}591 disabled={saving()}592 >593 + add another url594 </button>595 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>596 <button597 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"598 type="button"599 title={props.currentTabUrl}600 onMouseDown={(e) => e.preventDefault()}601 onClick={() => addCurrentSite(props.currentTabUrl!)}602 disabled={saving()}603 >604 + add current site605 </button>606 </Show>607 </div>608 </div>609 </Show>610 </div>611 </Show>612613 {}614 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>615 <div616 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"617 onClick={startEditTags}618 title="Click to edit tags"619 >620 <For each={entry().tags!}>621 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}622 </For>623 </div>624 </Show>625626 {}627 <Show when={entry().type === "note"}>628 <div class="peach-entry-note-toolbar-row">629 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />630 </div>631 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">632 <NoteEditorInline633 entryKey={entry().id}634 initialContent={entry().notes ?? ""}635 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}636 onBlur={saveNoteInline}637 autoFocus={false}638 />639 </section>640 </Show>641642 {}643 <Show when={entry().type === "card"}>644 <section class="peach-fields" style="position:relative;z-index:1;">645 {}646 <Show when={entry().last4}>647 <div class="peach-field-card" style="padding-bottom:4px;">648 <span class="peach-label">649 <Show when={entry().network && entry().network !== "unknown"}>650 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>651 </Show>652 {entry().cardNickname || `•••• ${entry().last4}`}653 </span>654 </div>655 </Show>656657 {}658 <div class="peach-field-card">659 <span class="peach-label">cardholder name</span>660 <div class="peach-field-value-row">661 <span class="peach-field-value">{entry().cardholderName || "—"}</span>662 <Show when={entry().cardholderName}>663 <button664 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}665 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}666 aria-label="Copy cardholder name"667 >668 <CopyGlyph /><span class="peach-copy-sheen" />669 </button>670 </Show>671 </div>672 </div>673674 {}675 <div class="peach-field-card">676 <span class="peach-label">card number</span>677 <div class="peach-field-value-row">678 <span class={`peach-field-value peach-card-number`}>679 <Show when={showCardNumber()} fallback={680 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>681 }>682 <span>{entry().cardNumber ?? ""}</span>683 </Show>684 </span>685 <div class="peach-field-actions">686 <button687 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"688 onClick={() => setShowCardNumber(!showCardNumber())}689 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}690 >691 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>692 <EyeClosedGlyph />693 </Show>694 </button>695 <Show when={entry().cardNumber}>696 <button697 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}698 onClick={(e) => {699 const cardNum = entry().cardNumber!;700 if (entry().canary?.webhookUrl) {701 void sendMessage({702 type: "FIRE_CANARY_PING",703 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },704 });705 }706 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);707 }}708 aria-label="Copy card number"709 >710 <CopyGlyph /><span class="peach-copy-sheen" />711 </button>712 </Show>713 </div>714 </div>715 </div>716717 {}718 <Show when={entry().expMonth || entry().expYear}>719 <div class="peach-field-card">720 <span class="peach-label">expiry</span>721 <div class="peach-field-value-row">722 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>723 <button724 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}725 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}726 aria-label="Copy expiry"727 >728 <CopyGlyph /><span class="peach-copy-sheen" />729 </button>730 </div>731 </div>732 </Show>733734 {}735 <Show when={entry().cvc}>736 <div class="peach-field-card">737 <span class="peach-label">CVC</span>738 <div class="peach-field-value-row">739 <span class="peach-field-value">740 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>741 <span>{entry().cvc}</span>742 </Show>743 </span>744 <div class="peach-field-actions">745 <button746 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"747 onClick={() => setShowCardCvc(!showCardCvc())}748 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}749 >750 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>751 <EyeClosedGlyph />752 </Show>753 </button>754 <button755 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}756 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}757 aria-label="Copy CVC"758 >759 <CopyGlyph /><span class="peach-copy-sheen" />760 </button>761 </div>762 </div>763 </div>764 </Show>765 </section>766 </Show>767768 {}769 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>770 <section class="peach-fields" style="position:relative;z-index:1;">771772 {}773 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>774 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>775 <span class="peach-label">{entry().username?.includes("@") ? "email" : "username"}</span>776 <Show777 when={editingField() === "username"}778 fallback={779 <div class="peach-field-value-row">780 <span781 class="peach-field-value peach-inline-clickable"782 title="Click to edit"783 onClick={() => startEdit("username", entry().username ?? "")}784 >785 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}786 </span>787 <Show when={entry().username}>788 <button789 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}790 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}791 aria-label="Copy username"792 >793 <CopyGlyph /><span class="peach-copy-sheen" />794 </button>795 </Show>796 </div>797 }798 >799 <div800 class="peach-inline-field-wrap"801 onFocusOut={(event) => handleEditorFocusOut("username", event)}802 >803 <input804 class="peach-inline-input"805 value={draft()}806 onInput={(e) => setDraft(e.currentTarget.value)}807 onKeyDown={onEditKeyDown}808 ref={autoFocus}809 disabled={saving()}810 autocomplete="off"811 />812 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />813 <Show when={!entry().email}>814 <button815 class="peach-add-field-btn"816 type="button"817 onMouseDown={(e) => e.preventDefault()}818 onClick={() => startEdit("email", "")}819 >820 + add email821 </button>822 </Show>823 </div>824 </Show>825 </div>826 </Show>827828 {}829 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>830 <span class="peach-label">password</span>831 <Show832 when={editingField() === "password"}833 fallback={834 <div class="peach-field-value-row">835 <span836 class="peach-password-value"837 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}838 style="cursor:pointer;"839 title="Click to edit password"840 onClick={() => startEdit("password", entry().password ?? "")}841 >842 <Show843 when={showPassword()}844 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}845 >846 <span class="peach-password-text">{entry().password ?? ""}</span>847 </Show>848 </span>849 <div class="peach-field-actions">850 <button851 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"852 onClick={() => setShowPassword(!showPassword())}853 aria-label="Reveal password"854 >855 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>856 <EyeClosedGlyph />857 </Show>858 </button>859 <button860 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}861 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}862 aria-label="Copy password"863 >864 <CopyGlyph /><span class="peach-copy-sheen" />865 </button>866 </div>867 </div>868 }869 >870 {}871 <div872 class="peach-inline-field-wrap"873 onFocusOut={(event) => handleEditorFocusOut("password", event)}874 >875 <div class="peach-password-edit-row">876 <input877 class="peach-inline-input"878 type={showPassword() ? "text" : "password"}879 placeholder="paste or type a password"880 value={draft()}881 onInput={(e) => setDraft(e.currentTarget.value)}882 onKeyDown={(e) => {883 if (e.key === "Enter") { e.preventDefault(); void saveField(); }884 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }885 }}886 ref={autoFocus}887 disabled={saving()}888 autocomplete="new-password"889 />890 <button891 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"892 type="button"893 onClick={() => setShowPassword(!showPassword())}894 aria-label={showPassword() ? "Hide" : "Show"}895 >896 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>897 <EyeClosedGlyph />898 </Show>899 </button>900 </div>901 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />902 <button903 class="peach-generate-toggle-link"904 type="button"905 onClick={() => setShowGenerator(!showGenerator())}906 >907 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">908 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />909 <path d="M9.5 2.5H11V1" />910 </svg>911 {showGenerator() ? "hide generator" : "generate"}912 </button>913 <Show when={showGenerator()}>914 <GeneratorDrawer915 open={true}916 compact={true}917 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}918 />919 </Show>920 </div>921 </Show>922 </div>923924 </section>925 </Show>926927 {}928 <Show when={entry().type !== "note" && entry().type !== "card"}>929 <Show930 when={entry().notes || editingField() === "notes"}931 fallback={932 <button933 class="peach-add-field-link peach-entry-add-link"934 onClick={() => startEdit("notes", "")}935 >936 + notes937 </button>938 }939 >940 <div style="position:relative;z-index:1;margin-top:6px;">941 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>942 <Show943 when={editingField() === "notes"}944 fallback={945 <div946 class="peach-note-content peach-inline-clickable"947 style="cursor:text;white-space:pre-wrap;"948 onClick={() => startEdit("notes", entry().notes ?? "")}949 >950 {entry().notes}951 </div>952 }953 >954 <div955 class="peach-inline-field-wrap"956 onFocusOut={(event) => handleEditorFocusOut("notes", event)}957 >958 <textarea959 class="peach-inline-textarea"960 value={draft()}961 onInput={(e) => setDraft(e.currentTarget.value)}962 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}963 ref={(el) => requestAnimationFrame(() => el?.focus())}964 rows={4}965 disabled={saving()}966 />967 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />968 </div>969 </Show>970 </div>971 </Show>972 </Show>973974 {}975 <Show976 when={editingTags()}977 fallback={978 <Show when={!entry().tags || entry().tags!.length === 0}>979 <button980 class="peach-add-field-link peach-entry-add-link"981 onClick={startEditTags}982 >983 + tags984 </button>985 </Show>986 }987 >988 <div989 class="peach-inline-tag-editor"990 onFocusOut={(e) => {991 const next = e.relatedTarget;992 if (next instanceof Node && e.currentTarget.contains(next)) return;993 void saveTags();994 }}995 >996 <span class="peach-label">tags</span>997 <div class="peach-tag-input-wrap">998 <For each={tagsDraft()}>999 {(tag) => (1000 <span class="peach-tag-chip">1001 {tag}1002 <button1003 class="peach-tag-chip-remove"1004 onMouseDown={(e) => e.preventDefault()}1005 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1006 aria-label={`Remove ${tag}`}1007 >×</button>1008 </span>1009 )}1010 </For>1011 <input1012 class="peach-tag-input"1013 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1014 value={tagInput()}1015 onInput={(e) => setTagInput(e.currentTarget.value)}1016 onKeyDown={(e) => {1017 if (e.key === "Enter" || e.key === ",") {1018 e.preventDefault();1019 addTag(tagInput());1020 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1021 setTagsDraft(tagsDraft().slice(0, -1));1022 } else if (e.key === "Escape") {1023 e.preventDefault();1024 setEditingTags(false);1025 setTagInput("");1026 }1027 }}1028 ref={(el) => requestAnimationFrame(() => el?.focus())}1029 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1030 />1031 </div>1032 </div>1033 </Show>10341035 {}1036 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1037 <Show1038 when={totpState() || editingField() === "totp"}1039 fallback={1040 <button1041 class="peach-add-field-link peach-entry-add-link"1042 onClick={() => startEdit("totp", "")}1043 >1044 + 2FA1045 </button>1046 }1047 >1048 <section class="peach-totp-band">1049 <Show1050 when={editingField() === "totp"}1051 fallback={1052 <Show when={totpState()}>1053 {(state) => (1054 <>1055 <div1056 class="peach-totp-main"1057 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1058 onDblClick={() => startEdit("totp", entry().totp ?? "")}1059 title="Click to copy · double-click to edit"1060 >1061 <span class="peach-label">2-factor</span>1062 <div class="peach-totp-code-row">1063 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1064 <span class="peach-totp-code-text">1065 {state().code.slice(0, 3)} {state().code.slice(3)}1066 </span>1067 </p>1068 </div>1069 </div>1070 <div class="peach-totp-meta">1071 <span class="peach-label">expires</span>1072 <div class="peach-totp-countdown">{state().remaining}s</div>1073 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1074 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1075 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1076 stroke-dasharray="106.8"1077 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1078 />1079 </svg>1080 </div>1081 </>1082 )}1083 </Show>1084 }1085 >1086 <div1087 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1088 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1089 >1090 <span class="peach-label">2-factor</span>1091 <input1092 class="peach-inline-input"1093 value={draft()}1094 onInput={(e) => setDraft(e.currentTarget.value)}1095 onKeyDown={onEditKeyDown}1096 ref={autoFocus}1097 disabled={saving()}1098 placeholder="otpauth://... or base32 secret"1099 autocomplete="off"1100 />1101 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1102 </div>1103 </Show>1104 </section>1105 </Show>1106 </Show>11071108 {}1109 <Show when={entry().type === "login"}>1110 <button1111 class="peach-add-field-link peach-entry-add-link"1112 onClick={() => setShowAdvanced((v) => !v)}1113 aria-expanded={showAdvanced()}1114 >1115 {showAdvanced() ? "− Advanced" : "+ Advanced"}1116 </button>1117 <Show when={showAdvanced()}>1118 <Show when={!isPasskeyOnly(entry())}>1119 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1120 </Show>11211122 <Show when={entry().passkey}>1123 {(passkey) => {1124 const currentBrowser = detectBrowser();1125 const crossBrowser =1126 passkey().browser &&1127 currentBrowser !== "unknown" &&1128 passkey().browser !== currentBrowser;1129 const algorithmLabel =1130 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1131 const formatDate = (ts?: number) =>1132 ts ? new Date(ts).toLocaleDateString() : "Never";1133 return (1134 <section1135 class="peach-fields peach-passkey-section"1136 style="position:relative;z-index:1;margin-top:6px;"1137 >1138 <div class="peach-field-card">1139 <span class="peach-label">passkey · {passkey().rpId}</span>1140 <div class="peach-field-value-row">1141 <span class="peach-field-value">{passkey().userName}</span>1142 <span class="peach-passkey-badge">passkey</span>1143 </div>1144 </div>11451146 <div class="peach-field-card">1147 <span class="peach-label">algorithm</span>1148 <div class="peach-field-value-row">1149 <span class="peach-field-value">{algorithmLabel}</span>1150 </div>1151 </div>11521153 <div class="peach-field-card">1154 <span class="peach-label">activity</span>1155 <div class="peach-field-value-row peach-passkey-activity">1156 <span class="peach-field-value">1157 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1158 </span>1159 <span class="peach-passkey-meta">1160 created {formatDate(passkey().createdAt)}1161 </span>1162 <span class="peach-passkey-meta">1163 last used {formatDate(passkey().lastUsedAt)}1164 </span>1165 </div>1166 </div>11671168 <Show when={crossBrowser}>1169 <div class="peach-info-notice peach-info-notice--warn">1170 {`This passkey was created on ${1171 passkey().browser === "chrome" ? "Chrome" : "Firefox"1172 }. To use it on ${1173 currentBrowser === "chrome" ? "Chrome" : "Firefox"1174 }, re-register it on this device.`}1175 </div>1176 </Show>11771178 <div class="peach-info-notice">1179 {passkey().browser === "firefox"1180 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1181 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1182 </div>11831184 <button1185 class="peach-add-field-link peach-entry-add-link"1186 style="color:rgba(220,38,38,0.7);"1187 onClick={async () => {1188 const id = entry().id;1189 await sendMessage({1190 type: "UPDATE_ENTRY",1191 payload: { id, patch: { passkey: undefined } },1192 });1193 await props.onFieldSaved();1194 }}1195 >1196 − remove passkey1197 </button>1198 </section>1199 );1200 }}1201 </Show>1202 </Show>1203 </Show>12041205 {}1206 <div style="height:4px;" />1207 </>1208 )}1209 </Show>1210 </div>12111212 </>1213 );1214}1215121612171218function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1219 return (1220 <div class="peach-inline-actions">1221 <button1222 class="peach-inline-save-btn"1223 onClick={props.onSave}1224 disabled={props.saving}1225 aria-label="Save"1226 title="Save (Enter)"1227 >1228 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1229 <path d="M1 4.5L4 7.5L10 1.5" />1230 </svg>1231 </button>1232 <button1233 class="peach-inline-cancel-btn"1234 onClick={props.onCancel}1235 disabled={props.saving}1236 aria-label="Cancel"1237 title="Cancel (Esc)"1238 >1239 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1240 <path d="M1 1L8 8M8 1L1 8" />1241 </svg>1242 </button>1243 </div>1244 );1245}12461247function EmptyVaultState() {1248 return (1249 <>1250 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1251 <div class="peach-entry-name">1252 <span class="peach-entry-name-text">1253 <span class="peach-entry-name-char" style="--char-index:0">e</span>1254 <span class="peach-entry-name-char" style="--char-index:1">m</span>1255 <span class="peach-entry-name-char" style="--char-index:2">p</span>1256 <span class="peach-entry-name-char" style="--char-index:3">t</span>1257 <span class="peach-entry-name-char" style="--char-index:4">y</span>1258 </span>1259 </div>1260 </div>1261 <div class="peach-empty-copy">1262 <span class="peach-label">vault</span>1263 <p>Your vault is ready for its first entry.</p>1264 </div>1265 </>1266 );1267}12681269function CopyGlyph() {1270 return (1271 <span class="peach-copy-icon">1272 <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">1273 <rect x="4" y="4" width="8" height="9" rx="1.5" />1274 <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" />1275 </svg>1276 </span>1277 );1278}12791280function EyeClosedGlyph() {1281 return (1282 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1283 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1284 <path d="M3.5 5.5L2.5 7.5" />1285 <path d="M7 6.5V8.5" />1286 <path d="M10.5 5.5L11.5 7.5" />1287 </svg>1288 );1289}12901291function EyeOpenGlyph() {1292 return (1293 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1294 <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" />1295 <circle cx="7" cy="5" r="1.75" />1296 </svg>1297 );1298}12991300function ChevronGlyph(props: { open: boolean }) {1301 return (1302 <svg1303 width="8"1304 height="8"1305 viewBox="0 0 8 8"1306 fill="none"1307 stroke="currentColor"1308 stroke-width="1.3"1309 stroke-linecap="round"1310 stroke-linejoin="round"1311 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1312 >1313 <path d="M2 1L6 4L2 7" />1314 </svg>1315 );1316}13171318function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1319 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1320 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1321 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1322 const [testResult, setTestResult] = createSignal<string | null>(null);1323 const [testing, setTesting] = createSignal(false);13241325 async function save() {1326 const url = webhookUrl().trim();1327 const nextLabel = label().trim() || undefined;1328 const canary = enabled() && url1329 ? {1330 webhookUrl: url,1331 label: nextLabel,1332 lastPinged: props.entry.canary?.lastPinged,1333 pingCount: props.entry.canary?.pingCount,1334 }1335 : undefined;1336 await sendMessage({1337 type: "UPDATE_ENTRY",1338 payload: { id: props.entry.id, patch: { canary } },1339 });1340 await props.onSaved();1341 }13421343 async function testPing() {1344 const url = webhookUrl().trim();1345 if (!url) return;1346 setTesting(true);1347 setTestResult(null);1348 const res = await sendMessage({1349 type: "FIRE_CANARY_PING",1350 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1351 }) as { ok: boolean; error?: string };1352 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1353 setTesting(false);1354 }13551356 return (1357 <div class="peach-canary-section">1358 <div class="peach-field-value-row">1359 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1360 <button1361 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1362 onClick={() => { setEnabled(!enabled()); void save(); }}1363 aria-label="Toggle canary"1364 >1365 <span class="peach-toggle-knob" />1366 </button>1367 </div>1368 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1369 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1370 </p>1371 <Show when={enabled()}>1372 <div class="peach-inline-field-wrap" style="margin-top:8px;">1373 <input1374 class="peach-inline-input"1375 placeholder="https://hooks.zapier.com/..."1376 value={webhookUrl()}1377 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1378 onBlur={() => void save()}1379 />1380 <input1381 class="peach-inline-input"1382 placeholder="label (optional, e.g. AWS decoy)"1383 value={label()}1384 onInput={(e) => setLabel(e.currentTarget.value)}1385 onBlur={() => void save()}1386 />1387 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1388 <button1389 class="peach-btn peach-btn--ghost peach-btn--sm"1390 onClick={() => void testPing()}1391 disabled={testing() || !webhookUrl().trim()}1392 >1393 {testing() ? "sending..." : "test ping"}1394 </button>1395 <Show when={testResult()}>1396 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1397 </Show>1398 </div>1399 <Show when={props.entry.canary?.lastPinged}>1400 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1401 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1402 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1403 </p>1404 </Show>1405 </div>1406 </Show>1407 </div>1408 );1409}