1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import { Portal } from "solid-js/web";3import type { VaultEntry } from "../../shared/types";4import { getDisplayDomain } from "../../shared/domain";5import { faviconUrl } from "../../shared/favicon";6import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";7import { sendMessage, triggerPipPulse, triggerRing } from "../App";8import { GeneratorDrawer } from "../components/GeneratorDrawer";9import { NoteEditor } 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 [totpTick, setTotpTick] = createSignal(0);29 const [confirmDelete, setConfirmDelete] = createSignal(false);30 const [totpHot, setTotpHot] = createSignal(false);31 const [noteEditing, setNoteEditing] = createSignal(false);32 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);3334 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);35 onCleanup(() => window.clearInterval(interval));3637 const displayDomain = createMemo(() =>38 props.entry?.url ? getDisplayDomain(props.entry.url) : ""39 );4041 createEffect(() => {42 const domain = displayDomain();43 setFaviconSrc(domain ? faviconUrl(domain) : null);44 });4546 const totpState = createMemo(() => {47 void totpTick();48 if (!props.entry?.totp) return null;49 return generateTOTP(props.entry.totp);50 });5152 let prevTotpCode: string | undefined;53 createEffect(() => {54 const state = totpState();55 if (!state) { prevTotpCode = undefined; return; }56 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {57 setTotpHot(true);58 window.setTimeout(() => setTotpHot(false), 3000);59 }60 prevTotpCode = state.code;61 });6263 const totpProgress = createMemo(() => {64 const state = totpState();65 if (!state) return 0;66 return ((state.remaining - 1) / 29) * 100;67 });6869 createEffect(() => {70 const shell = document.getElementById("hd-shell");71 if (!shell) return;7273 const state = totpState();74 if (!state) {75 delete shell.dataset.totpActive;76 shell.style.removeProperty("--peach-totp-progress");77 return;78 }7980 shell.dataset.totpActive = "true";81 shell.style.setProperty(82 "--peach-totp-progress",83 `${Math.max(0, totpProgress())}%`84 );8586 onCleanup(() => {87 delete shell.dataset.totpActive;88 shell.style.removeProperty("--peach-totp-progress");89 });90 });9192 const titleSizeClass = createMemo(() => {93 const length = props.entry?.title.length ?? 0;94 if (length >= 28) return "peach-entry-name--xxl";95 if (length >= 20) return "peach-entry-name--xl";96 if (length >= 14) return "peach-entry-name--lg";97 return "";98 });99 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);100101 102103 function startEdit(field: EditableField, value: string) {104 setEditingField(field);105 setDraft(value);106 setShowGenerator(false);107 setConfirmDelete(false);108 }109110 async function saveField() {111 const field = editingField();112 const entry = props.entry;113 if (!field || !entry) return;114 115 let value = field === "password" ? draft() : draft().trim();116 if (field === "totp" && value.startsWith("otpauth://")) {117 const parsed = parseOtpauthURI(value);118 value = parsed?.secret ?? value;119 }120 if (field === "password" && entry.type !== "note" && !value) {121 return;122 }123 setSaving(true);124 try {125 await sendMessage({126 type: "UPDATE_ENTRY",127 payload: { id: entry.id, patch: { [field]: value } },128 });129 triggerRing();130 setEditingField(null);131 setShowGenerator(false);132 await props.onFieldSaved();133 } catch {134 135 } finally {136 setSaving(false);137 }138 }139140 function cancelEdit() {141 setEditingField(null);142 setDraft("");143 setShowGenerator(false);144 }145146 async function saveNote(html: string) {147 const entry = props.entry;148 if (!entry) return;149 setSaving(true);150 try {151 await sendMessage({152 type: "UPDATE_ENTRY",153 payload: { id: entry.id, patch: { notes: html } },154 });155 triggerRing();156 setNoteEditing(false);157 await props.onFieldSaved();158 } catch {159 160 } finally {161 setSaving(false);162 }163 }164165 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {166 switch (field) {167 case "title":168 return entry.title;169 case "url":170 return entry.url ?? "";171 case "username":172 return entry.username ?? "";173 case "password":174 return entry.password ?? "";175 case "notes":176 return entry.notes ?? "";177 case "totp":178 return entry.totp ?? "";179 }180 }181182 function handleEditorFocusOut(183 field: Exclude<EditableField, null>,184 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }185 ) {186 const nextTarget = event.relatedTarget;187 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;188189 const entry = props.entry;190 if (!entry) return;191192 const currentValue = fieldValue(entry, field);193 const nextValue = field === "password" ? draft() : draft().trim();194 if (nextValue === currentValue) {195 cancelEdit();196 }197 }198199 async function savePassword(password: string) {200 const entry = props.entry;201 if (!entry) return;202 setSaving(true);203 try {204 await sendMessage({205 type: "UPDATE_ENTRY",206 payload: { id: entry.id, patch: { password } },207 });208 triggerRing();209 setShowGenerator(false);210 await props.onFieldSaved();211 } catch {212 213 } finally {214 setSaving(false);215 }216 }217218 219220 async function copyField(text: string, label: string, button?: HTMLButtonElement) {221 const response = await sendMessage({ type: "COPY", payload: { text, label } });222 if ((response as { ok: boolean }).ok) {223 const entry = props.entry;224 if (entry?.canary?.webhookUrl) {225 const trigger =226 label === "password"227 ? "password_copied"228 : label === "username"229 ? "username_copied"230 : undefined;231 if (trigger) {232 void sendMessage({233 type: "FIRE_CANARY_PING",234 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },235 });236 }237 }238 triggerRing();239 triggerPipPulse();240 setCopied(null);241 requestAnimationFrame(() => setCopied(label));242 if (button) {243 button.classList.remove("is-copied", "is-retreat");244 void button.offsetWidth;245 const drain = document.createElement("span");246 drain.className = "peach-copy-drain";247 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";248 drain.appendChild(bar);249 button.appendChild(drain);250 window.setTimeout(() => {251 button.classList.remove("is-copied");252 button.classList.add("is-retreat");253 drain.remove();254 window.setTimeout(() => button.classList.remove("is-retreat"), 160);255 }, 1800);256 }257 window.setTimeout(() => setCopied(null), 1800);258 }259 }260261 262263 createEffect(() => {264 if (!props.entry) return;265 const handler = (e: KeyboardEvent) => {266 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;267 if (inInput) return;268 switch (e.key) {269 case "c": e.preventDefault();270 if (props.entry?.password) void copyField(props.entry.password, "password"); break;271 case "u": e.preventDefault();272 if (props.entry?.username) void copyField(props.entry.username, "username"); break;273 case "Escape": e.preventDefault(); props.onEscape?.(); break;274 }275 };276 window.addEventListener("keydown", handler);277 onCleanup(() => window.removeEventListener("keydown", handler));278 });279280 281282 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {283 requestAnimationFrame(() => { el.focus(); el.select?.(); });284 }285286 function onEditKeyDown(e: KeyboardEvent) {287 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {288 e.preventDefault();289 void saveField();290 }291 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }292 }293294 return (295 <>296 <div297 class="peach-ghost"298 aria-hidden="true"299 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;"300 >301 {props.entry?.title || "empty"}302 </div>303304 <div class="peach-stage" style="position:relative;">305 <Show when={props.entry} fallback={<EmptyVaultState />}>306 {(entry) => (307 <>308 {}309 <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>310 <div class="peach-breach-badge">311 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">312 <path d="M6 1L11 10H1L6 1z" />313 <line x1="6" y1="5" x2="6" y2="7.5" />314 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />315 </svg>316 <span style="flex:1">found in a known data breach</span>317 <button318 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"319 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}320 >321 Change password322 </button>323 </div>324 </Show>325326 {}327 <div class="peach-entry-header">328 <div class="peach-entry-header-name">329 <Show330 when={editingField() === "title"}331 fallback={332 <button333 class="peach-entry-name-button"334 title="Click to edit title"335 onClick={() => startEdit("title", entry().title)}336 >337 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>338 <span class="peach-entry-name-text">339 <Show340 when={useAnimatedTitle()}341 fallback={entry().title}342 >343 <For each={entry().title.split("")}>344 {(ch, i) => (345 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>346 {ch === " " ? "\u00A0" : ch}347 </span>348 )}349 </For>350 </Show>351 </span>352 </h1>353 </button>354 }355 >356 <div357 class="peach-inline-title-wrap"358 onFocusOut={(event) => handleEditorFocusOut("title", event)}359 >360 <input361 class="peach-inline-title-input"362 value={draft()}363 onInput={(e) => setDraft(e.currentTarget.value)}364 onKeyDown={onEditKeyDown}365 ref={autoFocus}366 disabled={saving()}367 />368 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />369 </div>370 </Show>371 </div>372373 {}374 <Show when={editingField() !== "title"}>375 <Show376 when={confirmDelete()}377 fallback={378 <button379 class="peach-entry-trash-btn"380 onClick={() => setConfirmDelete(true)}381 aria-label="Move to trash"382 title="Delete entry"383 >384 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">385 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />386 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />387 </svg>388 </button>389 }390 >391 <div class="peach-entry-trash-confirm">392 <span class="peach-entry-trash-confirm-label">trash?</span>393 <button394 class="peach-btn peach-btn--sm peach-btn--danger"395 style="font-size:10px;padding:2px 8px;min-height:22px;"396 onClick={() => { setConfirmDelete(false); props.onDelete(); }}397 >398 yes399 </button>400 <button401 class="peach-btn peach-btn--ghost peach-btn--sm"402 style="font-size:10px;padding:2px 8px;min-height:22px;"403 onClick={() => setConfirmDelete(false)}404 >405 no406 </button>407 </div>408 </Show>409 </Show>410 </div>411412 {}413 <Show when={entry().type !== "note"}>414 <div class="peach-domain" style="position:relative;z-index:1;">415 <Show416 when={editingField() === "url"}417 fallback={418 <div419 class="peach-domain-row peach-inline-clickable"420 title="Click to edit URL"421 onClick={() => startEdit("url", entry().url ?? "")}422 >423 <Show when={faviconSrc()}>424 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />425 </Show>426 <span class="peach-domain-value">427 {displayDomain() || (entry().url ? entry().url : "add a website url")}428 </span>429 <span class="peach-inline-edit-hint">edit</span>430 </div>431 }432 >433 <div434 class="peach-inline-field-wrap"435 onFocusOut={(event) => handleEditorFocusOut("url", event)}436 >437 <input438 class="peach-inline-input"439 type="url"440 placeholder="https://"441 value={draft()}442 onInput={(e) => setDraft(e.currentTarget.value)}443 onKeyDown={onEditKeyDown}444 ref={autoFocus}445 disabled={saving()}446 />447 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />448 </div>449 </Show>450 </div>451 </Show>452453 {}454 <Show when={entry().tags && entry().tags!.length > 0}>455 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">456 <For each={entry().tags!}>457 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}458 </For>459 </div>460 </Show>461462 {}463 <Show when={entry().type === "note"}>464 <section class="peach-fields" style="position:relative;z-index:1;">465 <Show466 when={entry().notes}467 fallback={468 <div469 class="peach-note-content peach-inline-clickable"470 style="cursor:text;"471 onClick={() => setNoteEditing(true)}472 >473 <span style="color:var(--peach-t2);font-style:italic;">click to add notes</span>474 </div>475 }476 >477 <div478 class="peach-note-content peach-inline-clickable"479 innerHTML={entry().notes}480 onClick={() => setNoteEditing(true)}481 />482 </Show>483 </section>484 </Show>485486 {}487 <Show when={entry().type !== "note"}>488 <section class="peach-fields" style="position:relative;z-index:1;">489490 {}491 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>492 <span class="peach-label">username</span>493 <Show494 when={editingField() === "username"}495 fallback={496 <div class="peach-field-value-row">497 <span498 class="peach-field-value peach-inline-clickable"499 title="Click to edit"500 onClick={() => startEdit("username", entry().username ?? "")}501 >502 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}503 </span>504 <Show when={entry().username}>505 <button506 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}507 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}508 aria-label="Copy username"509 >510 <CopyGlyph /><span class="peach-copy-sheen" />511 </button>512 </Show>513 </div>514 }515 >516 <div517 class="peach-inline-field-wrap"518 onFocusOut={(event) => handleEditorFocusOut("username", event)}519 >520 <input521 class="peach-inline-input"522 value={draft()}523 onInput={(e) => setDraft(e.currentTarget.value)}524 onKeyDown={onEditKeyDown}525 ref={autoFocus}526 disabled={saving()}527 autocomplete="off"528 />529 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />530 </div>531 </Show>532 </div>533534 {}535 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>536 <span class="peach-label">password</span>537 <Show538 when={editingField() === "password"}539 fallback={540 <div class="peach-field-value-row">541 <span542 class="peach-password-value"543 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}544 style="cursor:pointer;"545 title="Click to edit password"546 onClick={() => startEdit("password", entry().password ?? "")}547 >548 <Show549 when={showPassword()}550 fallback={<span class="peach-password-mask">...............</span>}551 >552 <span class="peach-password-text">{entry().password ?? ""}</span>553 </Show>554 </span>555 <div class="peach-field-actions">556 <button557 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"558 onClick={() => setShowPassword(!showPassword())}559 aria-label="Reveal password"560 >561 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>562 <EyeClosedGlyph />563 </Show>564 </button>565 <button566 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}567 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}568 aria-label="Copy password"569 >570 <CopyGlyph /><span class="peach-copy-sheen" />571 </button>572 </div>573 </div>574 }575 >576 {}577 <div578 class="peach-inline-field-wrap"579 onFocusOut={(event) => handleEditorFocusOut("password", event)}580 >581 <div class="peach-password-edit-row">582 <input583 class="peach-inline-input"584 type={showPassword() ? "text" : "password"}585 placeholder="paste or type a password"586 value={draft()}587 onInput={(e) => setDraft(e.currentTarget.value)}588 onKeyDown={(e) => {589 if (e.key === "Enter") { e.preventDefault(); void saveField(); }590 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }591 }}592 ref={autoFocus}593 disabled={saving()}594 autocomplete="new-password"595 />596 <button597 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"598 type="button"599 onClick={() => setShowPassword(!showPassword())}600 aria-label={showPassword() ? "Hide" : "Show"}601 >602 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>603 <EyeClosedGlyph />604 </Show>605 </button>606 </div>607 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />608 <button609 class="peach-generate-toggle-link"610 type="button"611 onClick={() => setShowGenerator(!showGenerator())}612 >613 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">614 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />615 <path d="M9.5 2.5H11V1" />616 </svg>617 {showGenerator() ? "hide generator" : "generate"}618 </button>619 <Show when={showGenerator()}>620 <GeneratorDrawer621 open={true}622 compact={true}623 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}624 />625 </Show>626 </div>627 </Show>628 </div>629630 </section>631 </Show>632633 {}634 <Show when={entry().type !== "note"}>635 <Show636 when={entry().notes || editingField() === "notes"}637 fallback={638 <button639 class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"640 onClick={() => startEdit("notes", "")}641 >642 + add notes643 </button>644 }645 >646 <div style="position:relative;z-index:1;margin-top:6px;">647 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>648 <Show649 when={editingField() === "notes"}650 fallback={651 <div652 class="peach-note-content peach-inline-clickable"653 style="cursor:text;white-space:pre-wrap;"654 onClick={() => startEdit("notes", entry().notes ?? "")}655 >656 {entry().notes}657 </div>658 }659 >660 <div661 class="peach-inline-field-wrap"662 onFocusOut={(event) => handleEditorFocusOut("notes", event)}663 >664 <textarea665 class="peach-inline-textarea"666 value={draft()}667 onInput={(e) => setDraft(e.currentTarget.value)}668 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}669 ref={(el) => requestAnimationFrame(() => el?.focus())}670 rows={4}671 disabled={saving()}672 />673 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />674 </div>675 </Show>676 </div>677 </Show>678 </Show>679680 {}681 <Show when={totpState()}>682 {(state) => (683 <section class="peach-totp-band">684 <Show685 when={editingField() === "totp"}686 fallback={687 <>688 <div689 class="peach-totp-main peach-inline-clickable"690 onClick={() => startEdit("totp", entry().totp ?? "")}691 >692 <span class="peach-label">2-factor</span>693 <div class="peach-totp-code-row">694 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>695 <span class="peach-totp-code-text">696 {state().code.slice(0, 3)} {state().code.slice(3)}697 </span>698 </p>699 <button700 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}701 onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}702 aria-label="Copy TOTP code"703 >704 <CopyGlyph /><span class="peach-copy-sheen" />705 </button>706 </div>707 </div>708 <div class="peach-totp-meta">709 <span class="peach-label">expires</span>710 <div class="peach-totp-countdown">{state().remaining}s</div>711 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">712 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />713 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"714 stroke-dasharray="106.8"715 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}716 />717 </svg>718 </div>719 </>720 }721 >722 <div723 class="peach-inline-field-wrap peach-inline-field-wrap--totp"724 onFocusOut={(event) => handleEditorFocusOut("totp", event)}725 >726 <span class="peach-label">2-factor</span>727 <input728 class="peach-inline-input"729 value={draft()}730 onInput={(e) => setDraft(e.currentTarget.value)}731 onKeyDown={onEditKeyDown}732 ref={autoFocus}733 disabled={saving()}734 placeholder="otpauth://... or base32 secret"735 autocomplete="off"736 />737 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />738 </div>739 </Show>740 </section>741 )}742 </Show>743744 {}745 <div style="height:4px;" />746 </>747 )}748 </Show>749 </div>750751 <Show when={noteEditing()}>752 <Portal mount={document.getElementById("hd-shell") ?? document.body}>753 <NoteEditor754 initialContent={props.entry?.notes ?? ""}755 saving={saving()}756 onSave={saveNote}757 onCancel={() => setNoteEditing(false)}758 />759 </Portal>760 </Show>761 </>762 );763}764765766767function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {768 return (769 <div class="peach-inline-actions">770 <button771 class="peach-inline-save-btn"772 onClick={props.onSave}773 disabled={props.saving}774 aria-label="Save"775 title="Save (Enter)"776 >777 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">778 <path d="M1 4.5L4 7.5L10 1.5" />779 </svg>780 </button>781 <button782 class="peach-inline-cancel-btn"783 onClick={props.onCancel}784 disabled={props.saving}785 aria-label="Cancel"786 title="Cancel (Esc)"787 >788 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">789 <path d="M1 1L8 8M8 1L1 8" />790 </svg>791 </button>792 </div>793 );794}795796function EmptyVaultState() {797 return (798 <>799 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">800 <div class="peach-entry-name">801 <span class="peach-entry-name-text">802 <span class="peach-entry-name-char" style="--char-index:0">e</span>803 <span class="peach-entry-name-char" style="--char-index:1">m</span>804 <span class="peach-entry-name-char" style="--char-index:2">p</span>805 <span class="peach-entry-name-char" style="--char-index:3">t</span>806 <span class="peach-entry-name-char" style="--char-index:4">y</span>807 </span>808 </div>809 </div>810 <div class="peach-empty-copy">811 <span class="peach-label">vault</span>812 <p>Your vault is ready for its first entry.</p>813 </div>814 </>815 );816}817818function CopyGlyph() {819 return (820 <span class="peach-copy-icon">821 <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">822 <rect x="4" y="4" width="8" height="9" rx="1.5" />823 <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" />824 </svg>825 </span>826 );827}828829function EyeClosedGlyph() {830 return (831 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">832 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />833 <path d="M3.5 5.5L2.5 7.5" />834 <path d="M7 6.5V8.5" />835 <path d="M10.5 5.5L11.5 7.5" />836 </svg>837 );838}839840function EyeOpenGlyph() {841 return (842 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">843 <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" />844 <circle cx="7" cy="5" r="1.75" />845 </svg>846 );847}848849function ChevronGlyph(props: { open: boolean }) {850 return (851 <svg852 width="8"853 height="8"854 viewBox="0 0 8 8"855 fill="none"856 stroke="currentColor"857 stroke-width="1.3"858 stroke-linecap="round"859 stroke-linejoin="round"860 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}861 >862 <path d="M2 1L6 4L2 7" />863 </svg>864 );865}866867function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {868 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));869 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");870 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");871 const [testResult, setTestResult] = createSignal<string | null>(null);872 const [testing, setTesting] = createSignal(false);873874 async function save() {875 const url = webhookUrl().trim();876 const nextLabel = label().trim() || undefined;877 const canary = enabled() && url878 ? {879 webhookUrl: url,880 label: nextLabel,881 lastPinged: props.entry.canary?.lastPinged,882 pingCount: props.entry.canary?.pingCount,883 }884 : undefined;885 await sendMessage({886 type: "UPDATE_ENTRY",887 payload: { id: props.entry.id, patch: { canary } },888 });889 await props.onSaved();890 }891892 async function testPing() {893 const url = webhookUrl().trim();894 if (!url) return;895 setTesting(true);896 setTestResult(null);897 const res = await sendMessage({898 type: "FIRE_CANARY_PING",899 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },900 }) as { ok: boolean; error?: string };901 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);902 setTesting(false);903 }904905 return (906 <div class="peach-canary-section">907 <div class="peach-field-value-row">908 <span class="peach-field-value" style="font-size:12px;">canary alert</span>909 <button910 class={`peach-toggle ${enabled() ? "is-on" : ""}`}911 onClick={() => { setEnabled(!enabled()); void save(); }}912 aria-label="Toggle canary"913 >914 <span class="peach-toggle-knob" />915 </button>916 </div>917 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">918 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.919 </p>920 <Show when={enabled()}>921 <div class="peach-inline-field-wrap" style="margin-top:8px;">922 <input923 class="peach-inline-input"924 placeholder="https://hooks.zapier.com/..."925 value={webhookUrl()}926 onInput={(e) => setWebhookUrl(e.currentTarget.value)}927 onBlur={() => void save()}928 />929 <input930 class="peach-inline-input"931 placeholder="label (optional, e.g. AWS decoy)"932 value={label()}933 onInput={(e) => setLabel(e.currentTarget.value)}934 onBlur={() => void save()}935 />936 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">937 <button938 class="peach-btn peach-btn--ghost peach-btn--sm"939 onClick={() => void testPing()}940 disabled={testing() || !webhookUrl().trim()}941 >942 {testing() ? "sending..." : "test ping"}943 </button>944 <Show when={testResult()}>945 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>946 </Show>947 </div>948 <Show when={props.entry.canary?.lastPinged}>949 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">950 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}951 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts952 </p>953 </Show>954 </div>955 </Show>956 </div>957 );958}