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 const [showCardNumber, setShowCardNumber] = createSignal(false);29 const [showCardCvc, setShowCardCvc] = createSignal(false);30 const [totpTick, setTotpTick] = createSignal(0);31 const [confirmDelete, setConfirmDelete] = createSignal(false);32 const [totpHot, setTotpHot] = createSignal(false);33 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);34 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);35 let lastSavedNote: string | null = null;3637 function noteExec(cmd: string, val?: string) {38 document.execCommand(cmd, false, val);39 noteEditorEl()?.focus();40 }4142 async function saveNoteInline(html: string) {43 const entry = props.entry;44 if (!entry) return;45 const currentStored = entry.notes ?? "";46 if (html === currentStored) return;47 if (lastSavedNote !== null && html === lastSavedNote) return;48 lastSavedNote = html;49 setSaving(true);50 try {51 await sendMessage({52 type: "UPDATE_ENTRY",53 payload: { id: entry.id, patch: { notes: html } },54 });55 triggerRing();56 await props.onFieldSaved();57 } catch {58 lastSavedNote = null;59 } finally {60 setSaving(false);61 }62 }6364 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);65 onCleanup(() => window.clearInterval(interval));6667 const displayDomain = createMemo(() =>68 props.entry?.url ? getDisplayDomain(props.entry.url) : ""69 );7071 createEffect(() => {72 const domain = displayDomain();73 setFaviconSrc(domain ? faviconUrl(domain) : null);74 });7576 const totpState = createMemo(() => {77 void totpTick();78 if (!props.entry?.totp) return null;79 return generateTOTP(props.entry.totp);80 });8182 let prevTotpCode: string | undefined;83 createEffect(() => {84 const state = totpState();85 if (!state) { prevTotpCode = undefined; return; }86 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {87 setTotpHot(true);88 window.setTimeout(() => setTotpHot(false), 3000);89 }90 prevTotpCode = state.code;91 });9293 const totpProgress = createMemo(() => {94 const state = totpState();95 if (!state) return 0;96 return ((state.remaining - 1) / 29) * 100;97 });9899 createEffect(() => {100 const shell = document.getElementById("hd-shell");101 if (!shell) return;102103 const state = totpState();104 if (!state) {105 delete shell.dataset.totpActive;106 shell.style.removeProperty("--peach-totp-progress");107 return;108 }109110 shell.dataset.totpActive = "true";111 shell.style.setProperty(112 "--peach-totp-progress",113 `${Math.max(0, totpProgress())}%`114 );115116 onCleanup(() => {117 delete shell.dataset.totpActive;118 shell.style.removeProperty("--peach-totp-progress");119 });120 });121122 const titleSizeClass = createMemo(() => {123 const length = props.entry?.title.length ?? 0;124 if (length >= 28) return "peach-entry-name--xxl";125 if (length >= 20) return "peach-entry-name--xl";126 if (length >= 14) return "peach-entry-name--lg";127 return "";128 });129 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);130131 132133 function startEdit(field: EditableField, value: string) {134 setEditingField(field);135 setDraft(value);136 setShowGenerator(false);137 setConfirmDelete(false);138 }139140 async function saveField() {141 const field = editingField();142 const entry = props.entry;143 if (!field || !entry) return;144 145 let value = field === "password" ? draft() : draft().trim();146 if (field === "totp" && value.startsWith("otpauth://")) {147 const parsed = parseOtpauthURI(value);148 value = parsed?.secret ?? value;149 }150 if (field === "password" && entry.type !== "note" && !value) {151 return;152 }153 setSaving(true);154 try {155 await sendMessage({156 type: "UPDATE_ENTRY",157 payload: { id: entry.id, patch: { [field]: value } },158 });159 triggerRing();160 setEditingField(null);161 setShowGenerator(false);162 await props.onFieldSaved();163 } catch {164 165 } finally {166 setSaving(false);167 }168 }169170 function cancelEdit() {171 setEditingField(null);172 setDraft("");173 setShowGenerator(false);174 }175176 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {177 switch (field) {178 case "title":179 return entry.title;180 case "url":181 return entry.url ?? "";182 case "username":183 return entry.username ?? "";184 case "password":185 return entry.password ?? "";186 case "notes":187 return entry.notes ?? "";188 case "totp":189 return entry.totp ?? "";190 }191 }192193 function handleEditorFocusOut(194 field: Exclude<EditableField, null>,195 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }196 ) {197 const nextTarget = event.relatedTarget;198 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;199200 const entry = props.entry;201 if (!entry) return;202203 const currentValue = fieldValue(entry, field);204 const nextValue = field === "password" ? draft() : draft().trim();205 if (nextValue === currentValue) {206 cancelEdit();207 }208 }209210 async function savePassword(password: string) {211 const entry = props.entry;212 if (!entry) return;213 setSaving(true);214 try {215 await sendMessage({216 type: "UPDATE_ENTRY",217 payload: { id: entry.id, patch: { password } },218 });219 triggerRing();220 setShowGenerator(false);221 await props.onFieldSaved();222 } catch {223 224 } finally {225 setSaving(false);226 }227 }228229 230231 async function copyField(text: string, label: string, button?: HTMLButtonElement) {232 const ok = await popupCopy(text, label);233 if (ok) {234 const entry = props.entry;235 if (entry?.canary?.webhookUrl) {236 const trigger =237 label === "password"238 ? "password_copied"239 : label === "username"240 ? "username_copied"241 : undefined;242 if (trigger) {243 void sendMessage({244 type: "FIRE_CANARY_PING",245 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },246 });247 }248 }249 triggerRing();250 triggerPipPulse();251 setCopied(null);252 requestAnimationFrame(() => setCopied(label));253 if (button) {254 button.classList.remove("is-copied", "is-retreat");255 void button.offsetWidth;256 const drain = document.createElement("span");257 drain.className = "peach-copy-drain";258 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";259 drain.appendChild(bar);260 button.appendChild(drain);261 window.setTimeout(() => {262 button.classList.remove("is-copied");263 button.classList.add("is-retreat");264 drain.remove();265 window.setTimeout(() => button.classList.remove("is-retreat"), 160);266 }, 1800);267 }268 window.setTimeout(() => setCopied(null), 1800);269 }270 }271272 273274 createEffect(() => {275 if (!props.entry) return;276 const handler = (e: KeyboardEvent) => {277 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;278 if (inInput) return;279 switch (e.key) {280 case "c": e.preventDefault();281 if (props.entry?.password) void copyField(props.entry.password, "password"); break;282 case "u": e.preventDefault();283 if (props.entry?.username) void copyField(props.entry.username, "username"); break;284 case "Escape": e.preventDefault(); props.onEscape?.(); break;285 }286 };287 window.addEventListener("keydown", handler);288 onCleanup(() => window.removeEventListener("keydown", handler));289 });290291 292293 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {294 requestAnimationFrame(() => { el.focus(); el.select?.(); });295 }296297 function onEditKeyDown(e: KeyboardEvent) {298 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {299 e.preventDefault();300 void saveField();301 }302 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }303 }304305 return (306 <>307 <div308 class="peach-ghost"309 aria-hidden="true"310 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;"311 >312 {props.entry?.title || "empty"}313 </div>314315 <div class="peach-stage" style="position:relative;">316 <Show when={props.entry} fallback={<EmptyVaultState />}>317 {(entry) => (318 <>319 {}320 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>321 <div class="peach-breach-badge">322 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">323 <path d="M6 1L11 10H1L6 1z" />324 <line x1="6" y1="5" x2="6" y2="7.5" />325 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />326 </svg>327 <span style="flex:1">found in a known data breach</span>328 <button329 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"330 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}331 >332 Change password333 </button>334 </div>335 </Show>336337 {}338 <div class="peach-entry-header">339 <div class="peach-entry-header-name">340 <Show341 when={editingField() === "title"}342 fallback={343 <button344 class="peach-entry-name-button"345 title="Click to edit title"346 onClick={() => startEdit("title", entry().title)}347 >348 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>349 <span class="peach-entry-name-text">350 <Show351 when={useAnimatedTitle()}352 fallback={entry().title}353 >354 <For each={entry().title.split("")}>355 {(ch, i) => (356 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>357 {ch === " " ? "\u00A0" : ch}358 </span>359 )}360 </For>361 </Show>362 </span>363 </h1>364 </button>365 }366 >367 <div368 class="peach-inline-title-wrap"369 onFocusOut={(event) => handleEditorFocusOut("title", event)}370 >371 <input372 class="peach-inline-title-input"373 value={draft()}374 onInput={(e) => setDraft(e.currentTarget.value)}375 onKeyDown={onEditKeyDown}376 ref={autoFocus}377 disabled={saving()}378 />379 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />380 </div>381 </Show>382 </div>383384 {}385 <Show when={editingField() !== "title"}>386 <Show387 when={confirmDelete()}388 fallback={389 <button390 class="peach-entry-trash-btn"391 onClick={() => setConfirmDelete(true)}392 aria-label="Move to trash"393 title="Delete entry"394 >395 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">396 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />397 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />398 </svg>399 </button>400 }401 >402 <div class="peach-entry-trash-confirm">403 <span class="peach-entry-trash-confirm-label">trash?</span>404 <button405 class="peach-btn peach-btn--sm peach-btn--danger"406 style="font-size:10px;padding:2px 8px;min-height:22px;"407 onClick={() => { setConfirmDelete(false); props.onDelete(); }}408 >409 yes410 </button>411 <button412 class="peach-btn peach-btn--ghost peach-btn--sm"413 style="font-size:10px;padding:2px 8px;min-height:22px;"414 onClick={() => setConfirmDelete(false)}415 >416 no417 </button>418 </div>419 </Show>420 </Show>421 </div>422423 {}424 <Show when={entry().type !== "note" && entry().type !== "card"}>425 <div class="peach-domain" style="position:relative;z-index:1;">426 <Show427 when={editingField() === "url"}428 fallback={429 <div430 class="peach-domain-row peach-inline-clickable"431 title="Click to edit URL"432 onClick={() => startEdit("url", entry().url ?? "")}433 >434 <Show when={faviconSrc()}>435 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />436 </Show>437 <span class="peach-domain-value">438 {displayDomain() || (entry().url ? entry().url : "add a website url")}439 </span>440 <span class="peach-inline-edit-hint">edit</span>441 </div>442 }443 >444 <div445 class="peach-inline-field-wrap"446 onFocusOut={(event) => handleEditorFocusOut("url", event)}447 >448 <input449 class="peach-inline-input"450 type="url"451 placeholder="https://"452 value={draft()}453 onInput={(e) => setDraft(e.currentTarget.value)}454 onKeyDown={onEditKeyDown}455 ref={autoFocus}456 disabled={saving()}457 />458 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />459 </div>460 </Show>461 </div>462 </Show>463464 {}465 <Show when={entry().tags && entry().tags!.length > 0}>466 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">467 <For each={entry().tags!}>468 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}469 </For>470 </div>471 </Show>472473 {}474 <Show when={entry().type === "note"}>475 <div class="peach-entry-note-toolbar-row">476 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />477 </div>478 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">479 <NoteEditorInline480 entryKey={entry().id}481 initialContent={entry().notes ?? ""}482 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}483 onBlur={saveNoteInline}484 autoFocus={false}485 />486 </section>487 </Show>488489 {}490 <Show when={entry().type === "card"}>491 <section class="peach-fields" style="position:relative;z-index:1;">492 {}493 <Show when={entry().last4}>494 <div class="peach-field-card" style="padding-bottom:4px;">495 <span class="peach-label">496 <Show when={entry().network && entry().network !== "unknown"}>497 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>498 </Show>499 {entry().cardNickname || `•••• ${entry().last4}`}500 </span>501 </div>502 </Show>503504 {}505 <div class="peach-field-card">506 <span class="peach-label">cardholder name</span>507 <div class="peach-field-value-row">508 <span class="peach-field-value">{entry().cardholderName || "—"}</span>509 <Show when={entry().cardholderName}>510 <button511 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}512 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}513 aria-label="Copy cardholder name"514 >515 <CopyGlyph /><span class="peach-copy-sheen" />516 </button>517 </Show>518 </div>519 </div>520521 {}522 <div class="peach-field-card">523 <span class="peach-label">card number</span>524 <div class="peach-field-value-row">525 <span class={`peach-field-value peach-card-number`}>526 <Show when={showCardNumber()} fallback={527 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>528 }>529 <span>{entry().cardNumber ?? ""}</span>530 </Show>531 </span>532 <div class="peach-field-actions">533 <button534 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"535 onClick={() => setShowCardNumber(!showCardNumber())}536 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}537 >538 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>539 <EyeClosedGlyph />540 </Show>541 </button>542 <Show when={entry().cardNumber}>543 <button544 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}545 onClick={(e) => {546 const cardNum = entry().cardNumber!;547 if (entry().canary?.webhookUrl) {548 void sendMessage({549 type: "FIRE_CANARY_PING",550 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },551 });552 }553 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);554 }}555 aria-label="Copy card number"556 >557 <CopyGlyph /><span class="peach-copy-sheen" />558 </button>559 </Show>560 </div>561 </div>562 </div>563564 {}565 <Show when={entry().expMonth || entry().expYear}>566 <div class="peach-field-card">567 <span class="peach-label">expiry</span>568 <div class="peach-field-value-row">569 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>570 <button571 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}572 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}573 aria-label="Copy expiry"574 >575 <CopyGlyph /><span class="peach-copy-sheen" />576 </button>577 </div>578 </div>579 </Show>580581 {}582 <Show when={entry().cvc}>583 <div class="peach-field-card">584 <span class="peach-label">CVC</span>585 <div class="peach-field-value-row">586 <span class="peach-field-value">587 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>588 <span>{entry().cvc}</span>589 </Show>590 </span>591 <div class="peach-field-actions">592 <button593 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"594 onClick={() => setShowCardCvc(!showCardCvc())}595 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}596 >597 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>598 <EyeClosedGlyph />599 </Show>600 </button>601 <button602 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}603 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}604 aria-label="Copy CVC"605 >606 <CopyGlyph /><span class="peach-copy-sheen" />607 </button>608 </div>609 </div>610 </div>611 </Show>612 </section>613 </Show>614615 {}616 <Show when={entry().type !== "note" && entry().type !== "card"}>617 <section class="peach-fields" style="position:relative;z-index:1;">618619 {}620 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>621 <span class="peach-label">username</span>622 <Show623 when={editingField() === "username"}624 fallback={625 <div class="peach-field-value-row">626 <span627 class="peach-field-value peach-inline-clickable"628 title="Click to edit"629 onClick={() => startEdit("username", entry().username ?? "")}630 >631 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}632 </span>633 <Show when={entry().username}>634 <button635 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}636 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}637 aria-label="Copy username"638 >639 <CopyGlyph /><span class="peach-copy-sheen" />640 </button>641 </Show>642 </div>643 }644 >645 <div646 class="peach-inline-field-wrap"647 onFocusOut={(event) => handleEditorFocusOut("username", event)}648 >649 <input650 class="peach-inline-input"651 value={draft()}652 onInput={(e) => setDraft(e.currentTarget.value)}653 onKeyDown={onEditKeyDown}654 ref={autoFocus}655 disabled={saving()}656 autocomplete="off"657 />658 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />659 </div>660 </Show>661 </div>662663 {}664 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>665 <span class="peach-label">password</span>666 <Show667 when={editingField() === "password"}668 fallback={669 <div class="peach-field-value-row">670 <span671 class="peach-password-value"672 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}673 style="cursor:pointer;"674 title="Click to edit password"675 onClick={() => startEdit("password", entry().password ?? "")}676 >677 <Show678 when={showPassword()}679 fallback={<span class="peach-password-mask">...............</span>}680 >681 <span class="peach-password-text">{entry().password ?? ""}</span>682 </Show>683 </span>684 <div class="peach-field-actions">685 <button686 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"687 onClick={() => setShowPassword(!showPassword())}688 aria-label="Reveal password"689 >690 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>691 <EyeClosedGlyph />692 </Show>693 </button>694 <button695 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}696 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}697 aria-label="Copy password"698 >699 <CopyGlyph /><span class="peach-copy-sheen" />700 </button>701 </div>702 </div>703 }704 >705 {}706 <div707 class="peach-inline-field-wrap"708 onFocusOut={(event) => handleEditorFocusOut("password", event)}709 >710 <div class="peach-password-edit-row">711 <input712 class="peach-inline-input"713 type={showPassword() ? "text" : "password"}714 placeholder="paste or type a password"715 value={draft()}716 onInput={(e) => setDraft(e.currentTarget.value)}717 onKeyDown={(e) => {718 if (e.key === "Enter") { e.preventDefault(); void saveField(); }719 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }720 }}721 ref={autoFocus}722 disabled={saving()}723 autocomplete="new-password"724 />725 <button726 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"727 type="button"728 onClick={() => setShowPassword(!showPassword())}729 aria-label={showPassword() ? "Hide" : "Show"}730 >731 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>732 <EyeClosedGlyph />733 </Show>734 </button>735 </div>736 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />737 <button738 class="peach-generate-toggle-link"739 type="button"740 onClick={() => setShowGenerator(!showGenerator())}741 >742 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">743 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />744 <path d="M9.5 2.5H11V1" />745 </svg>746 {showGenerator() ? "hide generator" : "generate"}747 </button>748 <Show when={showGenerator()}>749 <GeneratorDrawer750 open={true}751 compact={true}752 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}753 />754 </Show>755 </div>756 </Show>757 </div>758759 </section>760 </Show>761762 {}763 <Show when={entry().type !== "note" && entry().type !== "card"}>764 <Show765 when={entry().notes || editingField() === "notes"}766 fallback={767 <button768 class="peach-add-field-link peach-entry-add-link"769 onClick={() => startEdit("notes", "")}770 >771 + add notes772 </button>773 }774 >775 <div style="position:relative;z-index:1;margin-top:6px;">776 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>777 <Show778 when={editingField() === "notes"}779 fallback={780 <div781 class="peach-note-content peach-inline-clickable"782 style="cursor:text;white-space:pre-wrap;"783 onClick={() => startEdit("notes", entry().notes ?? "")}784 >785 {entry().notes}786 </div>787 }788 >789 <div790 class="peach-inline-field-wrap"791 onFocusOut={(event) => handleEditorFocusOut("notes", event)}792 >793 <textarea794 class="peach-inline-textarea"795 value={draft()}796 onInput={(e) => setDraft(e.currentTarget.value)}797 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}798 ref={(el) => requestAnimationFrame(() => el?.focus())}799 rows={4}800 disabled={saving()}801 />802 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />803 </div>804 </Show>805 </div>806 </Show>807 </Show>808809 {}810 <Show when={entry().type === "login"}>811 <Show812 when={totpState() || editingField() === "totp"}813 fallback={814 <button815 class="peach-add-field-link peach-entry-add-link"816 onClick={() => startEdit("totp", "")}817 >818 + add 2FA819 </button>820 }821 >822 <section class="peach-totp-band">823 <Show824 when={editingField() === "totp"}825 fallback={826 <Show when={totpState()}>827 {(state) => (828 <>829 <div830 class="peach-totp-main"831 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}832 onDblClick={() => startEdit("totp", entry().totp ?? "")}833 title="Click to copy · double-click to edit"834 >835 <span class="peach-label">2-factor</span>836 <div class="peach-totp-code-row">837 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>838 <span class="peach-totp-code-text">839 {state().code.slice(0, 3)} {state().code.slice(3)}840 </span>841 </p>842 </div>843 </div>844 <div class="peach-totp-meta">845 <span class="peach-label">expires</span>846 <div class="peach-totp-countdown">{state().remaining}s</div>847 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">848 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />849 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"850 stroke-dasharray="106.8"851 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}852 />853 </svg>854 </div>855 </>856 )}857 </Show>858 }859 >860 <div861 class="peach-inline-field-wrap peach-inline-field-wrap--totp"862 onFocusOut={(event) => handleEditorFocusOut("totp", event)}863 >864 <span class="peach-label">2-factor</span>865 <input866 class="peach-inline-input"867 value={draft()}868 onInput={(e) => setDraft(e.currentTarget.value)}869 onKeyDown={onEditKeyDown}870 ref={autoFocus}871 disabled={saving()}872 placeholder="otpauth://... or base32 secret"873 autocomplete="off"874 />875 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />876 </div>877 </Show>878 </section>879 </Show>880 </Show>881882 {}883 <div style="height:4px;" />884 </>885 )}886 </Show>887 </div>888889 </>890 );891}892893894895function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {896 return (897 <div class="peach-inline-actions">898 <button899 class="peach-inline-save-btn"900 onClick={props.onSave}901 disabled={props.saving}902 aria-label="Save"903 title="Save (Enter)"904 >905 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">906 <path d="M1 4.5L4 7.5L10 1.5" />907 </svg>908 </button>909 <button910 class="peach-inline-cancel-btn"911 onClick={props.onCancel}912 disabled={props.saving}913 aria-label="Cancel"914 title="Cancel (Esc)"915 >916 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">917 <path d="M1 1L8 8M8 1L1 8" />918 </svg>919 </button>920 </div>921 );922}923924function EmptyVaultState() {925 return (926 <>927 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">928 <div class="peach-entry-name">929 <span class="peach-entry-name-text">930 <span class="peach-entry-name-char" style="--char-index:0">e</span>931 <span class="peach-entry-name-char" style="--char-index:1">m</span>932 <span class="peach-entry-name-char" style="--char-index:2">p</span>933 <span class="peach-entry-name-char" style="--char-index:3">t</span>934 <span class="peach-entry-name-char" style="--char-index:4">y</span>935 </span>936 </div>937 </div>938 <div class="peach-empty-copy">939 <span class="peach-label">vault</span>940 <p>Your vault is ready for its first entry.</p>941 </div>942 </>943 );944}945946function CopyGlyph() {947 return (948 <span class="peach-copy-icon">949 <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">950 <rect x="4" y="4" width="8" height="9" rx="1.5" />951 <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" />952 </svg>953 </span>954 );955}956957function EyeClosedGlyph() {958 return (959 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">960 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />961 <path d="M3.5 5.5L2.5 7.5" />962 <path d="M7 6.5V8.5" />963 <path d="M10.5 5.5L11.5 7.5" />964 </svg>965 );966}967968function EyeOpenGlyph() {969 return (970 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">971 <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" />972 <circle cx="7" cy="5" r="1.75" />973 </svg>974 );975}976977function ChevronGlyph(props: { open: boolean }) {978 return (979 <svg980 width="8"981 height="8"982 viewBox="0 0 8 8"983 fill="none"984 stroke="currentColor"985 stroke-width="1.3"986 stroke-linecap="round"987 stroke-linejoin="round"988 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}989 >990 <path d="M2 1L6 4L2 7" />991 </svg>992 );993}994995function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {996 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));997 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");998 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");999 const [testResult, setTestResult] = createSignal<string | null>(null);1000 const [testing, setTesting] = createSignal(false);10011002 async function save() {1003 const url = webhookUrl().trim();1004 const nextLabel = label().trim() || undefined;1005 const canary = enabled() && url1006 ? {1007 webhookUrl: url,1008 label: nextLabel,1009 lastPinged: props.entry.canary?.lastPinged,1010 pingCount: props.entry.canary?.pingCount,1011 }1012 : undefined;1013 await sendMessage({1014 type: "UPDATE_ENTRY",1015 payload: { id: props.entry.id, patch: { canary } },1016 });1017 await props.onSaved();1018 }10191020 async function testPing() {1021 const url = webhookUrl().trim();1022 if (!url) return;1023 setTesting(true);1024 setTestResult(null);1025 const res = await sendMessage({1026 type: "FIRE_CANARY_PING",1027 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1028 }) as { ok: boolean; error?: string };1029 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1030 setTesting(false);1031 }10321033 return (1034 <div class="peach-canary-section">1035 <div class="peach-field-value-row">1036 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1037 <button1038 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1039 onClick={() => { setEnabled(!enabled()); void save(); }}1040 aria-label="Toggle canary"1041 >1042 <span class="peach-toggle-knob" />1043 </button>1044 </div>1045 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1046 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1047 </p>1048 <Show when={enabled()}>1049 <div class="peach-inline-field-wrap" style="margin-top:8px;">1050 <input1051 class="peach-inline-input"1052 placeholder="https://hooks.zapier.com/..."1053 value={webhookUrl()}1054 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1055 onBlur={() => void save()}1056 />1057 <input1058 class="peach-inline-input"1059 placeholder="label (optional, e.g. AWS decoy)"1060 value={label()}1061 onInput={(e) => setLabel(e.currentTarget.value)}1062 onBlur={() => void save()}1063 />1064 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1065 <button1066 class="peach-btn peach-btn--ghost peach-btn--sm"1067 onClick={() => void testPing()}1068 disabled={testing() || !webhookUrl().trim()}1069 >1070 {testing() ? "sending..." : "test ping"}1071 </button>1072 <Show when={testResult()}>1073 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1074 </Show>1075 </div>1076 <Show when={props.entry.canary?.lastPinged}>1077 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1078 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1079 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1080 </p>1081 </Show>1082 </div>1083 </Show>1084 </div>1085 );1086}