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 { GeneratorDrawer } from "../components/GeneratorDrawer";8import { NoteEditor } from "./NoteEditor";910type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1112interface EntryProps {13 entry: VaultEntry | null;14 onDelete: () => void;15 16 onFieldSaved: () => void | Promise<void>;17 onEscape?: () => void;18}1920export function EntryView(props: EntryProps) {21 const [editingField, setEditingField] = createSignal<EditableField>(null);22 const [draft, setDraft] = createSignal("");23 const [saving, setSaving] = createSignal(false);24 const [showPassword, setShowPassword] = createSignal(false);25 const [showGenerator, setShowGenerator] = createSignal(false);26 const [copied, setCopied] = createSignal<string | null>(null);27 const [totpTick, setTotpTick] = createSignal(0);28 const [confirmDelete, setConfirmDelete] = createSignal(false);29 const [totpHot, setTotpHot] = createSignal(false);30 const [noteEditing, setNoteEditing] = createSignal(false);31 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);3233 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);34 onCleanup(() => window.clearInterval(interval));3536 const displayDomain = createMemo(() =>37 props.entry?.url ? getDisplayDomain(props.entry.url) : ""38 );3940 createEffect(() => {41 const domain = displayDomain();42 setFaviconSrc(domain ? faviconUrl(domain) : null);43 });4445 const totpState = createMemo(() => {46 void totpTick();47 if (!props.entry?.totp) return null;48 return generateTOTP(props.entry.totp);49 });5051 let prevTotpCode: string | undefined;52 createEffect(() => {53 const state = totpState();54 if (!state) { prevTotpCode = undefined; return; }55 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {56 setTotpHot(true);57 window.setTimeout(() => setTotpHot(false), 3000);58 }59 prevTotpCode = state.code;60 });6162 const totpProgress = createMemo(() => {63 const state = totpState();64 if (!state) return 0;65 return ((state.remaining - 1) / 29) * 100;66 });6768 createEffect(() => {69 const shell = document.getElementById("hd-shell");70 if (!shell) return;7172 const state = totpState();73 if (!state) {74 delete shell.dataset.totpActive;75 shell.style.removeProperty("--peach-totp-progress");76 return;77 }7879 shell.dataset.totpActive = "true";80 shell.style.setProperty(81 "--peach-totp-progress",82 `${Math.max(0, totpProgress())}%`83 );8485 onCleanup(() => {86 delete shell.dataset.totpActive;87 shell.style.removeProperty("--peach-totp-progress");88 });89 });9091 const titleSizeClass = createMemo(() => {92 const length = props.entry?.title.length ?? 0;93 if (length >= 28) return "peach-entry-name--xxl";94 if (length >= 20) return "peach-entry-name--xl";95 if (length >= 14) return "peach-entry-name--lg";96 return "";97 });98 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);99100 101102 function startEdit(field: EditableField, value: string) {103 setEditingField(field);104 setDraft(value);105 setShowGenerator(false);106 setConfirmDelete(false);107 }108109 async function saveField() {110 const field = editingField();111 const entry = props.entry;112 if (!field || !entry) return;113 114 let value = field === "password" ? draft() : draft().trim();115 if (field === "totp" && value.startsWith("otpauth://")) {116 const parsed = parseOtpauthURI(value);117 value = parsed?.secret ?? value;118 }119 if (field === "password" && entry.type !== "note" && !value) {120 return;121 }122 setSaving(true);123 try {124 await sendMessage({125 type: "UPDATE_ENTRY",126 payload: { id: entry.id, patch: { [field]: value } },127 });128 triggerRing();129 setEditingField(null);130 setShowGenerator(false);131 await props.onFieldSaved();132 } catch {133 134 } finally {135 setSaving(false);136 }137 }138139 function cancelEdit() {140 setEditingField(null);141 setDraft("");142 setShowGenerator(false);143 }144145 async function saveNote(html: string) {146 const entry = props.entry;147 if (!entry) return;148 setSaving(true);149 try {150 await sendMessage({151 type: "UPDATE_ENTRY",152 payload: { id: entry.id, patch: { notes: html } },153 });154 triggerRing();155 setNoteEditing(false);156 await props.onFieldSaved();157 } catch {158 159 } finally {160 setSaving(false);161 }162 }163164 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {165 switch (field) {166 case "title":167 return entry.title;168 case "url":169 return entry.url ?? "";170 case "username":171 return entry.username ?? "";172 case "password":173 return entry.password ?? "";174 case "notes":175 return entry.notes ?? "";176 case "totp":177 return entry.totp ?? "";178 }179 }180181 function handleEditorFocusOut(182 field: Exclude<EditableField, null>,183 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }184 ) {185 const nextTarget = event.relatedTarget;186 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;187188 const entry = props.entry;189 if (!entry) return;190191 const currentValue = fieldValue(entry, field);192 const nextValue = field === "password" ? draft() : draft().trim();193 if (nextValue === currentValue) {194 cancelEdit();195 }196 }197198 async function savePassword(password: string) {199 const entry = props.entry;200 if (!entry) return;201 setSaving(true);202 try {203 await sendMessage({204 type: "UPDATE_ENTRY",205 payload: { id: entry.id, patch: { password } },206 });207 triggerRing();208 setShowGenerator(false);209 await props.onFieldSaved();210 } catch {211 212 } finally {213 setSaving(false);214 }215 }216217 218219 async function copyField(text: string, label: string, button?: HTMLButtonElement) {220 const response = await sendMessage({ type: "COPY", payload: { text, label } });221 if ((response as { ok: boolean }).ok) {222 const entry = props.entry;223 if (entry?.canary?.webhookUrl) {224 const trigger =225 label === "password"226 ? "password_copied"227 : label === "username"228 ? "username_copied"229 : undefined;230 if (trigger) {231 void sendMessage({232 type: "FIRE_CANARY_PING",233 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },234 });235 }236 }237 triggerRing();238 triggerPipPulse();239 setCopied(null);240 requestAnimationFrame(() => setCopied(label));241 if (button) {242 button.classList.remove("is-copied", "is-retreat");243 void button.offsetWidth;244 const drain = document.createElement("span");245 drain.className = "peach-copy-drain";246 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";247 drain.appendChild(bar);248 button.appendChild(drain);249 window.setTimeout(() => {250 button.classList.remove("is-copied");251 button.classList.add("is-retreat");252 drain.remove();253 window.setTimeout(() => button.classList.remove("is-retreat"), 160);254 }, 1800);255 }256 window.setTimeout(() => setCopied(null), 1800);257 }258 }259260 261262 createEffect(() => {263 if (!props.entry) return;264 const handler = (e: KeyboardEvent) => {265 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;266 if (inInput) return;267 switch (e.key) {268 case "c": e.preventDefault();269 if (props.entry?.password) void copyField(props.entry.password, "password"); break;270 case "u": e.preventDefault();271 if (props.entry?.username) void copyField(props.entry.username, "username"); break;272 case "Escape": e.preventDefault(); props.onEscape?.(); break;273 }274 };275 window.addEventListener("keydown", handler);276 onCleanup(() => window.removeEventListener("keydown", handler));277 });278279 280281 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {282 requestAnimationFrame(() => { el.focus(); el.select?.(); });283 }284285 function onEditKeyDown(e: KeyboardEvent) {286 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {287 e.preventDefault();288 void saveField();289 }290 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }291 }292293 return (294 <>295 <div296 class="peach-ghost"297 aria-hidden="true"298 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;"299 >300 {props.entry?.title || "empty"}301 </div>302303 <div class="peach-stage" style="position:relative;">304 <Show when={props.entry} fallback={<EmptyVaultState />}>305 {(entry) => (306 <>307 {}308 <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>309 <div class="peach-breach-badge">310 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">311 <path d="M6 1L11 10H1L6 1z" />312 <line x1="6" y1="5" x2="6" y2="7.5" />313 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />314 </svg>315 <span style="flex:1">found in a known data breach</span>316 <button317 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"318 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}319 >320 Change password321 </button>322 </div>323 </Show>324325 {}326 <div class="peach-entry-header">327 <div class="peach-entry-header-name">328 <Show329 when={editingField() === "title"}330 fallback={331 <button332 class="peach-entry-name-button"333 title="Click to edit title"334 onClick={() => startEdit("title", entry().title)}335 >336 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>337 <span class="peach-entry-name-text">338 <Show339 when={useAnimatedTitle()}340 fallback={entry().title}341 >342 <For each={entry().title.split("")}>343 {(ch, i) => (344 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>345 {ch === " " ? "\u00A0" : ch}346 </span>347 )}348 </For>349 </Show>350 </span>351 </h1>352 </button>353 }354 >355 <div356 class="peach-inline-title-wrap"357 onFocusOut={(event) => handleEditorFocusOut("title", event)}358 >359 <input360 class="peach-inline-title-input"361 value={draft()}362 onInput={(e) => setDraft(e.currentTarget.value)}363 onKeyDown={onEditKeyDown}364 ref={autoFocus}365 disabled={saving()}366 />367 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />368 </div>369 </Show>370 </div>371372 {}373 <Show when={editingField() !== "title"}>374 <Show375 when={confirmDelete()}376 fallback={377 <button378 class="peach-entry-trash-btn"379 onClick={() => setConfirmDelete(true)}380 aria-label="Move to trash"381 title="Delete entry"382 >383 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">384 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />385 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />386 </svg>387 </button>388 }389 >390 <div class="peach-entry-trash-confirm">391 <span class="peach-entry-trash-confirm-label">trash?</span>392 <button393 class="peach-btn peach-btn--sm peach-btn--danger"394 style="font-size:10px;padding:2px 8px;min-height:22px;"395 onClick={() => { setConfirmDelete(false); props.onDelete(); }}396 >397 yes398 </button>399 <button400 class="peach-btn peach-btn--ghost peach-btn--sm"401 style="font-size:10px;padding:2px 8px;min-height:22px;"402 onClick={() => setConfirmDelete(false)}403 >404 no405 </button>406 </div>407 </Show>408 </Show>409 </div>410411 {}412 <Show when={entry().type !== "note"}>413 <div class="peach-domain" style="position:relative;z-index:1;">414 <Show415 when={editingField() === "url"}416 fallback={417 <div418 class="peach-domain-row peach-inline-clickable"419 title="Click to edit URL"420 onClick={() => startEdit("url", entry().url ?? "")}421 >422 <Show when={faviconSrc()}>423 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />424 </Show>425 <span class="peach-domain-value">426 {displayDomain() || (entry().url ? entry().url : "add a website url")}427 </span>428 <span class="peach-inline-edit-hint">edit</span>429 </div>430 }431 >432 <div433 class="peach-inline-field-wrap"434 onFocusOut={(event) => handleEditorFocusOut("url", event)}435 >436 <input437 class="peach-inline-input"438 type="url"439 placeholder="https://"440 value={draft()}441 onInput={(e) => setDraft(e.currentTarget.value)}442 onKeyDown={onEditKeyDown}443 ref={autoFocus}444 disabled={saving()}445 />446 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />447 </div>448 </Show>449 </div>450 </Show>451452 {}453 <Show when={entry().tags && entry().tags!.length > 0}>454 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">455 <For each={entry().tags!}>456 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}457 </For>458 </div>459 </Show>460461 {}462 <Show when={entry().type === "note"}>463 <section class="peach-fields" style="position:relative;z-index:1;">464 <Show465 when={entry().notes}466 fallback={467 <div468 class="peach-note-content peach-inline-clickable"469 style="cursor:text;"470 onClick={() => setNoteEditing(true)}471 >472 <span style="color:var(--peach-t2);font-style:italic;">click to add notes</span>473 </div>474 }475 >476 <div477 class="peach-note-content peach-inline-clickable"478 innerHTML={entry().notes}479 onClick={() => setNoteEditing(true)}480 />481 </Show>482 </section>483 </Show>484485 {}486 <Show when={entry().type !== "note"}>487 <section class="peach-fields" style="position:relative;z-index:1;">488489 {}490 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>491 <span class="peach-label">username</span>492 <Show493 when={editingField() === "username"}494 fallback={495 <div class="peach-field-value-row">496 <span497 class="peach-field-value peach-inline-clickable"498 title="Click to edit"499 onClick={() => startEdit("username", entry().username ?? "")}500 >501 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}502 </span>503 <Show when={entry().username}>504 <button505 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}506 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}507 aria-label="Copy username"508 >509 <CopyGlyph /><span class="peach-copy-sheen" />510 </button>511 </Show>512 </div>513 }514 >515 <div516 class="peach-inline-field-wrap"517 onFocusOut={(event) => handleEditorFocusOut("username", event)}518 >519 <input520 class="peach-inline-input"521 value={draft()}522 onInput={(e) => setDraft(e.currentTarget.value)}523 onKeyDown={onEditKeyDown}524 ref={autoFocus}525 disabled={saving()}526 autocomplete="off"527 />528 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />529 </div>530 </Show>531 </div>532533 {}534 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>535 <span class="peach-label">password</span>536 <Show537 when={editingField() === "password"}538 fallback={539 <div class="peach-field-value-row">540 <span541 class="peach-password-value"542 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}543 style="cursor:pointer;"544 title="Click to edit password"545 onClick={() => startEdit("password", entry().password ?? "")}546 >547 <Show548 when={showPassword()}549 fallback={<span class="peach-password-mask">...............</span>}550 >551 <span class="peach-password-text">{entry().password ?? ""}</span>552 </Show>553 </span>554 <div class="peach-field-actions">555 <button556 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"557 onClick={() => setShowPassword(!showPassword())}558 aria-label="Reveal password"559 >560 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>561 <EyeClosedGlyph />562 </Show>563 </button>564 <button565 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}566 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}567 aria-label="Copy password"568 >569 <CopyGlyph /><span class="peach-copy-sheen" />570 </button>571 </div>572 </div>573 }574 >575 {}576 <div577 class="peach-inline-field-wrap"578 onFocusOut={(event) => handleEditorFocusOut("password", event)}579 >580 <div class="peach-password-edit-row">581 <input582 class="peach-inline-input"583 type={showPassword() ? "text" : "password"}584 placeholder="paste or type a password"585 value={draft()}586 onInput={(e) => setDraft(e.currentTarget.value)}587 onKeyDown={(e) => {588 if (e.key === "Enter") { e.preventDefault(); void saveField(); }589 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }590 }}591 ref={autoFocus}592 disabled={saving()}593 autocomplete="new-password"594 />595 <button596 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"597 type="button"598 onClick={() => setShowPassword(!showPassword())}599 aria-label={showPassword() ? "Hide" : "Show"}600 >601 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>602 <EyeClosedGlyph />603 </Show>604 </button>605 </div>606 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />607 <button608 class="peach-generate-toggle-link"609 type="button"610 onClick={() => setShowGenerator(!showGenerator())}611 >612 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">613 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />614 <path d="M9.5 2.5H11V1" />615 </svg>616 {showGenerator() ? "hide generator" : "generate"}617 </button>618 <Show when={showGenerator()}>619 <GeneratorDrawer620 open={true}621 compact={true}622 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}623 />624 </Show>625 </div>626 </Show>627 </div>628629 </section>630 </Show>631632 {}633 <Show when={entry().type !== "note"}>634 <Show635 when={entry().notes || editingField() === "notes"}636 fallback={637 <button638 class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"639 onClick={() => startEdit("notes", "")}640 >641 + add notes642 </button>643 }644 >645 <div style="position:relative;z-index:1;margin-top:6px;">646 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>647 <Show648 when={editingField() === "notes"}649 fallback={650 <div651 class="peach-note-content peach-inline-clickable"652 style="cursor:text;white-space:pre-wrap;"653 onClick={() => startEdit("notes", entry().notes ?? "")}654 >655 {entry().notes}656 </div>657 }658 >659 <div660 class="peach-inline-field-wrap"661 onFocusOut={(event) => handleEditorFocusOut("notes", event)}662 >663 <textarea664 class="peach-inline-textarea"665 value={draft()}666 onInput={(e) => setDraft(e.currentTarget.value)}667 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}668 ref={(el) => requestAnimationFrame(() => el?.focus())}669 rows={4}670 disabled={saving()}671 />672 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />673 </div>674 </Show>675 </div>676 </Show>677 </Show>678679 {}680 <Show when={totpState()}>681 {(state) => (682 <section class="peach-totp-band">683 <Show684 when={editingField() === "totp"}685 fallback={686 <>687 <div688 class="peach-totp-main peach-inline-clickable"689 onClick={() => startEdit("totp", entry().totp ?? "")}690 >691 <span class="peach-label">2-factor</span>692 <div class="peach-totp-code-row">693 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>694 <span class="peach-totp-code-text">695 {state().code.slice(0, 3)} {state().code.slice(3)}696 </span>697 </p>698 <button699 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}700 onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}701 aria-label="Copy TOTP code"702 >703 <CopyGlyph /><span class="peach-copy-sheen" />704 </button>705 </div>706 </div>707 <div class="peach-totp-meta">708 <span class="peach-label">expires</span>709 <div class="peach-totp-countdown">{state().remaining}s</div>710 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">711 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />712 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"713 stroke-dasharray="106.8"714 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}715 />716 </svg>717 </div>718 </>719 }720 >721 <div722 class="peach-inline-field-wrap peach-inline-field-wrap--totp"723 onFocusOut={(event) => handleEditorFocusOut("totp", event)}724 >725 <span class="peach-label">2-factor</span>726 <input727 class="peach-inline-input"728 value={draft()}729 onInput={(e) => setDraft(e.currentTarget.value)}730 onKeyDown={onEditKeyDown}731 ref={autoFocus}732 disabled={saving()}733 placeholder="otpauth://... or base32 secret"734 autocomplete="off"735 />736 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />737 </div>738 </Show>739 </section>740 )}741 </Show>742743 {}744 <div style="height:4px;" />745 </>746 )}747 </Show>748 </div>749750 <Show when={noteEditing()}>751 <NoteEditor752 initialContent={props.entry?.notes ?? ""}753 saving={saving()}754 onSave={saveNote}755 onCancel={() => setNoteEditing(false)}756 />757 </Show>758 </>759 );760}761762763764function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {765 return (766 <div class="peach-inline-actions">767 <button768 class="peach-inline-save-btn"769 onClick={props.onSave}770 disabled={props.saving}771 aria-label="Save"772 title="Save (Enter)"773 >774 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">775 <path d="M1 4.5L4 7.5L10 1.5" />776 </svg>777 </button>778 <button779 class="peach-inline-cancel-btn"780 onClick={props.onCancel}781 disabled={props.saving}782 aria-label="Cancel"783 title="Cancel (Esc)"784 >785 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">786 <path d="M1 1L8 8M8 1L1 8" />787 </svg>788 </button>789 </div>790 );791}792793function EmptyVaultState() {794 return (795 <>796 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">797 <div class="peach-entry-name">798 <span class="peach-entry-name-text">799 <span class="peach-entry-name-char" style="--char-index:0">e</span>800 <span class="peach-entry-name-char" style="--char-index:1">m</span>801 <span class="peach-entry-name-char" style="--char-index:2">p</span>802 <span class="peach-entry-name-char" style="--char-index:3">t</span>803 <span class="peach-entry-name-char" style="--char-index:4">y</span>804 </span>805 </div>806 </div>807 <div class="peach-empty-copy">808 <span class="peach-label">vault</span>809 <p>Your vault is ready for its first entry.</p>810 </div>811 </>812 );813}814815function CopyGlyph() {816 return (817 <span class="peach-copy-icon">818 <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">819 <rect x="4" y="4" width="8" height="9" rx="1.5" />820 <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" />821 </svg>822 </span>823 );824}825826function EyeClosedGlyph() {827 return (828 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">829 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />830 <path d="M3.5 5.5L2.5 7.5" />831 <path d="M7 6.5V8.5" />832 <path d="M10.5 5.5L11.5 7.5" />833 </svg>834 );835}836837function EyeOpenGlyph() {838 return (839 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">840 <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" />841 <circle cx="7" cy="5" r="1.75" />842 </svg>843 );844}845846function ChevronGlyph(props: { open: boolean }) {847 return (848 <svg849 width="8"850 height="8"851 viewBox="0 0 8 8"852 fill="none"853 stroke="currentColor"854 stroke-width="1.3"855 stroke-linecap="round"856 stroke-linejoin="round"857 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}858 >859 <path d="M2 1L6 4L2 7" />860 </svg>861 );862}863864function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {865 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));866 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");867 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");868 const [testResult, setTestResult] = createSignal<string | null>(null);869 const [testing, setTesting] = createSignal(false);870871 async function save() {872 const url = webhookUrl().trim();873 const nextLabel = label().trim() || undefined;874 const canary = enabled() && url875 ? {876 webhookUrl: url,877 label: nextLabel,878 lastPinged: props.entry.canary?.lastPinged,879 pingCount: props.entry.canary?.pingCount,880 }881 : undefined;882 await sendMessage({883 type: "UPDATE_ENTRY",884 payload: { id: props.entry.id, patch: { canary } },885 });886 await props.onSaved();887 }888889 async function testPing() {890 const url = webhookUrl().trim();891 if (!url) return;892 setTesting(true);893 setTestResult(null);894 const res = await sendMessage({895 type: "FIRE_CANARY_PING",896 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },897 }) as { ok: boolean; error?: string };898 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);899 setTesting(false);900 }901902 return (903 <div class="peach-canary-section">904 <div class="peach-field-value-row">905 <span class="peach-field-value" style="font-size:12px;">canary alert</span>906 <button907 class={`peach-toggle ${enabled() ? "is-on" : ""}`}908 onClick={() => { setEnabled(!enabled()); void save(); }}909 aria-label="Toggle canary"910 >911 <span class="peach-toggle-knob" />912 </button>913 </div>914 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">915 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.916 </p>917 <Show when={enabled()}>918 <div class="peach-inline-field-wrap" style="margin-top:8px;">919 <input920 class="peach-inline-input"921 placeholder="https://hooks.zapier.com/..."922 value={webhookUrl()}923 onInput={(e) => setWebhookUrl(e.currentTarget.value)}924 onBlur={() => void save()}925 />926 <input927 class="peach-inline-input"928 placeholder="label (optional, e.g. AWS decoy)"929 value={label()}930 onInput={(e) => setLabel(e.currentTarget.value)}931 onBlur={() => void save()}932 />933 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">934 <button935 class="peach-btn peach-btn--ghost peach-btn--sm"936 onClick={() => void testPing()}937 disabled={testing() || !webhookUrl().trim()}938 >939 {testing() ? "sending..." : "test ping"}940 </button>941 <Show when={testResult()}>942 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>943 </Show>944 </div>945 <Show when={props.entry.canary?.lastPinged}>946 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">947 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}948 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts949 </p>950 </Show>951 </div>952 </Show>953 </div>954 );955}