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, triggerTotpRing } from "../App";7import { GeneratorDrawer } from "../components/GeneratorDrawer";89type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1011interface EntryProps {12 entry: VaultEntry | null;13 onDelete: () => void;14 15 onFieldSaved: () => void | Promise<void>;16 onEscape?: () => void;17}1819export function EntryView(props: EntryProps) {20 const [editingField, setEditingField] = createSignal<EditableField>(null);21 const [draft, setDraft] = createSignal("");22 const [saving, setSaving] = createSignal(false);23 const [showPassword, setShowPassword] = createSignal(false);24 const [showGenerator, setShowGenerator] = createSignal(false);25 const [copied, setCopied] = createSignal<string | null>(null);26 const [totpTick, setTotpTick] = createSignal(0);27 const [confirmDelete, setConfirmDelete] = createSignal(false);28 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);2930 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);31 onCleanup(() => window.clearInterval(interval));3233 const displayDomain = createMemo(() =>34 props.entry?.url ? getDisplayDomain(props.entry.url) : ""35 );3637 createEffect(() => {38 const domain = displayDomain();39 setFaviconSrc(domain ? faviconUrl(domain) : null);40 });4142 const totpState = createMemo(() => {43 void totpTick();44 if (!props.entry?.totp) return null;45 return generateTOTP(props.entry.totp);46 });4748 let prevTotpCode: string | undefined;49 createEffect(() => {50 const state = totpState();51 if (!state) { prevTotpCode = undefined; return; }52 if (prevTotpCode !== undefined && state.code !== prevTotpCode) triggerTotpRing();53 prevTotpCode = state.code;54 });5556 const totpProgress = createMemo(() => {57 const state = totpState();58 if (!state) return 0;59 return ((state.remaining - 1) / 29) * 100;60 });6162 createEffect(() => {63 const shell = document.getElementById("hd-shell");64 if (!shell) return;6566 const state = totpState();67 if (!state) {68 delete shell.dataset.totpActive;69 shell.style.removeProperty("--peach-totp-progress");70 return;71 }7273 shell.dataset.totpActive = "true";74 shell.style.setProperty(75 "--peach-totp-progress",76 `${Math.max(0, totpProgress())}%`77 );7879 onCleanup(() => {80 delete shell.dataset.totpActive;81 shell.style.removeProperty("--peach-totp-progress");82 });83 });8485 const titleSizeClass = createMemo(() => {86 const length = props.entry?.title.length ?? 0;87 if (length >= 28) return "peach-entry-name--xxl";88 if (length >= 20) return "peach-entry-name--xl";89 if (length >= 14) return "peach-entry-name--lg";90 return "";91 });92 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);9394 9596 function startEdit(field: EditableField, value: string) {97 setEditingField(field);98 setDraft(value);99 setShowGenerator(false);100 setConfirmDelete(false);101 }102103 async function saveField() {104 const field = editingField();105 const entry = props.entry;106 if (!field || !entry) return;107 108 let value = field === "password" ? draft() : draft().trim();109 if (field === "totp" && value.startsWith("otpauth://")) {110 const parsed = parseOtpauthURI(value);111 value = parsed?.secret ?? value;112 }113 if (field === "password" && entry.type !== "note" && !value) {114 return;115 }116 setSaving(true);117 try {118 await sendMessage({119 type: "UPDATE_ENTRY",120 payload: { id: entry.id, patch: { [field]: value } },121 });122 triggerRing();123 setEditingField(null);124 setShowGenerator(false);125 await props.onFieldSaved();126 } catch {127 128 } finally {129 setSaving(false);130 }131 }132133 function cancelEdit() {134 setEditingField(null);135 setDraft("");136 setShowGenerator(false);137 }138139 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {140 switch (field) {141 case "title":142 return entry.title;143 case "url":144 return entry.url ?? "";145 case "username":146 return entry.username ?? "";147 case "password":148 return entry.password ?? "";149 case "notes":150 return entry.notes ?? "";151 case "totp":152 return entry.totp ?? "";153 }154 }155156 function handleEditorFocusOut(157 field: Exclude<EditableField, null>,158 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }159 ) {160 const nextTarget = event.relatedTarget;161 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;162163 const entry = props.entry;164 if (!entry) return;165166 const currentValue = fieldValue(entry, field);167 const nextValue = field === "password" ? draft() : draft().trim();168 if (nextValue === currentValue) {169 cancelEdit();170 }171 }172173 async function savePassword(password: string) {174 const entry = props.entry;175 if (!entry) return;176 setSaving(true);177 try {178 await sendMessage({179 type: "UPDATE_ENTRY",180 payload: { id: entry.id, patch: { password } },181 });182 triggerRing();183 setShowGenerator(false);184 await props.onFieldSaved();185 } catch {186 187 } finally {188 setSaving(false);189 }190 }191192 193194 async function copyField(text: string, label: string, button?: HTMLButtonElement) {195 const response = await sendMessage({ type: "COPY", payload: { text, label } });196 if ((response as { ok: boolean }).ok) {197 const entry = props.entry;198 if (entry?.canary?.webhookUrl) {199 const trigger =200 label === "password"201 ? "password_copied"202 : label === "username"203 ? "username_copied"204 : undefined;205 if (trigger) {206 void sendMessage({207 type: "FIRE_CANARY_PING",208 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },209 });210 }211 }212 triggerRing();213 triggerPipPulse();214 setCopied(null);215 requestAnimationFrame(() => setCopied(label));216 if (button) {217 button.classList.remove("is-copied", "is-retreat");218 void button.offsetWidth;219 const drain = document.createElement("span");220 drain.className = "peach-copy-drain";221 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";222 drain.appendChild(bar);223 button.appendChild(drain);224 window.setTimeout(() => {225 button.classList.remove("is-copied");226 button.classList.add("is-retreat");227 drain.remove();228 window.setTimeout(() => button.classList.remove("is-retreat"), 160);229 }, 1800);230 }231 window.setTimeout(() => setCopied(null), 1800);232 }233 }234235 236237 createEffect(() => {238 if (!props.entry) return;239 const handler = (e: KeyboardEvent) => {240 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;241 if (inInput) return;242 switch (e.key) {243 case "c": e.preventDefault();244 if (props.entry?.password) void copyField(props.entry.password, "password"); break;245 case "u": e.preventDefault();246 if (props.entry?.username) void copyField(props.entry.username, "username"); break;247 case "Escape": e.preventDefault(); props.onEscape?.(); break;248 }249 };250 window.addEventListener("keydown", handler);251 onCleanup(() => window.removeEventListener("keydown", handler));252 });253254 255256 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {257 requestAnimationFrame(() => { el.focus(); el.select?.(); });258 }259260 function onEditKeyDown(e: KeyboardEvent) {261 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {262 e.preventDefault();263 void saveField();264 }265 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }266 }267268 return (269 <>270 <div271 class="peach-ghost"272 aria-hidden="true"273 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;"274 >275 {props.entry?.title || "empty"}276 </div>277278 <div class="peach-stage" style="position:relative;">279 <Show when={props.entry} fallback={<EmptyVaultState />}>280 {(entry) => (281 <>282 {}283 <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>284 <div class="peach-breach-badge">285 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">286 <path d="M6 1L11 10H1L6 1z" />287 <line x1="6" y1="5" x2="6" y2="7.5" />288 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />289 </svg>290 <span style="flex:1">found in a known data breach</span>291 <button292 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"293 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}294 >295 Change password296 </button>297 </div>298 </Show>299300 {}301 <div class="peach-entry-header">302 <div class="peach-entry-header-name">303 <Show304 when={editingField() === "title"}305 fallback={306 <button307 class="peach-entry-name-button"308 title="Click to edit title"309 onClick={() => startEdit("title", entry().title)}310 >311 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>312 <span class="peach-entry-name-text">313 <Show314 when={useAnimatedTitle()}315 fallback={entry().title}316 >317 <For each={entry().title.split("")}>318 {(ch, i) => (319 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>320 {ch === " " ? "\u00A0" : ch}321 </span>322 )}323 </For>324 </Show>325 </span>326 </h1>327 </button>328 }329 >330 <div331 class="peach-inline-title-wrap"332 onFocusOut={(event) => handleEditorFocusOut("title", event)}333 >334 <input335 class="peach-inline-title-input"336 value={draft()}337 onInput={(e) => setDraft(e.currentTarget.value)}338 onKeyDown={onEditKeyDown}339 ref={autoFocus}340 disabled={saving()}341 />342 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />343 </div>344 </Show>345 </div>346347 {}348 <Show when={editingField() !== "title"}>349 <Show350 when={confirmDelete()}351 fallback={352 <button353 class="peach-entry-trash-btn"354 onClick={() => setConfirmDelete(true)}355 aria-label="Move to trash"356 title="Delete entry"357 >358 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">359 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />360 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />361 </svg>362 </button>363 }364 >365 <div class="peach-entry-trash-confirm">366 <span class="peach-entry-trash-confirm-label">trash?</span>367 <button368 class="peach-btn peach-btn--sm peach-btn--danger"369 style="font-size:10px;padding:2px 8px;min-height:22px;"370 onClick={() => { setConfirmDelete(false); props.onDelete(); }}371 >372 yes373 </button>374 <button375 class="peach-btn peach-btn--ghost peach-btn--sm"376 style="font-size:10px;padding:2px 8px;min-height:22px;"377 onClick={() => setConfirmDelete(false)}378 >379 no380 </button>381 </div>382 </Show>383 </Show>384 </div>385386 {}387 <Show when={entry().type !== "note"}>388 <div class="peach-domain" style="position:relative;z-index:1;">389 <Show390 when={editingField() === "url"}391 fallback={392 <div393 class="peach-domain-row peach-inline-clickable"394 title="Click to edit URL"395 onClick={() => startEdit("url", entry().url ?? "")}396 >397 <Show when={faviconSrc()}>398 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />399 </Show>400 <span class="peach-domain-value">401 {displayDomain() || (entry().url ? entry().url : "add a website url")}402 </span>403 <span class="peach-inline-edit-hint">edit</span>404 </div>405 }406 >407 <div408 class="peach-inline-field-wrap"409 onFocusOut={(event) => handleEditorFocusOut("url", event)}410 >411 <input412 class="peach-inline-input"413 type="url"414 placeholder="https://"415 value={draft()}416 onInput={(e) => setDraft(e.currentTarget.value)}417 onKeyDown={onEditKeyDown}418 ref={autoFocus}419 disabled={saving()}420 />421 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />422 </div>423 </Show>424 </div>425 </Show>426427 {}428 <Show when={entry().tags && entry().tags!.length > 0}>429 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">430 <For each={entry().tags!}>431 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}432 </For>433 </div>434 </Show>435436 {}437 <Show when={entry().type === "note"}>438 <section class="peach-fields" style="position:relative;z-index:1;">439 <Show440 when={editingField() === "notes"}441 fallback={442 <div443 class="peach-note-content peach-inline-clickable"444 style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"445 title="Click to edit"446 onClick={() => startEdit("notes", entry().notes ?? "")}447 >448 {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}449 </div>450 }451 >452 <div453 class="peach-inline-field-wrap"454 onFocusOut={(event) => handleEditorFocusOut("notes", event)}455 >456 <textarea457 class="peach-inline-textarea"458 value={draft()}459 onInput={(e) => setDraft(e.currentTarget.value)}460 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}461 ref={(el) => requestAnimationFrame(() => el?.focus())}462 rows={6}463 disabled={saving()}464 />465 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />466 </div>467 </Show>468 </section>469 </Show>470471 {}472 <Show when={entry().type !== "note"}>473 <section class="peach-fields" style="position:relative;z-index:1;">474475 {}476 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>477 <span class="peach-label">username</span>478 <Show479 when={editingField() === "username"}480 fallback={481 <div class="peach-field-value-row">482 <span483 class="peach-field-value peach-inline-clickable"484 title="Click to edit"485 onClick={() => startEdit("username", entry().username ?? "")}486 >487 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}488 </span>489 <Show when={entry().username}>490 <button491 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}492 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}493 aria-label="Copy username"494 >495 <CopyGlyph /><span class="peach-copy-sheen" />496 </button>497 </Show>498 </div>499 }500 >501 <div502 class="peach-inline-field-wrap"503 onFocusOut={(event) => handleEditorFocusOut("username", event)}504 >505 <input506 class="peach-inline-input"507 value={draft()}508 onInput={(e) => setDraft(e.currentTarget.value)}509 onKeyDown={onEditKeyDown}510 ref={autoFocus}511 disabled={saving()}512 autocomplete="off"513 />514 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />515 </div>516 </Show>517 </div>518519 {}520 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>521 <span class="peach-label">password</span>522 <Show523 when={editingField() === "password"}524 fallback={525 <div class="peach-field-value-row">526 <span527 class="peach-password-value"528 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}529 style="cursor:pointer;"530 title="Click to edit password"531 onClick={() => startEdit("password", entry().password ?? "")}532 >533 <Show534 when={showPassword()}535 fallback={<span class="peach-password-mask">...............</span>}536 >537 <span class="peach-password-text">{entry().password ?? ""}</span>538 </Show>539 </span>540 <div class="peach-field-actions">541 <button542 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"543 onClick={() => setShowPassword(!showPassword())}544 aria-label="Reveal password"545 >546 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>547 <EyeClosedGlyph />548 </Show>549 </button>550 <button551 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}552 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}553 aria-label="Copy password"554 >555 <CopyGlyph /><span class="peach-copy-sheen" />556 </button>557 </div>558 </div>559 }560 >561 {}562 <div563 class="peach-inline-field-wrap"564 onFocusOut={(event) => handleEditorFocusOut("password", event)}565 >566 <div class="peach-password-edit-row">567 <input568 class="peach-inline-input"569 type={showPassword() ? "text" : "password"}570 placeholder="paste or type a password"571 value={draft()}572 onInput={(e) => setDraft(e.currentTarget.value)}573 onKeyDown={(e) => {574 if (e.key === "Enter") { e.preventDefault(); void saveField(); }575 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }576 }}577 ref={autoFocus}578 disabled={saving()}579 autocomplete="new-password"580 />581 <button582 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"583 type="button"584 onClick={() => setShowPassword(!showPassword())}585 aria-label={showPassword() ? "Hide" : "Show"}586 >587 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>588 <EyeClosedGlyph />589 </Show>590 </button>591 </div>592 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />593 <button594 class="peach-generate-toggle-link"595 type="button"596 onClick={() => setShowGenerator(!showGenerator())}597 >598 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">599 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />600 <path d="M9.5 2.5H11V1" />601 </svg>602 {showGenerator() ? "hide generator" : "generate"}603 </button>604 <Show when={showGenerator()}>605 <GeneratorDrawer606 open={true}607 compact={true}608 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}609 />610 </Show>611 </div>612 </Show>613 </div>614615 </section>616 </Show>617618 {}619 <Show when={entry().type !== "note"}>620 <Show621 when={entry().notes || editingField() === "notes"}622 fallback={623 <button624 class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"625 onClick={() => startEdit("notes", "")}626 >627 + add notes628 </button>629 }630 >631 <div style="position:relative;z-index:1;margin-top:6px;">632 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>633 <Show634 when={editingField() === "notes"}635 fallback={636 <div637 class="peach-note-content peach-inline-clickable"638 style="cursor:text;white-space:pre-wrap;"639 onClick={() => startEdit("notes", entry().notes ?? "")}640 >641 {entry().notes}642 </div>643 }644 >645 <div646 class="peach-inline-field-wrap"647 onFocusOut={(event) => handleEditorFocusOut("notes", event)}648 >649 <textarea650 class="peach-inline-textarea"651 value={draft()}652 onInput={(e) => setDraft(e.currentTarget.value)}653 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}654 ref={(el) => requestAnimationFrame(() => el?.focus())}655 rows={4}656 disabled={saving()}657 />658 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />659 </div>660 </Show>661 </div>662 </Show>663 </Show>664665 {}666 <Show when={totpState()}>667 {(state) => (668 <section class={`peach-totp-band${state().remaining <= 9 ? " is-urgent" : ""}`}>669 <Show670 when={editingField() === "totp"}671 fallback={672 <>673 <div674 class="peach-totp-main peach-inline-clickable"675 title="Click to edit 2FA secret"676 onClick={() => startEdit("totp", entry().totp ?? "")}677 >678 <span class="peach-label">2-factor</span>679 <div class="peach-totp-code-row">680 <p class="peach-totp-code">681 <span class="peach-totp-code-text">682 {state().code.slice(0, 3)} {state().code.slice(3)}683 </span>684 </p>685 <button686 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}687 onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}688 aria-label="Copy TOTP code"689 >690 <CopyGlyph /><span class="peach-copy-sheen" />691 </button>692 </div>693 </div>694 <div class="peach-totp-meta">695 <span class="peach-label">expires</span>696 <div class="peach-totp-countdown">{state().remaining}s</div>697 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">698 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />699 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"700 stroke-dasharray="106.8"701 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}702 />703 </svg>704 </div>705 </>706 }707 >708 <div709 class="peach-inline-field-wrap peach-inline-field-wrap--totp"710 onFocusOut={(event) => handleEditorFocusOut("totp", event)}711 >712 <span class="peach-label">2-factor</span>713 <input714 class="peach-inline-input"715 value={draft()}716 onInput={(e) => setDraft(e.currentTarget.value)}717 onKeyDown={onEditKeyDown}718 ref={autoFocus}719 disabled={saving()}720 placeholder="otpauth://... or base32 secret"721 autocomplete="off"722 />723 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />724 </div>725 </Show>726 </section>727 )}728 </Show>729730 {}731 <div style="height:4px;" />732 </>733 )}734 </Show>735 </div>736 </>737 );738}739740741742function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {743 return (744 <div class="peach-inline-actions">745 <button746 class="peach-inline-save-btn"747 onClick={props.onSave}748 disabled={props.saving}749 aria-label="Save"750 title="Save (Enter)"751 >752 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">753 <path d="M1 4.5L4 7.5L10 1.5" />754 </svg>755 </button>756 <button757 class="peach-inline-cancel-btn"758 onClick={props.onCancel}759 disabled={props.saving}760 aria-label="Cancel"761 title="Cancel (Esc)"762 >763 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">764 <path d="M1 1L8 8M8 1L1 8" />765 </svg>766 </button>767 </div>768 );769}770771function EmptyVaultState() {772 return (773 <>774 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">775 <div class="peach-entry-name">776 <span class="peach-entry-name-text">777 <span class="peach-entry-name-char" style="--char-index:0">e</span>778 <span class="peach-entry-name-char" style="--char-index:1">m</span>779 <span class="peach-entry-name-char" style="--char-index:2">p</span>780 <span class="peach-entry-name-char" style="--char-index:3">t</span>781 <span class="peach-entry-name-char" style="--char-index:4">y</span>782 </span>783 </div>784 </div>785 <div class="peach-empty-copy">786 <span class="peach-label">vault</span>787 <p>Your vault is ready for its first entry.</p>788 </div>789 </>790 );791}792793function CopyGlyph() {794 return (795 <span class="peach-copy-icon">796 <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">797 <rect x="4" y="4" width="8" height="9" rx="1.5" />798 <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" />799 </svg>800 </span>801 );802}803804function EyeClosedGlyph() {805 return (806 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">807 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />808 <path d="M3.5 5.5L2.5 7.5" />809 <path d="M7 6.5V8.5" />810 <path d="M10.5 5.5L11.5 7.5" />811 </svg>812 );813}814815function EyeOpenGlyph() {816 return (817 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">818 <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" />819 <circle cx="7" cy="5" r="1.75" />820 </svg>821 );822}823824function ChevronGlyph(props: { open: boolean }) {825 return (826 <svg827 width="8"828 height="8"829 viewBox="0 0 8 8"830 fill="none"831 stroke="currentColor"832 stroke-width="1.3"833 stroke-linecap="round"834 stroke-linejoin="round"835 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}836 >837 <path d="M2 1L6 4L2 7" />838 </svg>839 );840}841842function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {843 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));844 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");845 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");846 const [testResult, setTestResult] = createSignal<string | null>(null);847 const [testing, setTesting] = createSignal(false);848849 async function save() {850 const url = webhookUrl().trim();851 const nextLabel = label().trim() || undefined;852 const canary = enabled() && url853 ? {854 webhookUrl: url,855 label: nextLabel,856 lastPinged: props.entry.canary?.lastPinged,857 pingCount: props.entry.canary?.pingCount,858 }859 : undefined;860 await sendMessage({861 type: "UPDATE_ENTRY",862 payload: { id: props.entry.id, patch: { canary } },863 });864 await props.onSaved();865 }866867 async function testPing() {868 const url = webhookUrl().trim();869 if (!url) return;870 setTesting(true);871 setTestResult(null);872 const res = await sendMessage({873 type: "FIRE_CANARY_PING",874 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },875 }) as { ok: boolean; error?: string };876 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);877 setTesting(false);878 }879880 return (881 <div class="peach-canary-section">882 <div class="peach-field-value-row">883 <span class="peach-field-value" style="font-size:12px;">canary alert</span>884 <button885 class={`peach-toggle ${enabled() ? "is-on" : ""}`}886 onClick={() => { setEnabled(!enabled()); void save(); }}887 aria-label="Toggle canary"888 >889 <span class="peach-toggle-knob" />890 </button>891 </div>892 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">893 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.894 </p>895 <Show when={enabled()}>896 <div class="peach-inline-field-wrap" style="margin-top:8px;">897 <input898 class="peach-inline-input"899 placeholder="https://hooks.zapier.com/..."900 value={webhookUrl()}901 onInput={(e) => setWebhookUrl(e.currentTarget.value)}902 onBlur={() => void save()}903 />904 <input905 class="peach-inline-input"906 placeholder="label (optional, e.g. AWS decoy)"907 value={label()}908 onInput={(e) => setLabel(e.currentTarget.value)}909 onBlur={() => void save()}910 />911 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">912 <button913 class="peach-btn peach-btn--ghost peach-btn--sm"914 onClick={() => void testPing()}915 disabled={testing() || !webhookUrl().trim()}916 >917 {testing() ? "sending..." : "test ping"}918 </button>919 <Show when={testResult()}>920 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>921 </Show>922 </div>923 <Show when={props.entry.canary?.lastPinged}>924 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">925 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}926 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts927 </p>928 </Show>929 </div>930 </Show>931 </div>932 );933}