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 onDelete: () => void;18 19 onFieldSaved: () => void | Promise<void>;20 onEscape?: () => void;21}2223export function EntryView(props: EntryProps) {24 const [editingField, setEditingField] = createSignal<EditableField>(null);25 const [draft, setDraft] = createSignal("");26 const [saving, setSaving] = createSignal(false);27 const [showPassword, setShowPassword] = createSignal(false);28 const [showGenerator, setShowGenerator] = createSignal(false);29 const [copied, setCopied] = createSignal<string | null>(null);30 31 32 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);33 const [editingTags, setEditingTags] = createSignal(false);34 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);35 const [tagInput, setTagInput] = createSignal("");36 const [showCardNumber, setShowCardNumber] = createSignal(false);37 const [showCardCvc, setShowCardCvc] = createSignal(false);38 const [totpTick, setTotpTick] = createSignal(0);39 const [confirmDelete, setConfirmDelete] = createSignal(false);40 const [totpHot, setTotpHot] = createSignal(false);41 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);42 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);43 let lastSavedNote: string | null = null;4445 function noteExec(cmd: string, val?: string) {46 document.execCommand(cmd, false, val);47 noteEditorEl()?.focus();48 }4950 async function saveNoteInline(html: string) {51 const entry = props.entry;52 if (!entry) return;53 const currentStored = entry.notes ?? "";54 if (html === currentStored) return;55 if (lastSavedNote !== null && html === lastSavedNote) return;56 lastSavedNote = html;57 setSaving(true);58 try {59 await sendMessage({60 type: "UPDATE_ENTRY",61 payload: { id: entry.id, patch: { notes: html } },62 });63 triggerRing();64 await props.onFieldSaved();65 } catch {66 lastSavedNote = null;67 } finally {68 setSaving(false);69 }70 }7172 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);73 onCleanup(() => window.clearInterval(interval));7475 const displayDomain = createMemo(() =>76 props.entry?.url ? getDisplayDomain(props.entry.url) : ""77 );7879 createEffect(() => {80 const domain = displayDomain();81 setFaviconSrc(domain ? faviconUrl(domain) : null);82 });8384 85 86 createEffect(() => {87 void props.entry?.id;88 setConfirmDelete(false);89 setEditingField(null);90 setEditingTags(false);91 });9293 const totpState = createMemo(() => {94 void totpTick();95 if (!props.entry?.totp) return null;96 return generateTOTP(props.entry.totp);97 });9899 let prevTotpCode: string | undefined;100 createEffect(() => {101 const state = totpState();102 if (!state) { prevTotpCode = undefined; return; }103 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {104 setTotpHot(true);105 window.setTimeout(() => setTotpHot(false), 3000);106 }107 prevTotpCode = state.code;108 });109110 const totpProgress = createMemo(() => {111 const state = totpState();112 if (!state) return 0;113 return ((state.remaining - 1) / 29) * 100;114 });115116 createEffect(() => {117 const shell = document.getElementById("hd-shell");118 if (!shell) return;119120 const state = totpState();121 if (!state) {122 delete shell.dataset.totpActive;123 shell.style.removeProperty("--peach-totp-progress");124 return;125 }126127 shell.dataset.totpActive = "true";128 shell.style.setProperty(129 "--peach-totp-progress",130 `${Math.max(0, totpProgress())}%`131 );132133 onCleanup(() => {134 delete shell.dataset.totpActive;135 shell.style.removeProperty("--peach-totp-progress");136 });137 });138139 const titleSizeClass = createMemo(() => {140 const length = props.entry?.title.length ?? 0;141 if (length >= 28) return "peach-entry-name--xxl";142 if (length >= 20) return "peach-entry-name--xl";143 if (length >= 14) return "peach-entry-name--lg";144 return "";145 });146 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);147148 149150 function startEdit(field: EditableField, value: string) {151 setEditingField(field);152 setDraft(value);153 setShowGenerator(false);154 setConfirmDelete(false);155 if (field === "url") {156 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);157 }158 }159160 async function saveField() {161 const field = editingField();162 const entry = props.entry;163 if (!field || !entry) return;164 165 let value = field === "password" ? draft() : draft().trim();166 if (field === "totp" && value.startsWith("otpauth://")) {167 const parsed = parseOtpauthURI(value);168 value = parsed?.secret ?? value;169 }170 if (field === "password" && entry.type !== "note" && !value) {171 return;172 }173 setSaving(true);174 try {175 const patch: Partial<VaultEntry> = { [field]: value };176 if (field === "url") {177 178 patch.additionalUrls = additionalUrlsDraft();179 }180 await sendMessage({181 type: "UPDATE_ENTRY",182 payload: { id: entry.id, patch },183 });184 triggerRing();185 setEditingField(null);186 setShowGenerator(false);187 await props.onFieldSaved();188 } catch {189 190 } finally {191 setSaving(false);192 }193 }194195 function startEditTags() {196 setTagsDraft([...(props.entry?.tags ?? [])]);197 setTagInput("");198 setEditingTags(true);199 }200201 function addTag(value: string) {202 const n = value.toLowerCase().trim();203 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;204 setTagsDraft([...tagsDraft(), n]);205 setTagInput("");206 }207208 async function saveTags() {209 if (tagInput()) addTag(tagInput());210 const entry = props.entry;211 if (!entry) return;212 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;213 setEditingTags(false);214 setSaving(true);215 try {216 await sendMessage({217 type: "UPDATE_ENTRY",218 payload: { id: entry.id, patch: { tags: next } },219 });220 triggerRing();221 await props.onFieldSaved();222 } catch {223 224 } finally {225 setSaving(false);226 }227 }228229 function cancelEdit() {230 setEditingField(null);231 setDraft("");232 setShowGenerator(false);233 }234235 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {236 switch (field) {237 case "title":238 return entry.title;239 case "url":240 return entry.url ?? "";241 case "username":242 return entry.username ?? "";243 case "password":244 return entry.password ?? "";245 case "notes":246 return entry.notes ?? "";247 case "totp":248 return entry.totp ?? "";249 }250 }251252 function handleEditorFocusOut(253 field: Exclude<EditableField, null>,254 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }255 ) {256 const nextTarget = event.relatedTarget;257 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;258259 const entry = props.entry;260 if (!entry) return;261262 const currentValue = fieldValue(entry, field);263 const nextValue = field === "password" ? draft() : draft().trim();264 if (nextValue === currentValue) {265 cancelEdit();266 }267 }268269 async function savePassword(password: string) {270 const entry = props.entry;271 if (!entry) return;272 setSaving(true);273 try {274 await sendMessage({275 type: "UPDATE_ENTRY",276 payload: { id: entry.id, patch: { password } },277 });278 triggerRing();279 setShowGenerator(false);280 await props.onFieldSaved();281 } catch {282 283 } finally {284 setSaving(false);285 }286 }287288 289290 async function copyField(text: string, label: string, button?: HTMLButtonElement) {291 const ok = await popupCopy(text, label);292 if (ok) {293 const entry = props.entry;294 if (entry?.canary?.webhookUrl) {295 const trigger =296 label === "password"297 ? "password_copied"298 : label === "username"299 ? "username_copied"300 : undefined;301 if (trigger) {302 void sendMessage({303 type: "FIRE_CANARY_PING",304 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },305 });306 }307 }308 triggerRing();309 triggerPipPulse();310 setCopied(null);311 requestAnimationFrame(() => setCopied(label));312 if (button) {313 button.classList.remove("is-copied", "is-retreat");314 void button.offsetWidth;315 const drain = document.createElement("span");316 drain.className = "peach-copy-drain";317 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";318 drain.appendChild(bar);319 button.appendChild(drain);320 window.setTimeout(() => {321 button.classList.remove("is-copied");322 button.classList.add("is-retreat");323 drain.remove();324 window.setTimeout(() => button.classList.remove("is-retreat"), 160);325 }, 1800);326 }327 window.setTimeout(() => setCopied(null), 1800);328 }329 }330331 332333 createEffect(() => {334 if (!props.entry) return;335 const handler = (e: KeyboardEvent) => {336 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;337 if (inInput) return;338 switch (e.key) {339 case "c": e.preventDefault();340 if (props.entry?.password) void copyField(props.entry.password, "password"); break;341 case "u": e.preventDefault();342 if (props.entry?.username) void copyField(props.entry.username, "username"); break;343 case "Escape": e.preventDefault(); props.onEscape?.(); break;344 }345 };346 window.addEventListener("keydown", handler);347 onCleanup(() => window.removeEventListener("keydown", handler));348 });349350 351352 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {353 requestAnimationFrame(() => { el.focus(); el.select?.(); });354 }355356 function onEditKeyDown(e: KeyboardEvent) {357 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {358 e.preventDefault();359 void saveField();360 }361 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }362 }363364 return (365 <>366 <div367 class="peach-ghost"368 aria-hidden="true"369 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;"370 >371 {props.entry?.title || "empty"}372 </div>373374 <div class="peach-stage" style="position:relative;">375 <Show when={props.entry} fallback={<EmptyVaultState />}>376 {(entry) => (377 <>378 {}379 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>380 <div class="peach-breach-badge">381 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">382 <path d="M6 1L11 10H1L6 1z" />383 <line x1="6" y1="5" x2="6" y2="7.5" />384 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />385 </svg>386 <span style="flex:1">found in a known data breach</span>387 <button388 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"389 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}390 >391 Change password392 </button>393 </div>394 </Show>395396 {}397 <div class="peach-entry-header">398 <div class="peach-entry-header-name">399 <Show400 when={editingField() === "title"}401 fallback={402 <button403 class="peach-entry-name-button"404 title="Click to edit title"405 onClick={() => startEdit("title", entry().title)}406 >407 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>408 <span class="peach-entry-name-text">409 <Show410 when={useAnimatedTitle()}411 fallback={entry().title}412 >413 <For each={entry().title.split("")}>414 {(ch, i) => (415 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>416 {ch === " " ? "\u00A0" : ch}417 </span>418 )}419 </For>420 </Show>421 </span>422 </h1>423 </button>424 }425 >426 <div427 class="peach-inline-title-wrap"428 onFocusOut={(event) => handleEditorFocusOut("title", event)}429 >430 <input431 class="peach-inline-title-input"432 value={draft()}433 onInput={(e) => setDraft(e.currentTarget.value)}434 onKeyDown={onEditKeyDown}435 ref={autoFocus}436 disabled={saving()}437 />438 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />439 </div>440 </Show>441 </div>442443 {}444 <Show when={editingField() !== "title"}>445 <Show446 when={confirmDelete()}447 fallback={448 <button449 class="peach-entry-trash-btn"450 onClick={() => setConfirmDelete(true)}451 aria-label="Move to trash"452 title="Delete entry"453 >454 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">455 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />456 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />457 </svg>458 </button>459 }460 >461 <div class="peach-entry-trash-confirm">462 <span class="peach-entry-trash-confirm-label">trash?</span>463 <button464 class="peach-btn peach-btn--sm peach-btn--danger"465 style="font-size:10px;padding:2px 8px;min-height:22px;"466 onClick={() => { setConfirmDelete(false); props.onDelete(); }}467 >468 yes469 </button>470 <button471 class="peach-btn peach-btn--ghost peach-btn--sm"472 style="font-size:10px;padding:2px 8px;min-height:22px;"473 onClick={() => setConfirmDelete(false)}474 >475 no476 </button>477 </div>478 </Show>479 </Show>480 </div>481482 {}483 <Show when={entry().type !== "note" && entry().type !== "card"}>484 <div class="peach-domain" style="position:relative;z-index:1;">485 <Show486 when={editingField() === "url"}487 fallback={488 <div489 class="peach-domain-row peach-inline-clickable"490 title="Click to edit URL"491 onClick={() => startEdit("url", entry().url ?? "")}492 >493 <Show when={faviconSrc()}>494 <img495 src={faviconSrc()!}496 width="16"497 height="16"498 alt=""499 style="border-radius:3px;margin-right:5px;flex-shrink:0;"500 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}501 />502 </Show>503 <span class="peach-domain-value">504 {displayDomain() || (entry().url ? entry().url : "add a website url")}505 </span>506 <span class="peach-inline-edit-hint">edit</span>507 </div>508 }509 >510 <div511 class="peach-inline-field-wrap peach-inline-field-wrap--url"512 onFocusOut={(event) => handleEditorFocusOut("url", event)}513 >514 <div class="peach-inline-field-row">515 <input516 class="peach-inline-input"517 type="url"518 placeholder="https://"519 value={draft()}520 onInput={(e) => setDraft(e.currentTarget.value)}521 onKeyDown={onEditKeyDown}522 ref={autoFocus}523 disabled={saving()}524 />525 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />526 </div>527 <div class="peach-additional-urls">528 <Show when={additionalUrlsDraft().length > 0}>529 <span class="peach-label peach-additional-urls-label">also matches</span>530 </Show>531 <For each={additionalUrlsDraft()}>532 {(value, i) => (533 <div class="peach-additional-url-row">534 <input535 class="peach-inline-input"536 type="url"537 placeholder="https://"538 value={value}539 onInput={(e) => setAdditionalUrlsDraft((arr) => {540 const next = [...arr];541 next[i()] = e.currentTarget.value;542 return next;543 })}544 disabled={saving()}545 />546 <button547 class="peach-additional-url-remove"548 type="button"549 aria-label="Remove URL"550 onMouseDown={(e) => e.preventDefault()}551 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}552 disabled={saving()}553 >×</button>554 </div>555 )}556 </For>557 <button558 class="peach-add-field-link peach-additional-urls-add"559 type="button"560 onMouseDown={(e) => e.preventDefault()}561 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}562 disabled={saving()}563 >564 + add another url565 </button>566 </div>567 </div>568 </Show>569 </div>570 </Show>571572 {}573 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>574 <div575 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"576 onClick={startEditTags}577 title="Click to edit tags"578 >579 <For each={entry().tags!}>580 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}581 </For>582 </div>583 </Show>584585 {}586 <Show when={entry().type === "note"}>587 <div class="peach-entry-note-toolbar-row">588 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />589 </div>590 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">591 <NoteEditorInline592 entryKey={entry().id}593 initialContent={entry().notes ?? ""}594 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}595 onBlur={saveNoteInline}596 autoFocus={false}597 />598 </section>599 </Show>600601 {}602 <Show when={entry().type === "card"}>603 <section class="peach-fields" style="position:relative;z-index:1;">604 {}605 <Show when={entry().last4}>606 <div class="peach-field-card" style="padding-bottom:4px;">607 <span class="peach-label">608 <Show when={entry().network && entry().network !== "unknown"}>609 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>610 </Show>611 {entry().cardNickname || `•••• ${entry().last4}`}612 </span>613 </div>614 </Show>615616 {}617 <div class="peach-field-card">618 <span class="peach-label">cardholder name</span>619 <div class="peach-field-value-row">620 <span class="peach-field-value">{entry().cardholderName || "—"}</span>621 <Show when={entry().cardholderName}>622 <button623 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}624 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}625 aria-label="Copy cardholder name"626 >627 <CopyGlyph /><span class="peach-copy-sheen" />628 </button>629 </Show>630 </div>631 </div>632633 {}634 <div class="peach-field-card">635 <span class="peach-label">card number</span>636 <div class="peach-field-value-row">637 <span class={`peach-field-value peach-card-number`}>638 <Show when={showCardNumber()} fallback={639 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>640 }>641 <span>{entry().cardNumber ?? ""}</span>642 </Show>643 </span>644 <div class="peach-field-actions">645 <button646 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"647 onClick={() => setShowCardNumber(!showCardNumber())}648 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}649 >650 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>651 <EyeClosedGlyph />652 </Show>653 </button>654 <Show when={entry().cardNumber}>655 <button656 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}657 onClick={(e) => {658 const cardNum = entry().cardNumber!;659 if (entry().canary?.webhookUrl) {660 void sendMessage({661 type: "FIRE_CANARY_PING",662 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },663 });664 }665 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);666 }}667 aria-label="Copy card number"668 >669 <CopyGlyph /><span class="peach-copy-sheen" />670 </button>671 </Show>672 </div>673 </div>674 </div>675676 {}677 <Show when={entry().expMonth || entry().expYear}>678 <div class="peach-field-card">679 <span class="peach-label">expiry</span>680 <div class="peach-field-value-row">681 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>682 <button683 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}684 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}685 aria-label="Copy expiry"686 >687 <CopyGlyph /><span class="peach-copy-sheen" />688 </button>689 </div>690 </div>691 </Show>692693 {}694 <Show when={entry().cvc}>695 <div class="peach-field-card">696 <span class="peach-label">CVC</span>697 <div class="peach-field-value-row">698 <span class="peach-field-value">699 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>700 <span>{entry().cvc}</span>701 </Show>702 </span>703 <div class="peach-field-actions">704 <button705 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"706 onClick={() => setShowCardCvc(!showCardCvc())}707 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}708 >709 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>710 <EyeClosedGlyph />711 </Show>712 </button>713 <button714 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}715 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}716 aria-label="Copy CVC"717 >718 <CopyGlyph /><span class="peach-copy-sheen" />719 </button>720 </div>721 </div>722 </div>723 </Show>724 </section>725 </Show>726727 {}728 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>729 <section class="peach-fields" style="position:relative;z-index:1;">730731 {}732 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>733 <span class="peach-label">username</span>734 <Show735 when={editingField() === "username"}736 fallback={737 <div class="peach-field-value-row">738 <span739 class="peach-field-value peach-inline-clickable"740 title="Click to edit"741 onClick={() => startEdit("username", entry().username ?? "")}742 >743 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}744 </span>745 <Show when={entry().username}>746 <button747 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}748 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}749 aria-label="Copy username"750 >751 <CopyGlyph /><span class="peach-copy-sheen" />752 </button>753 </Show>754 </div>755 }756 >757 <div758 class="peach-inline-field-wrap"759 onFocusOut={(event) => handleEditorFocusOut("username", event)}760 >761 <input762 class="peach-inline-input"763 value={draft()}764 onInput={(e) => setDraft(e.currentTarget.value)}765 onKeyDown={onEditKeyDown}766 ref={autoFocus}767 disabled={saving()}768 autocomplete="off"769 />770 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />771 </div>772 </Show>773 </div>774775 {}776 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>777 <span class="peach-label">password</span>778 <Show779 when={editingField() === "password"}780 fallback={781 <div class="peach-field-value-row">782 <span783 class="peach-password-value"784 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}785 style="cursor:pointer;"786 title="Click to edit password"787 onClick={() => startEdit("password", entry().password ?? "")}788 >789 <Show790 when={showPassword()}791 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}792 >793 <span class="peach-password-text">{entry().password ?? ""}</span>794 </Show>795 </span>796 <div class="peach-field-actions">797 <button798 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"799 onClick={() => setShowPassword(!showPassword())}800 aria-label="Reveal password"801 >802 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>803 <EyeClosedGlyph />804 </Show>805 </button>806 <button807 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}808 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}809 aria-label="Copy password"810 >811 <CopyGlyph /><span class="peach-copy-sheen" />812 </button>813 </div>814 </div>815 }816 >817 {}818 <div819 class="peach-inline-field-wrap"820 onFocusOut={(event) => handleEditorFocusOut("password", event)}821 >822 <div class="peach-password-edit-row">823 <input824 class="peach-inline-input"825 type={showPassword() ? "text" : "password"}826 placeholder="paste or type a password"827 value={draft()}828 onInput={(e) => setDraft(e.currentTarget.value)}829 onKeyDown={(e) => {830 if (e.key === "Enter") { e.preventDefault(); void saveField(); }831 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }832 }}833 ref={autoFocus}834 disabled={saving()}835 autocomplete="new-password"836 />837 <button838 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"839 type="button"840 onClick={() => setShowPassword(!showPassword())}841 aria-label={showPassword() ? "Hide" : "Show"}842 >843 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>844 <EyeClosedGlyph />845 </Show>846 </button>847 </div>848 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />849 <button850 class="peach-generate-toggle-link"851 type="button"852 onClick={() => setShowGenerator(!showGenerator())}853 >854 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">855 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />856 <path d="M9.5 2.5H11V1" />857 </svg>858 {showGenerator() ? "hide generator" : "generate"}859 </button>860 <Show when={showGenerator()}>861 <GeneratorDrawer862 open={true}863 compact={true}864 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}865 />866 </Show>867 </div>868 </Show>869 </div>870871 </section>872 </Show>873874 {}875 <Show when={entry().type !== "note" && entry().type !== "card"}>876 <Show877 when={entry().notes || editingField() === "notes"}878 fallback={879 <button880 class="peach-add-field-link peach-entry-add-link"881 onClick={() => startEdit("notes", "")}882 >883 + notes884 </button>885 }886 >887 <div style="position:relative;z-index:1;margin-top:6px;">888 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>889 <Show890 when={editingField() === "notes"}891 fallback={892 <div893 class="peach-note-content peach-inline-clickable"894 style="cursor:text;white-space:pre-wrap;"895 onClick={() => startEdit("notes", entry().notes ?? "")}896 >897 {entry().notes}898 </div>899 }900 >901 <div902 class="peach-inline-field-wrap"903 onFocusOut={(event) => handleEditorFocusOut("notes", event)}904 >905 <textarea906 class="peach-inline-textarea"907 value={draft()}908 onInput={(e) => setDraft(e.currentTarget.value)}909 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}910 ref={(el) => requestAnimationFrame(() => el?.focus())}911 rows={4}912 disabled={saving()}913 />914 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />915 </div>916 </Show>917 </div>918 </Show>919 </Show>920921 {}922 <Show923 when={editingTags()}924 fallback={925 <Show when={!entry().tags || entry().tags!.length === 0}>926 <button927 class="peach-add-field-link peach-entry-add-link"928 onClick={startEditTags}929 >930 + tags931 </button>932 </Show>933 }934 >935 <div936 class="peach-inline-tag-editor"937 onFocusOut={(e) => {938 const next = e.relatedTarget;939 if (next instanceof Node && e.currentTarget.contains(next)) return;940 void saveTags();941 }}942 >943 <span class="peach-label">tags</span>944 <div class="peach-tag-input-wrap">945 <For each={tagsDraft()}>946 {(tag) => (947 <span class="peach-tag-chip">948 {tag}949 <button950 class="peach-tag-chip-remove"951 onMouseDown={(e) => e.preventDefault()}952 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}953 aria-label={`Remove ${tag}`}954 >×</button>955 </span>956 )}957 </For>958 <input959 class="peach-tag-input"960 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}961 value={tagInput()}962 onInput={(e) => setTagInput(e.currentTarget.value)}963 onKeyDown={(e) => {964 if (e.key === "Enter" || e.key === ",") {965 e.preventDefault();966 addTag(tagInput());967 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {968 setTagsDraft(tagsDraft().slice(0, -1));969 } else if (e.key === "Escape") {970 e.preventDefault();971 setEditingTags(false);972 setTagInput("");973 }974 }}975 ref={(el) => requestAnimationFrame(() => el?.focus())}976 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"977 />978 </div>979 </div>980 </Show>981982 {}983 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>984 <Show985 when={totpState() || editingField() === "totp"}986 fallback={987 <button988 class="peach-add-field-link peach-entry-add-link"989 onClick={() => startEdit("totp", "")}990 >991 + 2FA992 </button>993 }994 >995 <section class="peach-totp-band">996 <Show997 when={editingField() === "totp"}998 fallback={999 <Show when={totpState()}>1000 {(state) => (1001 <>1002 <div1003 class="peach-totp-main"1004 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1005 onDblClick={() => startEdit("totp", entry().totp ?? "")}1006 title="Click to copy · double-click to edit"1007 >1008 <span class="peach-label">2-factor</span>1009 <div class="peach-totp-code-row">1010 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1011 <span class="peach-totp-code-text">1012 {state().code.slice(0, 3)} {state().code.slice(3)}1013 </span>1014 </p>1015 </div>1016 </div>1017 <div class="peach-totp-meta">1018 <span class="peach-label">expires</span>1019 <div class="peach-totp-countdown">{state().remaining}s</div>1020 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1021 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1022 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1023 stroke-dasharray="106.8"1024 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1025 />1026 </svg>1027 </div>1028 </>1029 )}1030 </Show>1031 }1032 >1033 <div1034 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1035 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1036 >1037 <span class="peach-label">2-factor</span>1038 <input1039 class="peach-inline-input"1040 value={draft()}1041 onInput={(e) => setDraft(e.currentTarget.value)}1042 onKeyDown={onEditKeyDown}1043 ref={autoFocus}1044 disabled={saving()}1045 placeholder="otpauth://... or base32 secret"1046 autocomplete="off"1047 />1048 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1049 </div>1050 </Show>1051 </section>1052 </Show>1053 </Show>10541055 {}1056 <Show when={entry().passkey}>1057 {(passkey) => {1058 const currentBrowser = detectBrowser();1059 const crossBrowser =1060 passkey().browser &&1061 currentBrowser !== "unknown" &&1062 passkey().browser !== currentBrowser;1063 const algorithmLabel =1064 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1065 const formatDate = (ts?: number) =>1066 ts ? new Date(ts).toLocaleDateString() : "Never";1067 return (1068 <section1069 class="peach-fields peach-passkey-section"1070 style="position:relative;z-index:1;margin-top:6px;"1071 >1072 <div class="peach-field-card">1073 <span class="peach-label">passkey · {passkey().rpId}</span>1074 <div class="peach-field-value-row">1075 <span class="peach-field-value">{passkey().userName}</span>1076 <span class="peach-passkey-badge">passkey</span>1077 </div>1078 </div>10791080 <div class="peach-field-card">1081 <span class="peach-label">algorithm</span>1082 <div class="peach-field-value-row">1083 <span class="peach-field-value">{algorithmLabel}</span>1084 </div>1085 </div>10861087 <div class="peach-field-card">1088 <span class="peach-label">activity</span>1089 <div class="peach-field-value-row peach-passkey-activity">1090 <span class="peach-field-value">1091 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1092 </span>1093 <span class="peach-passkey-meta">1094 created {formatDate(passkey().createdAt)}1095 </span>1096 <span class="peach-passkey-meta">1097 last used {formatDate(passkey().lastUsedAt)}1098 </span>1099 </div>1100 </div>11011102 <Show when={crossBrowser}>1103 <div class="peach-info-notice peach-info-notice--warn">1104 {`This passkey was created on ${1105 passkey().browser === "chrome" ? "Chrome" : "Firefox"1106 }. To use it on ${1107 currentBrowser === "chrome" ? "Chrome" : "Firefox"1108 }, re-register it on this device.`}1109 </div>1110 </Show>11111112 <div class="peach-info-notice">1113 {passkey().browser === "firefox"1114 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1115 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1116 </div>11171118 <button1119 class="peach-add-field-link peach-entry-add-link"1120 style="color:rgba(220,38,38,0.7);"1121 onClick={async () => {1122 const id = entry().id;1123 await sendMessage({1124 type: "UPDATE_ENTRY",1125 payload: { id, patch: { passkey: undefined } },1126 });1127 await props.onFieldSaved();1128 }}1129 >1130 − remove passkey1131 </button>1132 </section>1133 );1134 }}1135 </Show>11361137 {}1138 <div style="height:4px;" />1139 </>1140 )}1141 </Show>1142 </div>11431144 </>1145 );1146}1147114811491150function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1151 return (1152 <div class="peach-inline-actions">1153 <button1154 class="peach-inline-save-btn"1155 onClick={props.onSave}1156 disabled={props.saving}1157 aria-label="Save"1158 title="Save (Enter)"1159 >1160 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1161 <path d="M1 4.5L4 7.5L10 1.5" />1162 </svg>1163 </button>1164 <button1165 class="peach-inline-cancel-btn"1166 onClick={props.onCancel}1167 disabled={props.saving}1168 aria-label="Cancel"1169 title="Cancel (Esc)"1170 >1171 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1172 <path d="M1 1L8 8M8 1L1 8" />1173 </svg>1174 </button>1175 </div>1176 );1177}11781179function EmptyVaultState() {1180 return (1181 <>1182 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1183 <div class="peach-entry-name">1184 <span class="peach-entry-name-text">1185 <span class="peach-entry-name-char" style="--char-index:0">e</span>1186 <span class="peach-entry-name-char" style="--char-index:1">m</span>1187 <span class="peach-entry-name-char" style="--char-index:2">p</span>1188 <span class="peach-entry-name-char" style="--char-index:3">t</span>1189 <span class="peach-entry-name-char" style="--char-index:4">y</span>1190 </span>1191 </div>1192 </div>1193 <div class="peach-empty-copy">1194 <span class="peach-label">vault</span>1195 <p>Your vault is ready for its first entry.</p>1196 </div>1197 </>1198 );1199}12001201function CopyGlyph() {1202 return (1203 <span class="peach-copy-icon">1204 <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">1205 <rect x="4" y="4" width="8" height="9" rx="1.5" />1206 <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" />1207 </svg>1208 </span>1209 );1210}12111212function EyeClosedGlyph() {1213 return (1214 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1215 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1216 <path d="M3.5 5.5L2.5 7.5" />1217 <path d="M7 6.5V8.5" />1218 <path d="M10.5 5.5L11.5 7.5" />1219 </svg>1220 );1221}12221223function EyeOpenGlyph() {1224 return (1225 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1226 <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" />1227 <circle cx="7" cy="5" r="1.75" />1228 </svg>1229 );1230}12311232function ChevronGlyph(props: { open: boolean }) {1233 return (1234 <svg1235 width="8"1236 height="8"1237 viewBox="0 0 8 8"1238 fill="none"1239 stroke="currentColor"1240 stroke-width="1.3"1241 stroke-linecap="round"1242 stroke-linejoin="round"1243 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1244 >1245 <path d="M2 1L6 4L2 7" />1246 </svg>1247 );1248}12491250function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1251 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1252 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1253 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1254 const [testResult, setTestResult] = createSignal<string | null>(null);1255 const [testing, setTesting] = createSignal(false);12561257 async function save() {1258 const url = webhookUrl().trim();1259 const nextLabel = label().trim() || undefined;1260 const canary = enabled() && url1261 ? {1262 webhookUrl: url,1263 label: nextLabel,1264 lastPinged: props.entry.canary?.lastPinged,1265 pingCount: props.entry.canary?.pingCount,1266 }1267 : undefined;1268 await sendMessage({1269 type: "UPDATE_ENTRY",1270 payload: { id: props.entry.id, patch: { canary } },1271 });1272 await props.onSaved();1273 }12741275 async function testPing() {1276 const url = webhookUrl().trim();1277 if (!url) return;1278 setTesting(true);1279 setTestResult(null);1280 const res = await sendMessage({1281 type: "FIRE_CANARY_PING",1282 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1283 }) as { ok: boolean; error?: string };1284 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1285 setTesting(false);1286 }12871288 return (1289 <div class="peach-canary-section">1290 <div class="peach-field-value-row">1291 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1292 <button1293 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1294 onClick={() => { setEnabled(!enabled()); void save(); }}1295 aria-label="Toggle canary"1296 >1297 <span class="peach-toggle-knob" />1298 </button>1299 </div>1300 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1301 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1302 </p>1303 <Show when={enabled()}>1304 <div class="peach-inline-field-wrap" style="margin-top:8px;">1305 <input1306 class="peach-inline-input"1307 placeholder="https://hooks.zapier.com/..."1308 value={webhookUrl()}1309 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1310 onBlur={() => void save()}1311 />1312 <input1313 class="peach-inline-input"1314 placeholder="label (optional, e.g. AWS decoy)"1315 value={label()}1316 onInput={(e) => setLabel(e.currentTarget.value)}1317 onBlur={() => void save()}1318 />1319 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1320 <button1321 class="peach-btn peach-btn--ghost peach-btn--sm"1322 onClick={() => void testPing()}1323 disabled={testing() || !webhookUrl().trim()}1324 >1325 {testing() ? "sending..." : "test ping"}1326 </button>1327 <Show when={testResult()}>1328 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1329 </Show>1330 </div>1331 <Show when={props.entry.canary?.lastPinged}>1332 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1333 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1334 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1335 </p>1336 </Show>1337 </div>1338 </Show>1339 </div>1340 );1341}