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 } 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 const value = field === "password" ? draft() : draft().trim();109 if (field === "password" && entry.type !== "note" && !value) {110 return;111 }112 setSaving(true);113 try {114 await sendMessage({115 type: "UPDATE_ENTRY",116 payload: { id: entry.id, patch: { [field]: value } },117 });118 triggerRing();119 setEditingField(null);120 setShowGenerator(false);121 await props.onFieldSaved();122 } catch {123 124 } finally {125 setSaving(false);126 }127 }128129 function cancelEdit() {130 setEditingField(null);131 setDraft("");132 setShowGenerator(false);133 }134135 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {136 switch (field) {137 case "title":138 return entry.title;139 case "url":140 return entry.url ?? "";141 case "username":142 return entry.username ?? "";143 case "password":144 return entry.password ?? "";145 case "notes":146 return entry.notes ?? "";147 case "totp":148 return entry.totp ?? "";149 }150 }151152 function handleEditorFocusOut(153 field: Exclude<EditableField, null>,154 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }155 ) {156 const nextTarget = event.relatedTarget;157 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;158159 const entry = props.entry;160 if (!entry) return;161162 const currentValue = fieldValue(entry, field);163 const nextValue = field === "password" ? draft() : draft().trim();164 if (nextValue === currentValue) {165 cancelEdit();166 }167 }168169 async function savePassword(password: string) {170 const entry = props.entry;171 if (!entry) return;172 setSaving(true);173 try {174 await sendMessage({175 type: "UPDATE_ENTRY",176 payload: { id: entry.id, patch: { password } },177 });178 triggerRing();179 setShowGenerator(false);180 await props.onFieldSaved();181 } catch {182 183 } finally {184 setSaving(false);185 }186 }187188 189190 async function copyField(text: string, label: string, button?: HTMLButtonElement) {191 const response = await sendMessage({ type: "COPY", payload: { text, label } });192 if ((response as { ok: boolean }).ok) {193 const entry = props.entry;194 if (entry?.canary?.webhookUrl) {195 const trigger =196 label === "password"197 ? "password_copied"198 : label === "username"199 ? "username_copied"200 : undefined;201 if (trigger) {202 void sendMessage({203 type: "FIRE_CANARY_PING",204 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },205 });206 }207 }208 triggerRing();209 triggerPipPulse();210 setCopied(null);211 requestAnimationFrame(() => setCopied(label));212 if (button) {213 button.classList.remove("is-copied", "is-retreat");214 void button.offsetWidth;215 const drain = document.createElement("span");216 drain.className = "peach-copy-drain";217 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";218 drain.appendChild(bar);219 button.appendChild(drain);220 window.setTimeout(() => {221 button.classList.remove("is-copied");222 button.classList.add("is-retreat");223 drain.remove();224 window.setTimeout(() => button.classList.remove("is-retreat"), 160);225 }, 1800);226 }227 window.setTimeout(() => setCopied(null), 1800);228 }229 }230231 232233 createEffect(() => {234 if (!props.entry) return;235 const handler = (e: KeyboardEvent) => {236 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;237 if (inInput) return;238 switch (e.key) {239 case "c": e.preventDefault();240 if (props.entry?.password) void copyField(props.entry.password, "password"); break;241 case "u": e.preventDefault();242 if (props.entry?.username) void copyField(props.entry.username, "username"); break;243 case "Escape": e.preventDefault(); props.onEscape?.(); break;244 }245 };246 window.addEventListener("keydown", handler);247 onCleanup(() => window.removeEventListener("keydown", handler));248 });249250 251252 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {253 requestAnimationFrame(() => { el.focus(); el.select?.(); });254 }255256 function onEditKeyDown(e: KeyboardEvent) {257 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {258 e.preventDefault();259 void saveField();260 }261 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }262 }263264 return (265 <>266 <div267 class="peach-ghost"268 aria-hidden="true"269 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;"270 >271 {props.entry?.title || "empty"}272 </div>273274 <div class="peach-stage" style="position:relative;">275 <Show when={props.entry} fallback={<EmptyVaultState />}>276 {(entry) => (277 <>278 {}279 <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>280 <div class="peach-breach-badge">281 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">282 <path d="M6 1L11 10H1L6 1z" />283 <line x1="6" y1="5" x2="6" y2="7.5" />284 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />285 </svg>286 <span style="flex:1">found in a known data breach</span>287 <button288 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"289 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}290 >291 Change password292 </button>293 </div>294 </Show>295296 {}297 <div class="peach-entry-header">298 <div class="peach-entry-header-name">299 <Show300 when={editingField() === "title"}301 fallback={302 <button303 class="peach-entry-name-button"304 title="Click to edit title"305 onClick={() => startEdit("title", entry().title)}306 >307 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>308 <span class="peach-entry-name-text">309 <Show310 when={useAnimatedTitle()}311 fallback={entry().title}312 >313 <For each={entry().title.split("")}>314 {(ch, i) => (315 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>316 {ch === " " ? "\u00A0" : ch}317 </span>318 )}319 </For>320 </Show>321 </span>322 </h1>323 </button>324 }325 >326 <div327 class="peach-inline-title-wrap"328 onFocusOut={(event) => handleEditorFocusOut("title", event)}329 >330 <input331 class="peach-inline-title-input"332 value={draft()}333 onInput={(e) => setDraft(e.currentTarget.value)}334 onKeyDown={onEditKeyDown}335 ref={autoFocus}336 disabled={saving()}337 />338 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />339 </div>340 </Show>341 </div>342343 {}344 <Show when={editingField() !== "title"}>345 <Show346 when={confirmDelete()}347 fallback={348 <button349 class="peach-entry-trash-btn"350 onClick={() => setConfirmDelete(true)}351 aria-label="Move to trash"352 title="Delete entry"353 >354 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">355 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />356 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />357 </svg>358 </button>359 }360 >361 <div class="peach-entry-trash-confirm">362 <span class="peach-entry-trash-confirm-label">trash?</span>363 <button364 class="peach-btn peach-btn--sm peach-btn--danger"365 style="font-size:10px;padding:2px 8px;min-height:22px;"366 onClick={() => { setConfirmDelete(false); props.onDelete(); }}367 >368 yes369 </button>370 <button371 class="peach-btn peach-btn--ghost peach-btn--sm"372 style="font-size:10px;padding:2px 8px;min-height:22px;"373 onClick={() => setConfirmDelete(false)}374 >375 no376 </button>377 </div>378 </Show>379 </Show>380 </div>381382 {}383 <Show when={entry().type !== "note"}>384 <div class="peach-domain" style="position:relative;z-index:1;">385 <Show386 when={editingField() === "url"}387 fallback={388 <div389 class="peach-domain-row peach-inline-clickable"390 title="Click to edit URL"391 onClick={() => startEdit("url", entry().url ?? "")}392 >393 <Show when={faviconSrc()}>394 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />395 </Show>396 <span class="peach-domain-value">397 {displayDomain() || (entry().url ? entry().url : "add a website url")}398 </span>399 <span class="peach-inline-edit-hint">edit</span>400 </div>401 }402 >403 <div404 class="peach-inline-field-wrap"405 onFocusOut={(event) => handleEditorFocusOut("url", event)}406 >407 <input408 class="peach-inline-input"409 type="url"410 placeholder="https://"411 value={draft()}412 onInput={(e) => setDraft(e.currentTarget.value)}413 onKeyDown={onEditKeyDown}414 ref={autoFocus}415 disabled={saving()}416 />417 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />418 </div>419 </Show>420 </div>421 </Show>422423 {}424 <Show when={entry().tags && entry().tags!.length > 0}>425 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">426 <For each={entry().tags!}>427 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}428 </For>429 </div>430 </Show>431432 {}433 <Show when={entry().type === "note"}>434 <section class="peach-fields" style="position:relative;z-index:1;">435 <Show436 when={editingField() === "notes"}437 fallback={438 <div439 class="peach-note-content peach-inline-clickable"440 style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"441 title="Click to edit"442 onClick={() => startEdit("notes", entry().notes ?? "")}443 >444 {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}445 </div>446 }447 >448 <div449 class="peach-inline-field-wrap"450 onFocusOut={(event) => handleEditorFocusOut("notes", event)}451 >452 <textarea453 class="peach-inline-textarea"454 value={draft()}455 onInput={(e) => setDraft(e.currentTarget.value)}456 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}457 ref={(el) => requestAnimationFrame(() => el?.focus())}458 rows={6}459 disabled={saving()}460 />461 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />462 </div>463 </Show>464 </section>465 </Show>466467 {}468 <Show when={entry().type !== "note"}>469 <section class="peach-fields" style="position:relative;z-index:1;">470471 {}472 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>473 <span class="peach-label">username</span>474 <Show475 when={editingField() === "username"}476 fallback={477 <div class="peach-field-value-row">478 <span479 class="peach-field-value peach-inline-clickable"480 title="Click to edit"481 onClick={() => startEdit("username", entry().username ?? "")}482 >483 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}484 </span>485 <Show when={entry().username}>486 <button487 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}488 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}489 aria-label="Copy username"490 >491 <CopyGlyph /><span class="peach-copy-sheen" />492 </button>493 </Show>494 </div>495 }496 >497 <div498 class="peach-inline-field-wrap"499 onFocusOut={(event) => handleEditorFocusOut("username", event)}500 >501 <input502 class="peach-inline-input"503 value={draft()}504 onInput={(e) => setDraft(e.currentTarget.value)}505 onKeyDown={onEditKeyDown}506 ref={autoFocus}507 disabled={saving()}508 autocomplete="off"509 />510 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />511 </div>512 </Show>513 </div>514515 {}516 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>517 <span class="peach-label">password</span>518 <Show519 when={editingField() === "password"}520 fallback={521 <div class="peach-field-value-row">522 <span523 class="peach-password-value"524 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}525 style="cursor:pointer;"526 title="Click to edit password"527 onClick={() => startEdit("password", entry().password ?? "")}528 >529 <Show530 when={showPassword()}531 fallback={<span class="peach-password-mask">...............</span>}532 >533 <span class="peach-password-text">{entry().password ?? ""}</span>534 </Show>535 </span>536 <div class="peach-field-actions">537 <button538 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"539 onClick={() => setShowPassword(!showPassword())}540 aria-label="Reveal password"541 >542 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>543 <EyeClosedGlyph />544 </Show>545 </button>546 <button547 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}548 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}549 aria-label="Copy password"550 >551 <CopyGlyph /><span class="peach-copy-sheen" />552 </button>553 </div>554 </div>555 }556 >557 {}558 <div559 class="peach-inline-field-wrap"560 onFocusOut={(event) => handleEditorFocusOut("password", event)}561 >562 <div class="peach-password-edit-row">563 <input564 class="peach-inline-input"565 type={showPassword() ? "text" : "password"}566 placeholder="paste or type a password"567 value={draft()}568 onInput={(e) => setDraft(e.currentTarget.value)}569 onKeyDown={(e) => {570 if (e.key === "Enter") { e.preventDefault(); void saveField(); }571 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }572 }}573 ref={autoFocus}574 disabled={saving()}575 autocomplete="new-password"576 />577 <button578 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"579 type="button"580 onClick={() => setShowPassword(!showPassword())}581 aria-label={showPassword() ? "Hide" : "Show"}582 >583 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>584 <EyeClosedGlyph />585 </Show>586 </button>587 </div>588 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />589 <button590 class="peach-generate-toggle-link"591 type="button"592 onClick={() => setShowGenerator(!showGenerator())}593 >594 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">595 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />596 <path d="M9.5 2.5H11V1" />597 </svg>598 {showGenerator() ? "hide generator" : "generate"}599 </button>600 <Show when={showGenerator()}>601 <GeneratorDrawer602 open={true}603 compact={true}604 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}605 />606 </Show>607 </div>608 </Show>609 </div>610611 </section>612 </Show>613614 {}615 <Show when={entry().type !== "note"}>616 <Show617 when={entry().notes || editingField() === "notes"}618 fallback={619 <button620 class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"621 onClick={() => startEdit("notes", "")}622 >623 + add notes624 </button>625 }626 >627 <div style="position:relative;z-index:1;margin-top:6px;">628 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>629 <Show630 when={editingField() === "notes"}631 fallback={632 <div633 class="peach-note-content peach-inline-clickable"634 style="cursor:text;white-space:pre-wrap;"635 onClick={() => startEdit("notes", entry().notes ?? "")}636 >637 {entry().notes}638 </div>639 }640 >641 <div642 class="peach-inline-field-wrap"643 onFocusOut={(event) => handleEditorFocusOut("notes", event)}644 >645 <textarea646 class="peach-inline-textarea"647 value={draft()}648 onInput={(e) => setDraft(e.currentTarget.value)}649 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}650 ref={(el) => requestAnimationFrame(() => el?.focus())}651 rows={4}652 disabled={saving()}653 />654 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />655 </div>656 </Show>657 </div>658 </Show>659 </Show>660661 {}662 <Show when={totpState()}>663 {(state) => (664 <section class="peach-totp-band">665 <div class="peach-totp-main">666 <span class="peach-label">2-factor</span>667 <div class="peach-totp-code-row">668 <p class="peach-totp-code">669 <span class="peach-totp-code-text">670 {state().code.slice(0, 3)} {state().code.slice(3)}671 </span>672 </p>673 <button674 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}675 onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}676 aria-label="Copy TOTP code"677 >678 <CopyGlyph /><span class="peach-copy-sheen" />679 </button>680 </div>681 </div>682 <div class="peach-totp-meta">683 <span class="peach-label">expires</span>684 <div class="peach-totp-countdown">{state().remaining}s</div>685 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">686 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />687 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"688 stroke-dasharray="106.8"689 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}690 />691 </svg>692 </div>693 </section>694 )}695 </Show>696697 {}698 <div style="height:4px;" />699 </>700 )}701 </Show>702 </div>703 </>704 );705}706707708709function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {710 return (711 <div class="peach-inline-actions">712 <button713 class="peach-inline-save-btn"714 onClick={props.onSave}715 disabled={props.saving}716 aria-label="Save"717 title="Save (Enter)"718 >719 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">720 <path d="M1 4.5L4 7.5L10 1.5" />721 </svg>722 </button>723 <button724 class="peach-inline-cancel-btn"725 onClick={props.onCancel}726 disabled={props.saving}727 aria-label="Cancel"728 title="Cancel (Esc)"729 >730 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">731 <path d="M1 1L8 8M8 1L1 8" />732 </svg>733 </button>734 </div>735 );736}737738function EmptyVaultState() {739 return (740 <>741 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">742 <div class="peach-entry-name">743 <span class="peach-entry-name-text">744 <span class="peach-entry-name-char" style="--char-index:0">e</span>745 <span class="peach-entry-name-char" style="--char-index:1">m</span>746 <span class="peach-entry-name-char" style="--char-index:2">p</span>747 <span class="peach-entry-name-char" style="--char-index:3">t</span>748 <span class="peach-entry-name-char" style="--char-index:4">y</span>749 </span>750 </div>751 </div>752 <div class="peach-empty-copy">753 <span class="peach-label">vault</span>754 <p>Your vault is ready for its first entry.</p>755 </div>756 </>757 );758}759760function CopyGlyph() {761 return (762 <span class="peach-copy-icon">763 <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">764 <rect x="4" y="4" width="8" height="9" rx="1.5" />765 <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" />766 </svg>767 </span>768 );769}770771function EyeClosedGlyph() {772 return (773 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">774 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />775 <path d="M3.5 5.5L2.5 7.5" />776 <path d="M7 6.5V8.5" />777 <path d="M10.5 5.5L11.5 7.5" />778 </svg>779 );780}781782function EyeOpenGlyph() {783 return (784 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">785 <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" />786 <circle cx="7" cy="5" r="1.75" />787 </svg>788 );789}790791function ChevronGlyph(props: { open: boolean }) {792 return (793 <svg794 width="8"795 height="8"796 viewBox="0 0 8 8"797 fill="none"798 stroke="currentColor"799 stroke-width="1.3"800 stroke-linecap="round"801 stroke-linejoin="round"802 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}803 >804 <path d="M2 1L6 4L2 7" />805 </svg>806 );807}808809function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {810 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));811 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");812 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");813 const [testResult, setTestResult] = createSignal<string | null>(null);814 const [testing, setTesting] = createSignal(false);815816 async function save() {817 const url = webhookUrl().trim();818 const nextLabel = label().trim() || undefined;819 const canary = enabled() && url820 ? {821 webhookUrl: url,822 label: nextLabel,823 lastPinged: props.entry.canary?.lastPinged,824 pingCount: props.entry.canary?.pingCount,825 }826 : undefined;827 await sendMessage({828 type: "UPDATE_ENTRY",829 payload: { id: props.entry.id, patch: { canary } },830 });831 await props.onSaved();832 }833834 async function testPing() {835 const url = webhookUrl().trim();836 if (!url) return;837 setTesting(true);838 setTestResult(null);839 const res = await sendMessage({840 type: "FIRE_CANARY_PING",841 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },842 }) as { ok: boolean; error?: string };843 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);844 setTesting(false);845 }846847 return (848 <div class="peach-canary-section">849 <div class="peach-field-value-row">850 <span class="peach-field-value" style="font-size:12px;">canary alert</span>851 <button852 class={`peach-toggle ${enabled() ? "is-on" : ""}`}853 onClick={() => { setEnabled(!enabled()); void save(); }}854 aria-label="Toggle canary"855 >856 <span class="peach-toggle-knob" />857 </button>858 </div>859 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">860 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.861 </p>862 <Show when={enabled()}>863 <div class="peach-inline-field-wrap" style="margin-top:8px;">864 <input865 class="peach-inline-input"866 placeholder="https://hooks.zapier.com/..."867 value={webhookUrl()}868 onInput={(e) => setWebhookUrl(e.currentTarget.value)}869 onBlur={() => void save()}870 />871 <input872 class="peach-inline-input"873 placeholder="label (optional, e.g. AWS decoy)"874 value={label()}875 onInput={(e) => setLabel(e.currentTarget.value)}876 onBlur={() => void save()}877 />878 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">879 <button880 class="peach-btn peach-btn--ghost peach-btn--sm"881 onClick={() => void testPing()}882 disabled={testing() || !webhookUrl().trim()}883 >884 {testing() ? "sending..." : "test ping"}885 </button>886 <Show when={testResult()}>887 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>888 </Show>889 </div>890 <Show when={props.entry.canary?.lastPinged}>891 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">892 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}893 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts894 </p>895 </Show>896 </div>897 </Show>898 </div>899 );900}