1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";6import { sendMessage, triggerPipPulse, triggerRing } from "../App";7import { popupCopy } from "../copy";8import { GeneratorDrawer } from "../components/GeneratorDrawer";9import { NoteEditorInline, Toolbar } from "./NoteEditor";1011type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1213interface EntryProps {14 entry: VaultEntry | null;15 onDelete: () => void;16 17 onFieldSaved: () => void | Promise<void>;18 onEscape?: () => void;19}2021export function EntryView(props: EntryProps) {22 const [editingField, setEditingField] = createSignal<EditableField>(null);23 const [draft, setDraft] = createSignal("");24 const [saving, setSaving] = createSignal(false);25 const [showPassword, setShowPassword] = createSignal(false);26 const [showGenerator, setShowGenerator] = createSignal(false);27 const [copied, setCopied] = createSignal<string | null>(null);28 29 30 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);31 const [editingTags, setEditingTags] = createSignal(false);32 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);33 const [tagInput, setTagInput] = createSignal("");34 const [showCardNumber, setShowCardNumber] = createSignal(false);35 const [showCardCvc, setShowCardCvc] = createSignal(false);36 const [totpTick, setTotpTick] = createSignal(0);37 const [confirmDelete, setConfirmDelete] = createSignal(false);38 const [totpHot, setTotpHot] = createSignal(false);39 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);40 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);41 let lastSavedNote: string | null = null;4243 function noteExec(cmd: string, val?: string) {44 document.execCommand(cmd, false, val);45 noteEditorEl()?.focus();46 }4748 async function saveNoteInline(html: string) {49 const entry = props.entry;50 if (!entry) return;51 const currentStored = entry.notes ?? "";52 if (html === currentStored) return;53 if (lastSavedNote !== null && html === lastSavedNote) return;54 lastSavedNote = html;55 setSaving(true);56 try {57 await sendMessage({58 type: "UPDATE_ENTRY",59 payload: { id: entry.id, patch: { notes: html } },60 });61 triggerRing();62 await props.onFieldSaved();63 } catch {64 lastSavedNote = null;65 } finally {66 setSaving(false);67 }68 }6970 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);71 onCleanup(() => window.clearInterval(interval));7273 const displayDomain = createMemo(() =>74 props.entry?.url ? getDisplayDomain(props.entry.url) : ""75 );7677 createEffect(() => {78 const domain = displayDomain();79 setFaviconSrc(domain ? faviconUrl(domain) : null);80 });8182 83 84 createEffect(() => {85 void props.entry?.id;86 setConfirmDelete(false);87 setEditingField(null);88 setEditingTags(false);89 });9091 const totpState = createMemo(() => {92 void totpTick();93 if (!props.entry?.totp) return null;94 return generateTOTP(props.entry.totp);95 });9697 let prevTotpCode: string | undefined;98 createEffect(() => {99 const state = totpState();100 if (!state) { prevTotpCode = undefined; return; }101 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {102 setTotpHot(true);103 window.setTimeout(() => setTotpHot(false), 3000);104 }105 prevTotpCode = state.code;106 });107108 const totpProgress = createMemo(() => {109 const state = totpState();110 if (!state) return 0;111 return ((state.remaining - 1) / 29) * 100;112 });113114 createEffect(() => {115 const shell = document.getElementById("hd-shell");116 if (!shell) return;117118 const state = totpState();119 if (!state) {120 delete shell.dataset.totpActive;121 shell.style.removeProperty("--peach-totp-progress");122 return;123 }124125 shell.dataset.totpActive = "true";126 shell.style.setProperty(127 "--peach-totp-progress",128 `${Math.max(0, totpProgress())}%`129 );130131 onCleanup(() => {132 delete shell.dataset.totpActive;133 shell.style.removeProperty("--peach-totp-progress");134 });135 });136137 const titleSizeClass = createMemo(() => {138 const length = props.entry?.title.length ?? 0;139 if (length >= 28) return "peach-entry-name--xxl";140 if (length >= 20) return "peach-entry-name--xl";141 if (length >= 14) return "peach-entry-name--lg";142 return "";143 });144 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);145146 147148 function startEdit(field: EditableField, value: string) {149 setEditingField(field);150 setDraft(value);151 setShowGenerator(false);152 setConfirmDelete(false);153 if (field === "url") {154 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);155 }156 }157158 async function saveField() {159 const field = editingField();160 const entry = props.entry;161 if (!field || !entry) return;162 163 let value = field === "password" ? draft() : draft().trim();164 if (field === "totp" && value.startsWith("otpauth://")) {165 const parsed = parseOtpauthURI(value);166 value = parsed?.secret ?? value;167 }168 if (field === "password" && entry.type !== "note" && !value) {169 return;170 }171 setSaving(true);172 try {173 const patch: Partial<VaultEntry> = { [field]: value };174 if (field === "url") {175 176 patch.additionalUrls = additionalUrlsDraft();177 }178 await sendMessage({179 type: "UPDATE_ENTRY",180 payload: { id: entry.id, patch },181 });182 triggerRing();183 setEditingField(null);184 setShowGenerator(false);185 await props.onFieldSaved();186 } catch {187 188 } finally {189 setSaving(false);190 }191 }192193 function startEditTags() {194 setTagsDraft([...(props.entry?.tags ?? [])]);195 setTagInput("");196 setEditingTags(true);197 }198199 function addTag(value: string) {200 const n = value.toLowerCase().trim();201 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;202 setTagsDraft([...tagsDraft(), n]);203 setTagInput("");204 }205206 async function saveTags() {207 if (tagInput()) addTag(tagInput());208 const entry = props.entry;209 if (!entry) return;210 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;211 setEditingTags(false);212 setSaving(true);213 try {214 await sendMessage({215 type: "UPDATE_ENTRY",216 payload: { id: entry.id, patch: { tags: next } },217 });218 triggerRing();219 await props.onFieldSaved();220 } catch {221 222 } finally {223 setSaving(false);224 }225 }226227 function cancelEdit() {228 setEditingField(null);229 setDraft("");230 setShowGenerator(false);231 }232233 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {234 switch (field) {235 case "title":236 return entry.title;237 case "url":238 return entry.url ?? "";239 case "username":240 return entry.username ?? "";241 case "password":242 return entry.password ?? "";243 case "notes":244 return entry.notes ?? "";245 case "totp":246 return entry.totp ?? "";247 }248 }249250 function handleEditorFocusOut(251 field: Exclude<EditableField, null>,252 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }253 ) {254 const nextTarget = event.relatedTarget;255 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;256257 const entry = props.entry;258 if (!entry) return;259260 const currentValue = fieldValue(entry, field);261 const nextValue = field === "password" ? draft() : draft().trim();262 if (nextValue === currentValue) {263 cancelEdit();264 }265 }266267 async function savePassword(password: string) {268 const entry = props.entry;269 if (!entry) return;270 setSaving(true);271 try {272 await sendMessage({273 type: "UPDATE_ENTRY",274 payload: { id: entry.id, patch: { password } },275 });276 triggerRing();277 setShowGenerator(false);278 await props.onFieldSaved();279 } catch {280 281 } finally {282 setSaving(false);283 }284 }285286 287288 async function copyField(text: string, label: string, button?: HTMLButtonElement) {289 const ok = await popupCopy(text, label);290 if (ok) {291 const entry = props.entry;292 if (entry?.canary?.webhookUrl) {293 const trigger =294 label === "password"295 ? "password_copied"296 : label === "username"297 ? "username_copied"298 : undefined;299 if (trigger) {300 void sendMessage({301 type: "FIRE_CANARY_PING",302 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },303 });304 }305 }306 triggerRing();307 triggerPipPulse();308 setCopied(null);309 requestAnimationFrame(() => setCopied(label));310 if (button) {311 button.classList.remove("is-copied", "is-retreat");312 void button.offsetWidth;313 const drain = document.createElement("span");314 drain.className = "peach-copy-drain";315 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";316 drain.appendChild(bar);317 button.appendChild(drain);318 window.setTimeout(() => {319 button.classList.remove("is-copied");320 button.classList.add("is-retreat");321 drain.remove();322 window.setTimeout(() => button.classList.remove("is-retreat"), 160);323 }, 1800);324 }325 window.setTimeout(() => setCopied(null), 1800);326 }327 }328329 330331 createEffect(() => {332 if (!props.entry) return;333 const handler = (e: KeyboardEvent) => {334 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;335 if (inInput) return;336 switch (e.key) {337 case "c": e.preventDefault();338 if (props.entry?.password) void copyField(props.entry.password, "password"); break;339 case "u": e.preventDefault();340 if (props.entry?.username) void copyField(props.entry.username, "username"); break;341 case "Escape": e.preventDefault(); props.onEscape?.(); break;342 }343 };344 window.addEventListener("keydown", handler);345 onCleanup(() => window.removeEventListener("keydown", handler));346 });347348 349350 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {351 requestAnimationFrame(() => { el.focus(); el.select?.(); });352 }353354 function onEditKeyDown(e: KeyboardEvent) {355 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {356 e.preventDefault();357 void saveField();358 }359 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }360 }361362 return (363 <>364 <div365 class="peach-ghost"366 aria-hidden="true"367 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;"368 >369 {props.entry?.title || "empty"}370 </div>371372 <div class="peach-stage" style="position:relative;">373 <Show when={props.entry} fallback={<EmptyVaultState />}>374 {(entry) => (375 <>376 {}377 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>378 <div class="peach-breach-badge">379 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">380 <path d="M6 1L11 10H1L6 1z" />381 <line x1="6" y1="5" x2="6" y2="7.5" />382 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />383 </svg>384 <span style="flex:1">found in a known data breach</span>385 <button386 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"387 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}388 >389 Change password390 </button>391 </div>392 </Show>393394 {}395 <div class="peach-entry-header">396 <div class="peach-entry-header-name">397 <Show398 when={editingField() === "title"}399 fallback={400 <button401 class="peach-entry-name-button"402 title="Click to edit title"403 onClick={() => startEdit("title", entry().title)}404 >405 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>406 <span class="peach-entry-name-text">407 <Show408 when={useAnimatedTitle()}409 fallback={entry().title}410 >411 <For each={entry().title.split("")}>412 {(ch, i) => (413 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>414 {ch === " " ? "\u00A0" : ch}415 </span>416 )}417 </For>418 </Show>419 </span>420 </h1>421 </button>422 }423 >424 <div425 class="peach-inline-title-wrap"426 onFocusOut={(event) => handleEditorFocusOut("title", event)}427 >428 <input429 class="peach-inline-title-input"430 value={draft()}431 onInput={(e) => setDraft(e.currentTarget.value)}432 onKeyDown={onEditKeyDown}433 ref={autoFocus}434 disabled={saving()}435 />436 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />437 </div>438 </Show>439 </div>440441 {}442 <Show when={editingField() !== "title"}>443 <Show444 when={confirmDelete()}445 fallback={446 <button447 class="peach-entry-trash-btn"448 onClick={() => setConfirmDelete(true)}449 aria-label="Move to trash"450 title="Delete entry"451 >452 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">453 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />454 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />455 </svg>456 </button>457 }458 >459 <div class="peach-entry-trash-confirm">460 <span class="peach-entry-trash-confirm-label">trash?</span>461 <button462 class="peach-btn peach-btn--sm peach-btn--danger"463 style="font-size:10px;padding:2px 8px;min-height:22px;"464 onClick={() => { setConfirmDelete(false); props.onDelete(); }}465 >466 yes467 </button>468 <button469 class="peach-btn peach-btn--ghost peach-btn--sm"470 style="font-size:10px;padding:2px 8px;min-height:22px;"471 onClick={() => setConfirmDelete(false)}472 >473 no474 </button>475 </div>476 </Show>477 </Show>478 </div>479480 {}481 <Show when={entry().type !== "note" && entry().type !== "card"}>482 <div class="peach-domain" style="position:relative;z-index:1;">483 <Show484 when={editingField() === "url"}485 fallback={486 <div487 class="peach-domain-row peach-inline-clickable"488 title="Click to edit URL"489 onClick={() => startEdit("url", entry().url ?? "")}490 >491 <Show when={faviconSrc()}>492 <img493 src={faviconSrc()!}494 width="16"495 height="16"496 alt=""497 style="border-radius:3px;margin-right:5px;flex-shrink:0;"498 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}499 />500 </Show>501 <span class="peach-domain-value">502 {displayDomain() || (entry().url ? entry().url : "add a website url")}503 </span>504 <span class="peach-inline-edit-hint">edit</span>505 </div>506 }507 >508 <div509 class="peach-inline-field-wrap peach-inline-field-wrap--url"510 onFocusOut={(event) => handleEditorFocusOut("url", event)}511 >512 <div class="peach-inline-field-row">513 <input514 class="peach-inline-input"515 type="url"516 placeholder="https://"517 value={draft()}518 onInput={(e) => setDraft(e.currentTarget.value)}519 onKeyDown={onEditKeyDown}520 ref={autoFocus}521 disabled={saving()}522 />523 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />524 </div>525 <div class="peach-additional-urls">526 <Show when={additionalUrlsDraft().length > 0}>527 <span class="peach-label peach-additional-urls-label">also matches</span>528 </Show>529 <For each={additionalUrlsDraft()}>530 {(value, i) => (531 <div class="peach-additional-url-row">532 <input533 class="peach-inline-input"534 type="url"535 placeholder="https://"536 value={value}537 onInput={(e) => setAdditionalUrlsDraft((arr) => {538 const next = [...arr];539 next[i()] = e.currentTarget.value;540 return next;541 })}542 disabled={saving()}543 />544 <button545 class="peach-additional-url-remove"546 type="button"547 aria-label="Remove URL"548 onMouseDown={(e) => e.preventDefault()}549 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}550 disabled={saving()}551 >×</button>552 </div>553 )}554 </For>555 <button556 class="peach-add-field-link peach-additional-urls-add"557 type="button"558 onMouseDown={(e) => e.preventDefault()}559 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}560 disabled={saving()}561 >562 + add another url563 </button>564 </div>565 </div>566 </Show>567 </div>568 </Show>569570 {}571 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>572 <div573 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"574 onClick={startEditTags}575 title="Click to edit tags"576 >577 <For each={entry().tags!}>578 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}579 </For>580 </div>581 </Show>582583 {}584 <Show when={entry().type === "note"}>585 <div class="peach-entry-note-toolbar-row">586 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />587 </div>588 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">589 <NoteEditorInline590 entryKey={entry().id}591 initialContent={entry().notes ?? ""}592 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}593 onBlur={saveNoteInline}594 autoFocus={false}595 />596 </section>597 </Show>598599 {}600 <Show when={entry().type === "card"}>601 <section class="peach-fields" style="position:relative;z-index:1;">602 {}603 <Show when={entry().last4}>604 <div class="peach-field-card" style="padding-bottom:4px;">605 <span class="peach-label">606 <Show when={entry().network && entry().network !== "unknown"}>607 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>608 </Show>609 {entry().cardNickname || `•••• ${entry().last4}`}610 </span>611 </div>612 </Show>613614 {}615 <div class="peach-field-card">616 <span class="peach-label">cardholder name</span>617 <div class="peach-field-value-row">618 <span class="peach-field-value">{entry().cardholderName || "—"}</span>619 <Show when={entry().cardholderName}>620 <button621 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}622 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}623 aria-label="Copy cardholder name"624 >625 <CopyGlyph /><span class="peach-copy-sheen" />626 </button>627 </Show>628 </div>629 </div>630631 {}632 <div class="peach-field-card">633 <span class="peach-label">card number</span>634 <div class="peach-field-value-row">635 <span class={`peach-field-value peach-card-number`}>636 <Show when={showCardNumber()} fallback={637 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>638 }>639 <span>{entry().cardNumber ?? ""}</span>640 </Show>641 </span>642 <div class="peach-field-actions">643 <button644 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"645 onClick={() => setShowCardNumber(!showCardNumber())}646 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}647 >648 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>649 <EyeClosedGlyph />650 </Show>651 </button>652 <Show when={entry().cardNumber}>653 <button654 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}655 onClick={(e) => {656 const cardNum = entry().cardNumber!;657 if (entry().canary?.webhookUrl) {658 void sendMessage({659 type: "FIRE_CANARY_PING",660 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },661 });662 }663 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);664 }}665 aria-label="Copy card number"666 >667 <CopyGlyph /><span class="peach-copy-sheen" />668 </button>669 </Show>670 </div>671 </div>672 </div>673674 {}675 <Show when={entry().expMonth || entry().expYear}>676 <div class="peach-field-card">677 <span class="peach-label">expiry</span>678 <div class="peach-field-value-row">679 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>680 <button681 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}682 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}683 aria-label="Copy expiry"684 >685 <CopyGlyph /><span class="peach-copy-sheen" />686 </button>687 </div>688 </div>689 </Show>690691 {}692 <Show when={entry().cvc}>693 <div class="peach-field-card">694 <span class="peach-label">CVC</span>695 <div class="peach-field-value-row">696 <span class="peach-field-value">697 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>698 <span>{entry().cvc}</span>699 </Show>700 </span>701 <div class="peach-field-actions">702 <button703 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"704 onClick={() => setShowCardCvc(!showCardCvc())}705 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}706 >707 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>708 <EyeClosedGlyph />709 </Show>710 </button>711 <button712 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}713 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}714 aria-label="Copy CVC"715 >716 <CopyGlyph /><span class="peach-copy-sheen" />717 </button>718 </div>719 </div>720 </div>721 </Show>722 </section>723 </Show>724725 {}726 <Show when={entry().type !== "note" && entry().type !== "card"}>727 <section class="peach-fields" style="position:relative;z-index:1;">728729 {}730 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>731 <span class="peach-label">username</span>732 <Show733 when={editingField() === "username"}734 fallback={735 <div class="peach-field-value-row">736 <span737 class="peach-field-value peach-inline-clickable"738 title="Click to edit"739 onClick={() => startEdit("username", entry().username ?? "")}740 >741 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}742 </span>743 <Show when={entry().username}>744 <button745 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}746 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}747 aria-label="Copy username"748 >749 <CopyGlyph /><span class="peach-copy-sheen" />750 </button>751 </Show>752 </div>753 }754 >755 <div756 class="peach-inline-field-wrap"757 onFocusOut={(event) => handleEditorFocusOut("username", event)}758 >759 <input760 class="peach-inline-input"761 value={draft()}762 onInput={(e) => setDraft(e.currentTarget.value)}763 onKeyDown={onEditKeyDown}764 ref={autoFocus}765 disabled={saving()}766 autocomplete="off"767 />768 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />769 </div>770 </Show>771 </div>772773 {}774 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>775 <span class="peach-label">password</span>776 <Show777 when={editingField() === "password"}778 fallback={779 <div class="peach-field-value-row">780 <span781 class="peach-password-value"782 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}783 style="cursor:pointer;"784 title="Click to edit password"785 onClick={() => startEdit("password", entry().password ?? "")}786 >787 <Show788 when={showPassword()}789 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}790 >791 <span class="peach-password-text">{entry().password ?? ""}</span>792 </Show>793 </span>794 <div class="peach-field-actions">795 <button796 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"797 onClick={() => setShowPassword(!showPassword())}798 aria-label="Reveal password"799 >800 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>801 <EyeClosedGlyph />802 </Show>803 </button>804 <button805 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}806 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}807 aria-label="Copy password"808 >809 <CopyGlyph /><span class="peach-copy-sheen" />810 </button>811 </div>812 </div>813 }814 >815 {}816 <div817 class="peach-inline-field-wrap"818 onFocusOut={(event) => handleEditorFocusOut("password", event)}819 >820 <div class="peach-password-edit-row">821 <input822 class="peach-inline-input"823 type={showPassword() ? "text" : "password"}824 placeholder="paste or type a password"825 value={draft()}826 onInput={(e) => setDraft(e.currentTarget.value)}827 onKeyDown={(e) => {828 if (e.key === "Enter") { e.preventDefault(); void saveField(); }829 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }830 }}831 ref={autoFocus}832 disabled={saving()}833 autocomplete="new-password"834 />835 <button836 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"837 type="button"838 onClick={() => setShowPassword(!showPassword())}839 aria-label={showPassword() ? "Hide" : "Show"}840 >841 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>842 <EyeClosedGlyph />843 </Show>844 </button>845 </div>846 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />847 <button848 class="peach-generate-toggle-link"849 type="button"850 onClick={() => setShowGenerator(!showGenerator())}851 >852 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">853 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />854 <path d="M9.5 2.5H11V1" />855 </svg>856 {showGenerator() ? "hide generator" : "generate"}857 </button>858 <Show when={showGenerator()}>859 <GeneratorDrawer860 open={true}861 compact={true}862 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}863 />864 </Show>865 </div>866 </Show>867 </div>868869 </section>870 </Show>871872 {}873 <Show when={entry().type !== "note" && entry().type !== "card"}>874 <Show875 when={entry().notes || editingField() === "notes"}876 fallback={877 <button878 class="peach-add-field-link peach-entry-add-link"879 onClick={() => startEdit("notes", "")}880 >881 + notes882 </button>883 }884 >885 <div style="position:relative;z-index:1;margin-top:6px;">886 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>887 <Show888 when={editingField() === "notes"}889 fallback={890 <div891 class="peach-note-content peach-inline-clickable"892 style="cursor:text;white-space:pre-wrap;"893 onClick={() => startEdit("notes", entry().notes ?? "")}894 >895 {entry().notes}896 </div>897 }898 >899 <div900 class="peach-inline-field-wrap"901 onFocusOut={(event) => handleEditorFocusOut("notes", event)}902 >903 <textarea904 class="peach-inline-textarea"905 value={draft()}906 onInput={(e) => setDraft(e.currentTarget.value)}907 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}908 ref={(el) => requestAnimationFrame(() => el?.focus())}909 rows={4}910 disabled={saving()}911 />912 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />913 </div>914 </Show>915 </div>916 </Show>917 </Show>918919 {}920 <Show when={entry().type === "login"}>921 <Show922 when={totpState() || editingField() === "totp"}923 fallback={924 <button925 class="peach-add-field-link peach-entry-add-link"926 onClick={() => startEdit("totp", "")}927 >928 + 2FA929 </button>930 }931 >932 <section class="peach-totp-band">933 <Show934 when={editingField() === "totp"}935 fallback={936 <Show when={totpState()}>937 {(state) => (938 <>939 <div940 class="peach-totp-main"941 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}942 onDblClick={() => startEdit("totp", entry().totp ?? "")}943 title="Click to copy · double-click to edit"944 >945 <span class="peach-label">2-factor</span>946 <div class="peach-totp-code-row">947 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>948 <span class="peach-totp-code-text">949 {state().code.slice(0, 3)} {state().code.slice(3)}950 </span>951 </p>952 </div>953 </div>954 <div class="peach-totp-meta">955 <span class="peach-label">expires</span>956 <div class="peach-totp-countdown">{state().remaining}s</div>957 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">958 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />959 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"960 stroke-dasharray="106.8"961 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}962 />963 </svg>964 </div>965 </>966 )}967 </Show>968 }969 >970 <div971 class="peach-inline-field-wrap peach-inline-field-wrap--totp"972 onFocusOut={(event) => handleEditorFocusOut("totp", event)}973 >974 <span class="peach-label">2-factor</span>975 <input976 class="peach-inline-input"977 value={draft()}978 onInput={(e) => setDraft(e.currentTarget.value)}979 onKeyDown={onEditKeyDown}980 ref={autoFocus}981 disabled={saving()}982 placeholder="otpauth://... or base32 secret"983 autocomplete="off"984 />985 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />986 </div>987 </Show>988 </section>989 </Show>990 </Show>991992 {}993 <Show when={entry().type !== "passkey"}>994 <Show995 when={editingTags()}996 fallback={997 <Show when={!entry().tags || entry().tags!.length === 0}>998 <button999 class="peach-add-field-link peach-entry-add-link"1000 onClick={startEditTags}1001 >1002 + tags1003 </button>1004 </Show>1005 }1006 >1007 <div1008 class="peach-inline-tag-editor"1009 onFocusOut={(e) => {1010 const next = e.relatedTarget;1011 if (next instanceof Node && e.currentTarget.contains(next)) return;1012 void saveTags();1013 }}1014 >1015 <span class="peach-label">tags</span>1016 <div class="peach-tag-input-wrap">1017 <For each={tagsDraft()}>1018 {(tag) => (1019 <span class="peach-tag-chip">1020 {tag}1021 <button1022 class="peach-tag-chip-remove"1023 onMouseDown={(e) => e.preventDefault()}1024 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1025 aria-label={`Remove ${tag}`}1026 >×</button>1027 </span>1028 )}1029 </For>1030 <input1031 class="peach-tag-input"1032 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1033 value={tagInput()}1034 onInput={(e) => setTagInput(e.currentTarget.value)}1035 onKeyDown={(e) => {1036 if (e.key === "Enter" || e.key === ",") {1037 e.preventDefault();1038 addTag(tagInput());1039 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1040 setTagsDraft(tagsDraft().slice(0, -1));1041 } else if (e.key === "Escape") {1042 e.preventDefault();1043 setEditingTags(false);1044 setTagInput("");1045 }1046 }}1047 ref={(el) => requestAnimationFrame(() => el?.focus())}1048 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1049 />1050 </div>1051 </div>1052 </Show>1053 </Show>10541055 {}1056 <div style="height:4px;" />1057 </>1058 )}1059 </Show>1060 </div>10611062 </>1063 );1064}1065106610671068function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1069 return (1070 <div class="peach-inline-actions">1071 <button1072 class="peach-inline-save-btn"1073 onClick={props.onSave}1074 disabled={props.saving}1075 aria-label="Save"1076 title="Save (Enter)"1077 >1078 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1079 <path d="M1 4.5L4 7.5L10 1.5" />1080 </svg>1081 </button>1082 <button1083 class="peach-inline-cancel-btn"1084 onClick={props.onCancel}1085 disabled={props.saving}1086 aria-label="Cancel"1087 title="Cancel (Esc)"1088 >1089 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1090 <path d="M1 1L8 8M8 1L1 8" />1091 </svg>1092 </button>1093 </div>1094 );1095}10961097function EmptyVaultState() {1098 return (1099 <>1100 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1101 <div class="peach-entry-name">1102 <span class="peach-entry-name-text">1103 <span class="peach-entry-name-char" style="--char-index:0">e</span>1104 <span class="peach-entry-name-char" style="--char-index:1">m</span>1105 <span class="peach-entry-name-char" style="--char-index:2">p</span>1106 <span class="peach-entry-name-char" style="--char-index:3">t</span>1107 <span class="peach-entry-name-char" style="--char-index:4">y</span>1108 </span>1109 </div>1110 </div>1111 <div class="peach-empty-copy">1112 <span class="peach-label">vault</span>1113 <p>Your vault is ready for its first entry.</p>1114 </div>1115 </>1116 );1117}11181119function CopyGlyph() {1120 return (1121 <span class="peach-copy-icon">1122 <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">1123 <rect x="4" y="4" width="8" height="9" rx="1.5" />1124 <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" />1125 </svg>1126 </span>1127 );1128}11291130function EyeClosedGlyph() {1131 return (1132 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1133 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1134 <path d="M3.5 5.5L2.5 7.5" />1135 <path d="M7 6.5V8.5" />1136 <path d="M10.5 5.5L11.5 7.5" />1137 </svg>1138 );1139}11401141function EyeOpenGlyph() {1142 return (1143 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1144 <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" />1145 <circle cx="7" cy="5" r="1.75" />1146 </svg>1147 );1148}11491150function ChevronGlyph(props: { open: boolean }) {1151 return (1152 <svg1153 width="8"1154 height="8"1155 viewBox="0 0 8 8"1156 fill="none"1157 stroke="currentColor"1158 stroke-width="1.3"1159 stroke-linecap="round"1160 stroke-linejoin="round"1161 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1162 >1163 <path d="M2 1L6 4L2 7" />1164 </svg>1165 );1166}11671168function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1169 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1170 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1171 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1172 const [testResult, setTestResult] = createSignal<string | null>(null);1173 const [testing, setTesting] = createSignal(false);11741175 async function save() {1176 const url = webhookUrl().trim();1177 const nextLabel = label().trim() || undefined;1178 const canary = enabled() && url1179 ? {1180 webhookUrl: url,1181 label: nextLabel,1182 lastPinged: props.entry.canary?.lastPinged,1183 pingCount: props.entry.canary?.pingCount,1184 }1185 : undefined;1186 await sendMessage({1187 type: "UPDATE_ENTRY",1188 payload: { id: props.entry.id, patch: { canary } },1189 });1190 await props.onSaved();1191 }11921193 async function testPing() {1194 const url = webhookUrl().trim();1195 if (!url) return;1196 setTesting(true);1197 setTestResult(null);1198 const res = await sendMessage({1199 type: "FIRE_CANARY_PING",1200 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1201 }) as { ok: boolean; error?: string };1202 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1203 setTesting(false);1204 }12051206 return (1207 <div class="peach-canary-section">1208 <div class="peach-field-value-row">1209 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1210 <button1211 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1212 onClick={() => { setEnabled(!enabled()); void save(); }}1213 aria-label="Toggle canary"1214 >1215 <span class="peach-toggle-knob" />1216 </button>1217 </div>1218 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1219 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1220 </p>1221 <Show when={enabled()}>1222 <div class="peach-inline-field-wrap" style="margin-top:8px;">1223 <input1224 class="peach-inline-input"1225 placeholder="https://hooks.zapier.com/..."1226 value={webhookUrl()}1227 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1228 onBlur={() => void save()}1229 />1230 <input1231 class="peach-inline-input"1232 placeholder="label (optional, e.g. AWS decoy)"1233 value={label()}1234 onInput={(e) => setLabel(e.currentTarget.value)}1235 onBlur={() => void save()}1236 />1237 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1238 <button1239 class="peach-btn peach-btn--ghost peach-btn--sm"1240 onClick={() => void testPing()}1241 disabled={testing() || !webhookUrl().trim()}1242 >1243 {testing() ? "sending..." : "test ping"}1244 </button>1245 <Show when={testResult()}>1246 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1247 </Show>1248 </div>1249 <Show when={props.entry.canary?.lastPinged}>1250 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1251 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1252 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1253 </p>1254 </Show>1255 </div>1256 </Show>1257 </div>1258 );1259}