1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";6import { sendMessage, triggerPipPulse, triggerRing } from "../App";7import { popupCopy } from "../copy";8import { GeneratorDrawer } from "../components/GeneratorDrawer";9import { NoteEditorInline, Toolbar } from "./NoteEditor";1011type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1213interface EntryProps {14 entry: VaultEntry | null;15 onDelete: () => void;16 17 onFieldSaved: () => void | Promise<void>;18 onEscape?: () => void;19}2021export function EntryView(props: EntryProps) {22 const [editingField, setEditingField] = createSignal<EditableField>(null);23 const [draft, setDraft] = createSignal("");24 const [saving, setSaving] = createSignal(false);25 const [showPassword, setShowPassword] = createSignal(false);26 const [showGenerator, setShowGenerator] = createSignal(false);27 const [copied, setCopied] = createSignal<string | null>(null);28 29 30 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);31 const [showCardNumber, setShowCardNumber] = createSignal(false);32 const [showCardCvc, setShowCardCvc] = createSignal(false);33 const [totpTick, setTotpTick] = createSignal(0);34 const [confirmDelete, setConfirmDelete] = createSignal(false);35 const [totpHot, setTotpHot] = createSignal(false);36 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);37 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);38 let lastSavedNote: string | null = null;3940 function noteExec(cmd: string, val?: string) {41 document.execCommand(cmd, false, val);42 noteEditorEl()?.focus();43 }4445 async function saveNoteInline(html: string) {46 const entry = props.entry;47 if (!entry) return;48 const currentStored = entry.notes ?? "";49 if (html === currentStored) return;50 if (lastSavedNote !== null && html === lastSavedNote) return;51 lastSavedNote = html;52 setSaving(true);53 try {54 await sendMessage({55 type: "UPDATE_ENTRY",56 payload: { id: entry.id, patch: { notes: html } },57 });58 triggerRing();59 await props.onFieldSaved();60 } catch {61 lastSavedNote = null;62 } finally {63 setSaving(false);64 }65 }6667 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);68 onCleanup(() => window.clearInterval(interval));6970 const displayDomain = createMemo(() =>71 props.entry?.url ? getDisplayDomain(props.entry.url) : ""72 );7374 createEffect(() => {75 const domain = displayDomain();76 setFaviconSrc(domain ? faviconUrl(domain) : null);77 });7879 const totpState = createMemo(() => {80 void totpTick();81 if (!props.entry?.totp) return null;82 return generateTOTP(props.entry.totp);83 });8485 let prevTotpCode: string | undefined;86 createEffect(() => {87 const state = totpState();88 if (!state) { prevTotpCode = undefined; return; }89 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {90 setTotpHot(true);91 window.setTimeout(() => setTotpHot(false), 3000);92 }93 prevTotpCode = state.code;94 });9596 const totpProgress = createMemo(() => {97 const state = totpState();98 if (!state) return 0;99 return ((state.remaining - 1) / 29) * 100;100 });101102 createEffect(() => {103 const shell = document.getElementById("hd-shell");104 if (!shell) return;105106 const state = totpState();107 if (!state) {108 delete shell.dataset.totpActive;109 shell.style.removeProperty("--peach-totp-progress");110 return;111 }112113 shell.dataset.totpActive = "true";114 shell.style.setProperty(115 "--peach-totp-progress",116 `${Math.max(0, totpProgress())}%`117 );118119 onCleanup(() => {120 delete shell.dataset.totpActive;121 shell.style.removeProperty("--peach-totp-progress");122 });123 });124125 const titleSizeClass = createMemo(() => {126 const length = props.entry?.title.length ?? 0;127 if (length >= 28) return "peach-entry-name--xxl";128 if (length >= 20) return "peach-entry-name--xl";129 if (length >= 14) return "peach-entry-name--lg";130 return "";131 });132 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);133134 135136 function startEdit(field: EditableField, value: string) {137 setEditingField(field);138 setDraft(value);139 setShowGenerator(false);140 setConfirmDelete(false);141 if (field === "url") {142 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);143 }144 }145146 async function saveField() {147 const field = editingField();148 const entry = props.entry;149 if (!field || !entry) return;150 151 let value = field === "password" ? draft() : draft().trim();152 if (field === "totp" && value.startsWith("otpauth://")) {153 const parsed = parseOtpauthURI(value);154 value = parsed?.secret ?? value;155 }156 if (field === "password" && entry.type !== "note" && !value) {157 return;158 }159 setSaving(true);160 try {161 const patch: Partial<VaultEntry> = { [field]: value };162 if (field === "url") {163 164 patch.additionalUrls = additionalUrlsDraft();165 }166 await sendMessage({167 type: "UPDATE_ENTRY",168 payload: { id: entry.id, patch },169 });170 triggerRing();171 setEditingField(null);172 setShowGenerator(false);173 await props.onFieldSaved();174 } catch {175 176 } finally {177 setSaving(false);178 }179 }180181 function cancelEdit() {182 setEditingField(null);183 setDraft("");184 setShowGenerator(false);185 }186187 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {188 switch (field) {189 case "title":190 return entry.title;191 case "url":192 return entry.url ?? "";193 case "username":194 return entry.username ?? "";195 case "password":196 return entry.password ?? "";197 case "notes":198 return entry.notes ?? "";199 case "totp":200 return entry.totp ?? "";201 }202 }203204 function handleEditorFocusOut(205 field: Exclude<EditableField, null>,206 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }207 ) {208 const nextTarget = event.relatedTarget;209 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;210211 const entry = props.entry;212 if (!entry) return;213214 const currentValue = fieldValue(entry, field);215 const nextValue = field === "password" ? draft() : draft().trim();216 if (nextValue === currentValue) {217 cancelEdit();218 }219 }220221 async function savePassword(password: string) {222 const entry = props.entry;223 if (!entry) return;224 setSaving(true);225 try {226 await sendMessage({227 type: "UPDATE_ENTRY",228 payload: { id: entry.id, patch: { password } },229 });230 triggerRing();231 setShowGenerator(false);232 await props.onFieldSaved();233 } catch {234 235 } finally {236 setSaving(false);237 }238 }239240 241242 async function copyField(text: string, label: string, button?: HTMLButtonElement) {243 const ok = await popupCopy(text, label);244 if (ok) {245 const entry = props.entry;246 if (entry?.canary?.webhookUrl) {247 const trigger =248 label === "password"249 ? "password_copied"250 : label === "username"251 ? "username_copied"252 : undefined;253 if (trigger) {254 void sendMessage({255 type: "FIRE_CANARY_PING",256 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },257 });258 }259 }260 triggerRing();261 triggerPipPulse();262 setCopied(null);263 requestAnimationFrame(() => setCopied(label));264 if (button) {265 button.classList.remove("is-copied", "is-retreat");266 void button.offsetWidth;267 const drain = document.createElement("span");268 drain.className = "peach-copy-drain";269 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";270 drain.appendChild(bar);271 button.appendChild(drain);272 window.setTimeout(() => {273 button.classList.remove("is-copied");274 button.classList.add("is-retreat");275 drain.remove();276 window.setTimeout(() => button.classList.remove("is-retreat"), 160);277 }, 1800);278 }279 window.setTimeout(() => setCopied(null), 1800);280 }281 }282283 284285 createEffect(() => {286 if (!props.entry) return;287 const handler = (e: KeyboardEvent) => {288 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;289 if (inInput) return;290 switch (e.key) {291 case "c": e.preventDefault();292 if (props.entry?.password) void copyField(props.entry.password, "password"); break;293 case "u": e.preventDefault();294 if (props.entry?.username) void copyField(props.entry.username, "username"); break;295 case "Escape": e.preventDefault(); props.onEscape?.(); break;296 }297 };298 window.addEventListener("keydown", handler);299 onCleanup(() => window.removeEventListener("keydown", handler));300 });301302 303304 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {305 requestAnimationFrame(() => { el.focus(); el.select?.(); });306 }307308 function onEditKeyDown(e: KeyboardEvent) {309 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {310 e.preventDefault();311 void saveField();312 }313 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }314 }315316 return (317 <>318 <div319 class="peach-ghost"320 aria-hidden="true"321 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;"322 >323 {props.entry?.title || "empty"}324 </div>325326 <div class="peach-stage" style="position:relative;">327 <Show when={props.entry} fallback={<EmptyVaultState />}>328 {(entry) => (329 <>330 {}331 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>332 <div class="peach-breach-badge">333 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">334 <path d="M6 1L11 10H1L6 1z" />335 <line x1="6" y1="5" x2="6" y2="7.5" />336 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />337 </svg>338 <span style="flex:1">found in a known data breach</span>339 <button340 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"341 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}342 >343 Change password344 </button>345 </div>346 </Show>347348 {}349 <div class="peach-entry-header">350 <div class="peach-entry-header-name">351 <Show352 when={editingField() === "title"}353 fallback={354 <button355 class="peach-entry-name-button"356 title="Click to edit title"357 onClick={() => startEdit("title", entry().title)}358 >359 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>360 <span class="peach-entry-name-text">361 <Show362 when={useAnimatedTitle()}363 fallback={entry().title}364 >365 <For each={entry().title.split("")}>366 {(ch, i) => (367 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>368 {ch === " " ? "\u00A0" : ch}369 </span>370 )}371 </For>372 </Show>373 </span>374 </h1>375 </button>376 }377 >378 <div379 class="peach-inline-title-wrap"380 onFocusOut={(event) => handleEditorFocusOut("title", event)}381 >382 <input383 class="peach-inline-title-input"384 value={draft()}385 onInput={(e) => setDraft(e.currentTarget.value)}386 onKeyDown={onEditKeyDown}387 ref={autoFocus}388 disabled={saving()}389 />390 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />391 </div>392 </Show>393 </div>394395 {}396 <Show when={editingField() !== "title"}>397 <Show398 when={confirmDelete()}399 fallback={400 <button401 class="peach-entry-trash-btn"402 onClick={() => setConfirmDelete(true)}403 aria-label="Move to trash"404 title="Delete entry"405 >406 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">407 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />408 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />409 </svg>410 </button>411 }412 >413 <div class="peach-entry-trash-confirm">414 <span class="peach-entry-trash-confirm-label">trash?</span>415 <button416 class="peach-btn peach-btn--sm peach-btn--danger"417 style="font-size:10px;padding:2px 8px;min-height:22px;"418 onClick={() => { setConfirmDelete(false); props.onDelete(); }}419 >420 yes421 </button>422 <button423 class="peach-btn peach-btn--ghost peach-btn--sm"424 style="font-size:10px;padding:2px 8px;min-height:22px;"425 onClick={() => setConfirmDelete(false)}426 >427 no428 </button>429 </div>430 </Show>431 </Show>432 </div>433434 {}435 <Show when={entry().type !== "note" && entry().type !== "card"}>436 <div class="peach-domain" style="position:relative;z-index:1;">437 <Show438 when={editingField() === "url"}439 fallback={440 <div441 class="peach-domain-row peach-inline-clickable"442 title="Click to edit URL"443 onClick={() => startEdit("url", entry().url ?? "")}444 >445 <Show when={faviconSrc()}>446 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />447 </Show>448 <span class="peach-domain-value">449 {displayDomain() || (entry().url ? entry().url : "add a website url")}450 </span>451 <span class="peach-inline-edit-hint">edit</span>452 </div>453 }454 >455 <div>456 <div457 class="peach-inline-field-wrap"458 onFocusOut={(event) => handleEditorFocusOut("url", event)}459 >460 <input461 class="peach-inline-input"462 type="url"463 placeholder="https://"464 value={draft()}465 onInput={(e) => setDraft(e.currentTarget.value)}466 onKeyDown={onEditKeyDown}467 ref={autoFocus}468 disabled={saving()}469 />470 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />471 </div>472 <div class="peach-additional-urls">473 <Show when={additionalUrlsDraft().length > 0}>474 <span class="peach-label peach-additional-urls-label">also matches</span>475 </Show>476 <For each={additionalUrlsDraft()}>477 {(value, i) => (478 <div class="peach-additional-url-row">479 <input480 class="peach-inline-input"481 type="url"482 placeholder="https://"483 value={value}484 onInput={(e) => setAdditionalUrlsDraft((arr) => {485 const next = [...arr];486 next[i()] = e.currentTarget.value;487 return next;488 })}489 disabled={saving()}490 />491 <button492 class="peach-additional-url-remove"493 type="button"494 aria-label="Remove URL"495 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}496 disabled={saving()}497 >×</button>498 </div>499 )}500 </For>501 <button502 class="peach-add-field-link peach-additional-urls-add"503 type="button"504 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}505 disabled={saving()}506 >507 + add another url508 </button>509 </div>510 </div>511 </Show>512 </div>513 </Show>514515 {}516 <Show when={entry().tags && entry().tags!.length > 0}>517 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">518 <For each={entry().tags!}>519 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}520 </For>521 </div>522 </Show>523524 {}525 <Show when={entry().type === "note"}>526 <div class="peach-entry-note-toolbar-row">527 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />528 </div>529 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">530 <NoteEditorInline531 entryKey={entry().id}532 initialContent={entry().notes ?? ""}533 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}534 onBlur={saveNoteInline}535 autoFocus={false}536 />537 </section>538 </Show>539540 {}541 <Show when={entry().type === "card"}>542 <section class="peach-fields" style="position:relative;z-index:1;">543 {}544 <Show when={entry().last4}>545 <div class="peach-field-card" style="padding-bottom:4px;">546 <span class="peach-label">547 <Show when={entry().network && entry().network !== "unknown"}>548 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>549 </Show>550 {entry().cardNickname || `•••• ${entry().last4}`}551 </span>552 </div>553 </Show>554555 {}556 <div class="peach-field-card">557 <span class="peach-label">cardholder name</span>558 <div class="peach-field-value-row">559 <span class="peach-field-value">{entry().cardholderName || "—"}</span>560 <Show when={entry().cardholderName}>561 <button562 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}563 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}564 aria-label="Copy cardholder name"565 >566 <CopyGlyph /><span class="peach-copy-sheen" />567 </button>568 </Show>569 </div>570 </div>571572 {}573 <div class="peach-field-card">574 <span class="peach-label">card number</span>575 <div class="peach-field-value-row">576 <span class={`peach-field-value peach-card-number`}>577 <Show when={showCardNumber()} fallback={578 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>579 }>580 <span>{entry().cardNumber ?? ""}</span>581 </Show>582 </span>583 <div class="peach-field-actions">584 <button585 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"586 onClick={() => setShowCardNumber(!showCardNumber())}587 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}588 >589 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>590 <EyeClosedGlyph />591 </Show>592 </button>593 <Show when={entry().cardNumber}>594 <button595 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}596 onClick={(e) => {597 const cardNum = entry().cardNumber!;598 if (entry().canary?.webhookUrl) {599 void sendMessage({600 type: "FIRE_CANARY_PING",601 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },602 });603 }604 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);605 }}606 aria-label="Copy card number"607 >608 <CopyGlyph /><span class="peach-copy-sheen" />609 </button>610 </Show>611 </div>612 </div>613 </div>614615 {}616 <Show when={entry().expMonth || entry().expYear}>617 <div class="peach-field-card">618 <span class="peach-label">expiry</span>619 <div class="peach-field-value-row">620 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>621 <button622 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}623 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}624 aria-label="Copy expiry"625 >626 <CopyGlyph /><span class="peach-copy-sheen" />627 </button>628 </div>629 </div>630 </Show>631632 {}633 <Show when={entry().cvc}>634 <div class="peach-field-card">635 <span class="peach-label">CVC</span>636 <div class="peach-field-value-row">637 <span class="peach-field-value">638 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>639 <span>{entry().cvc}</span>640 </Show>641 </span>642 <div class="peach-field-actions">643 <button644 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"645 onClick={() => setShowCardCvc(!showCardCvc())}646 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}647 >648 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>649 <EyeClosedGlyph />650 </Show>651 </button>652 <button653 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}654 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}655 aria-label="Copy CVC"656 >657 <CopyGlyph /><span class="peach-copy-sheen" />658 </button>659 </div>660 </div>661 </div>662 </Show>663 </section>664 </Show>665666 {}667 <Show when={entry().type !== "note" && entry().type !== "card"}>668 <section class="peach-fields" style="position:relative;z-index:1;">669670 {}671 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>672 <span class="peach-label">username</span>673 <Show674 when={editingField() === "username"}675 fallback={676 <div class="peach-field-value-row">677 <span678 class="peach-field-value peach-inline-clickable"679 title="Click to edit"680 onClick={() => startEdit("username", entry().username ?? "")}681 >682 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}683 </span>684 <Show when={entry().username}>685 <button686 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}687 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}688 aria-label="Copy username"689 >690 <CopyGlyph /><span class="peach-copy-sheen" />691 </button>692 </Show>693 </div>694 }695 >696 <div697 class="peach-inline-field-wrap"698 onFocusOut={(event) => handleEditorFocusOut("username", event)}699 >700 <input701 class="peach-inline-input"702 value={draft()}703 onInput={(e) => setDraft(e.currentTarget.value)}704 onKeyDown={onEditKeyDown}705 ref={autoFocus}706 disabled={saving()}707 autocomplete="off"708 />709 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />710 </div>711 </Show>712 </div>713714 {}715 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>716 <span class="peach-label">password</span>717 <Show718 when={editingField() === "password"}719 fallback={720 <div class="peach-field-value-row">721 <span722 class="peach-password-value"723 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}724 style="cursor:pointer;"725 title="Click to edit password"726 onClick={() => startEdit("password", entry().password ?? "")}727 >728 <Show729 when={showPassword()}730 fallback={<span class="peach-password-mask">...............</span>}731 >732 <span class="peach-password-text">{entry().password ?? ""}</span>733 </Show>734 </span>735 <div class="peach-field-actions">736 <button737 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"738 onClick={() => setShowPassword(!showPassword())}739 aria-label="Reveal password"740 >741 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>742 <EyeClosedGlyph />743 </Show>744 </button>745 <button746 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}747 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}748 aria-label="Copy password"749 >750 <CopyGlyph /><span class="peach-copy-sheen" />751 </button>752 </div>753 </div>754 }755 >756 {}757 <div758 class="peach-inline-field-wrap"759 onFocusOut={(event) => handleEditorFocusOut("password", event)}760 >761 <div class="peach-password-edit-row">762 <input763 class="peach-inline-input"764 type={showPassword() ? "text" : "password"}765 placeholder="paste or type a password"766 value={draft()}767 onInput={(e) => setDraft(e.currentTarget.value)}768 onKeyDown={(e) => {769 if (e.key === "Enter") { e.preventDefault(); void saveField(); }770 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }771 }}772 ref={autoFocus}773 disabled={saving()}774 autocomplete="new-password"775 />776 <button777 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"778 type="button"779 onClick={() => setShowPassword(!showPassword())}780 aria-label={showPassword() ? "Hide" : "Show"}781 >782 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>783 <EyeClosedGlyph />784 </Show>785 </button>786 </div>787 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />788 <button789 class="peach-generate-toggle-link"790 type="button"791 onClick={() => setShowGenerator(!showGenerator())}792 >793 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">794 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />795 <path d="M9.5 2.5H11V1" />796 </svg>797 {showGenerator() ? "hide generator" : "generate"}798 </button>799 <Show when={showGenerator()}>800 <GeneratorDrawer801 open={true}802 compact={true}803 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}804 />805 </Show>806 </div>807 </Show>808 </div>809810 </section>811 </Show>812813 {}814 <Show when={entry().type !== "note" && entry().type !== "card"}>815 <Show816 when={entry().notes || editingField() === "notes"}817 fallback={818 <button819 class="peach-add-field-link peach-entry-add-link"820 onClick={() => startEdit("notes", "")}821 >822 + add notes823 </button>824 }825 >826 <div style="position:relative;z-index:1;margin-top:6px;">827 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>828 <Show829 when={editingField() === "notes"}830 fallback={831 <div832 class="peach-note-content peach-inline-clickable"833 style="cursor:text;white-space:pre-wrap;"834 onClick={() => startEdit("notes", entry().notes ?? "")}835 >836 {entry().notes}837 </div>838 }839 >840 <div841 class="peach-inline-field-wrap"842 onFocusOut={(event) => handleEditorFocusOut("notes", event)}843 >844 <textarea845 class="peach-inline-textarea"846 value={draft()}847 onInput={(e) => setDraft(e.currentTarget.value)}848 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}849 ref={(el) => requestAnimationFrame(() => el?.focus())}850 rows={4}851 disabled={saving()}852 />853 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />854 </div>855 </Show>856 </div>857 </Show>858 </Show>859860 {}861 <Show when={entry().type === "login"}>862 <Show863 when={totpState() || editingField() === "totp"}864 fallback={865 <button866 class="peach-add-field-link peach-entry-add-link"867 onClick={() => startEdit("totp", "")}868 >869 + add 2FA870 </button>871 }872 >873 <section class="peach-totp-band">874 <Show875 when={editingField() === "totp"}876 fallback={877 <Show when={totpState()}>878 {(state) => (879 <>880 <div881 class="peach-totp-main"882 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}883 onDblClick={() => startEdit("totp", entry().totp ?? "")}884 title="Click to copy · double-click to edit"885 >886 <span class="peach-label">2-factor</span>887 <div class="peach-totp-code-row">888 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>889 <span class="peach-totp-code-text">890 {state().code.slice(0, 3)} {state().code.slice(3)}891 </span>892 </p>893 </div>894 </div>895 <div class="peach-totp-meta">896 <span class="peach-label">expires</span>897 <div class="peach-totp-countdown">{state().remaining}s</div>898 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">899 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />900 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"901 stroke-dasharray="106.8"902 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}903 />904 </svg>905 </div>906 </>907 )}908 </Show>909 }910 >911 <div912 class="peach-inline-field-wrap peach-inline-field-wrap--totp"913 onFocusOut={(event) => handleEditorFocusOut("totp", event)}914 >915 <span class="peach-label">2-factor</span>916 <input917 class="peach-inline-input"918 value={draft()}919 onInput={(e) => setDraft(e.currentTarget.value)}920 onKeyDown={onEditKeyDown}921 ref={autoFocus}922 disabled={saving()}923 placeholder="otpauth://... or base32 secret"924 autocomplete="off"925 />926 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />927 </div>928 </Show>929 </section>930 </Show>931 </Show>932933 {}934 <div style="height:4px;" />935 </>936 )}937 </Show>938 </div>939940 </>941 );942}943944945946function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {947 return (948 <div class="peach-inline-actions">949 <button950 class="peach-inline-save-btn"951 onClick={props.onSave}952 disabled={props.saving}953 aria-label="Save"954 title="Save (Enter)"955 >956 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">957 <path d="M1 4.5L4 7.5L10 1.5" />958 </svg>959 </button>960 <button961 class="peach-inline-cancel-btn"962 onClick={props.onCancel}963 disabled={props.saving}964 aria-label="Cancel"965 title="Cancel (Esc)"966 >967 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">968 <path d="M1 1L8 8M8 1L1 8" />969 </svg>970 </button>971 </div>972 );973}974975function EmptyVaultState() {976 return (977 <>978 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">979 <div class="peach-entry-name">980 <span class="peach-entry-name-text">981 <span class="peach-entry-name-char" style="--char-index:0">e</span>982 <span class="peach-entry-name-char" style="--char-index:1">m</span>983 <span class="peach-entry-name-char" style="--char-index:2">p</span>984 <span class="peach-entry-name-char" style="--char-index:3">t</span>985 <span class="peach-entry-name-char" style="--char-index:4">y</span>986 </span>987 </div>988 </div>989 <div class="peach-empty-copy">990 <span class="peach-label">vault</span>991 <p>Your vault is ready for its first entry.</p>992 </div>993 </>994 );995}996997function CopyGlyph() {998 return (999 <span class="peach-copy-icon">1000 <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">1001 <rect x="4" y="4" width="8" height="9" rx="1.5" />1002 <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" />1003 </svg>1004 </span>1005 );1006}10071008function EyeClosedGlyph() {1009 return (1010 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1011 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1012 <path d="M3.5 5.5L2.5 7.5" />1013 <path d="M7 6.5V8.5" />1014 <path d="M10.5 5.5L11.5 7.5" />1015 </svg>1016 );1017}10181019function EyeOpenGlyph() {1020 return (1021 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1022 <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" />1023 <circle cx="7" cy="5" r="1.75" />1024 </svg>1025 );1026}10271028function ChevronGlyph(props: { open: boolean }) {1029 return (1030 <svg1031 width="8"1032 height="8"1033 viewBox="0 0 8 8"1034 fill="none"1035 stroke="currentColor"1036 stroke-width="1.3"1037 stroke-linecap="round"1038 stroke-linejoin="round"1039 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1040 >1041 <path d="M2 1L6 4L2 7" />1042 </svg>1043 );1044}10451046function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1047 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1048 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1049 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1050 const [testResult, setTestResult] = createSignal<string | null>(null);1051 const [testing, setTesting] = createSignal(false);10521053 async function save() {1054 const url = webhookUrl().trim();1055 const nextLabel = label().trim() || undefined;1056 const canary = enabled() && url1057 ? {1058 webhookUrl: url,1059 label: nextLabel,1060 lastPinged: props.entry.canary?.lastPinged,1061 pingCount: props.entry.canary?.pingCount,1062 }1063 : undefined;1064 await sendMessage({1065 type: "UPDATE_ENTRY",1066 payload: { id: props.entry.id, patch: { canary } },1067 });1068 await props.onSaved();1069 }10701071 async function testPing() {1072 const url = webhookUrl().trim();1073 if (!url) return;1074 setTesting(true);1075 setTestResult(null);1076 const res = await sendMessage({1077 type: "FIRE_CANARY_PING",1078 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1079 }) as { ok: boolean; error?: string };1080 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1081 setTesting(false);1082 }10831084 return (1085 <div class="peach-canary-section">1086 <div class="peach-field-value-row">1087 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1088 <button1089 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1090 onClick={() => { setEnabled(!enabled()); void save(); }}1091 aria-label="Toggle canary"1092 >1093 <span class="peach-toggle-knob" />1094 </button>1095 </div>1096 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1097 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1098 </p>1099 <Show when={enabled()}>1100 <div class="peach-inline-field-wrap" style="margin-top:8px;">1101 <input1102 class="peach-inline-input"1103 placeholder="https://hooks.zapier.com/..."1104 value={webhookUrl()}1105 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1106 onBlur={() => void save()}1107 />1108 <input1109 class="peach-inline-input"1110 placeholder="label (optional, e.g. AWS decoy)"1111 value={label()}1112 onInput={(e) => setLabel(e.currentTarget.value)}1113 onBlur={() => void save()}1114 />1115 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1116 <button1117 class="peach-btn peach-btn--ghost peach-btn--sm"1118 onClick={() => void testPing()}1119 disabled={testing() || !webhookUrl().trim()}1120 >1121 {testing() ? "sending..." : "test ping"}1122 </button>1123 <Show when={testResult()}>1124 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1125 </Show>1126 </div>1127 <Show when={props.entry.canary?.lastPinged}>1128 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1129 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1130 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1131 </p>1132 </Show>1133 </div>1134 </Show>1135 </div>1136 );1137}