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 const totpState = createMemo(() => {83 void totpTick();84 if (!props.entry?.totp) return null;85 return generateTOTP(props.entry.totp);86 });8788 let prevTotpCode: string | undefined;89 createEffect(() => {90 const state = totpState();91 if (!state) { prevTotpCode = undefined; return; }92 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {93 setTotpHot(true);94 window.setTimeout(() => setTotpHot(false), 3000);95 }96 prevTotpCode = state.code;97 });9899 const totpProgress = createMemo(() => {100 const state = totpState();101 if (!state) return 0;102 return ((state.remaining - 1) / 29) * 100;103 });104105 createEffect(() => {106 const shell = document.getElementById("hd-shell");107 if (!shell) return;108109 const state = totpState();110 if (!state) {111 delete shell.dataset.totpActive;112 shell.style.removeProperty("--peach-totp-progress");113 return;114 }115116 shell.dataset.totpActive = "true";117 shell.style.setProperty(118 "--peach-totp-progress",119 `${Math.max(0, totpProgress())}%`120 );121122 onCleanup(() => {123 delete shell.dataset.totpActive;124 shell.style.removeProperty("--peach-totp-progress");125 });126 });127128 const titleSizeClass = createMemo(() => {129 const length = props.entry?.title.length ?? 0;130 if (length >= 28) return "peach-entry-name--xxl";131 if (length >= 20) return "peach-entry-name--xl";132 if (length >= 14) return "peach-entry-name--lg";133 return "";134 });135 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);136137 138139 function startEdit(field: EditableField, value: string) {140 setEditingField(field);141 setDraft(value);142 setShowGenerator(false);143 setConfirmDelete(false);144 if (field === "url") {145 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);146 }147 }148149 async function saveField() {150 const field = editingField();151 const entry = props.entry;152 if (!field || !entry) return;153 154 let value = field === "password" ? draft() : draft().trim();155 if (field === "totp" && value.startsWith("otpauth://")) {156 const parsed = parseOtpauthURI(value);157 value = parsed?.secret ?? value;158 }159 if (field === "password" && entry.type !== "note" && !value) {160 return;161 }162 setSaving(true);163 try {164 const patch: Partial<VaultEntry> = { [field]: value };165 if (field === "url") {166 167 patch.additionalUrls = additionalUrlsDraft();168 }169 await sendMessage({170 type: "UPDATE_ENTRY",171 payload: { id: entry.id, patch },172 });173 triggerRing();174 setEditingField(null);175 setShowGenerator(false);176 await props.onFieldSaved();177 } catch {178 179 } finally {180 setSaving(false);181 }182 }183184 function startEditTags() {185 setTagsDraft([...(props.entry?.tags ?? [])]);186 setTagInput("");187 setEditingTags(true);188 }189190 function addTag(value: string) {191 const n = value.toLowerCase().trim();192 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;193 setTagsDraft([...tagsDraft(), n]);194 setTagInput("");195 }196197 async function saveTags() {198 if (tagInput()) addTag(tagInput());199 const entry = props.entry;200 if (!entry) return;201 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;202 setEditingTags(false);203 setSaving(true);204 try {205 await sendMessage({206 type: "UPDATE_ENTRY",207 payload: { id: entry.id, patch: { tags: next } },208 });209 triggerRing();210 await props.onFieldSaved();211 } catch {212 213 } finally {214 setSaving(false);215 }216 }217218 function cancelEdit() {219 setEditingField(null);220 setDraft("");221 setShowGenerator(false);222 }223224 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {225 switch (field) {226 case "title":227 return entry.title;228 case "url":229 return entry.url ?? "";230 case "username":231 return entry.username ?? "";232 case "password":233 return entry.password ?? "";234 case "notes":235 return entry.notes ?? "";236 case "totp":237 return entry.totp ?? "";238 }239 }240241 function handleEditorFocusOut(242 field: Exclude<EditableField, null>,243 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }244 ) {245 const nextTarget = event.relatedTarget;246 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;247248 const entry = props.entry;249 if (!entry) return;250251 const currentValue = fieldValue(entry, field);252 const nextValue = field === "password" ? draft() : draft().trim();253 if (nextValue === currentValue) {254 cancelEdit();255 }256 }257258 async function savePassword(password: string) {259 const entry = props.entry;260 if (!entry) return;261 setSaving(true);262 try {263 await sendMessage({264 type: "UPDATE_ENTRY",265 payload: { id: entry.id, patch: { password } },266 });267 triggerRing();268 setShowGenerator(false);269 await props.onFieldSaved();270 } catch {271 272 } finally {273 setSaving(false);274 }275 }276277 278279 async function copyField(text: string, label: string, button?: HTMLButtonElement) {280 const ok = await popupCopy(text, label);281 if (ok) {282 const entry = props.entry;283 if (entry?.canary?.webhookUrl) {284 const trigger =285 label === "password"286 ? "password_copied"287 : label === "username"288 ? "username_copied"289 : undefined;290 if (trigger) {291 void sendMessage({292 type: "FIRE_CANARY_PING",293 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },294 });295 }296 }297 triggerRing();298 triggerPipPulse();299 setCopied(null);300 requestAnimationFrame(() => setCopied(label));301 if (button) {302 button.classList.remove("is-copied", "is-retreat");303 void button.offsetWidth;304 const drain = document.createElement("span");305 drain.className = "peach-copy-drain";306 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";307 drain.appendChild(bar);308 button.appendChild(drain);309 window.setTimeout(() => {310 button.classList.remove("is-copied");311 button.classList.add("is-retreat");312 drain.remove();313 window.setTimeout(() => button.classList.remove("is-retreat"), 160);314 }, 1800);315 }316 window.setTimeout(() => setCopied(null), 1800);317 }318 }319320 321322 createEffect(() => {323 if (!props.entry) return;324 const handler = (e: KeyboardEvent) => {325 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;326 if (inInput) return;327 switch (e.key) {328 case "c": e.preventDefault();329 if (props.entry?.password) void copyField(props.entry.password, "password"); break;330 case "u": e.preventDefault();331 if (props.entry?.username) void copyField(props.entry.username, "username"); break;332 case "Escape": e.preventDefault(); props.onEscape?.(); break;333 }334 };335 window.addEventListener("keydown", handler);336 onCleanup(() => window.removeEventListener("keydown", handler));337 });338339 340341 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {342 requestAnimationFrame(() => { el.focus(); el.select?.(); });343 }344345 function onEditKeyDown(e: KeyboardEvent) {346 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {347 e.preventDefault();348 void saveField();349 }350 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }351 }352353 return (354 <>355 <div356 class="peach-ghost"357 aria-hidden="true"358 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;"359 >360 {props.entry?.title || "empty"}361 </div>362363 <div class="peach-stage" style="position:relative;">364 <Show when={props.entry} fallback={<EmptyVaultState />}>365 {(entry) => (366 <>367 {}368 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>369 <div class="peach-breach-badge">370 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">371 <path d="M6 1L11 10H1L6 1z" />372 <line x1="6" y1="5" x2="6" y2="7.5" />373 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />374 </svg>375 <span style="flex:1">found in a known data breach</span>376 <button377 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"378 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}379 >380 Change password381 </button>382 </div>383 </Show>384385 {}386 <div class="peach-entry-header">387 <div class="peach-entry-header-name">388 <Show389 when={editingField() === "title"}390 fallback={391 <button392 class="peach-entry-name-button"393 title="Click to edit title"394 onClick={() => startEdit("title", entry().title)}395 >396 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>397 <span class="peach-entry-name-text">398 <Show399 when={useAnimatedTitle()}400 fallback={entry().title}401 >402 <For each={entry().title.split("")}>403 {(ch, i) => (404 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>405 {ch === " " ? "\u00A0" : ch}406 </span>407 )}408 </For>409 </Show>410 </span>411 </h1>412 </button>413 }414 >415 <div416 class="peach-inline-title-wrap"417 onFocusOut={(event) => handleEditorFocusOut("title", event)}418 >419 <input420 class="peach-inline-title-input"421 value={draft()}422 onInput={(e) => setDraft(e.currentTarget.value)}423 onKeyDown={onEditKeyDown}424 ref={autoFocus}425 disabled={saving()}426 />427 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />428 </div>429 </Show>430 </div>431432 {}433 <Show when={editingField() !== "title"}>434 <Show435 when={confirmDelete()}436 fallback={437 <button438 class="peach-entry-trash-btn"439 onClick={() => setConfirmDelete(true)}440 aria-label="Move to trash"441 title="Delete entry"442 >443 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">444 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />445 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />446 </svg>447 </button>448 }449 >450 <div class="peach-entry-trash-confirm">451 <span class="peach-entry-trash-confirm-label">trash?</span>452 <button453 class="peach-btn peach-btn--sm peach-btn--danger"454 style="font-size:10px;padding:2px 8px;min-height:22px;"455 onClick={() => { setConfirmDelete(false); props.onDelete(); }}456 >457 yes458 </button>459 <button460 class="peach-btn peach-btn--ghost peach-btn--sm"461 style="font-size:10px;padding:2px 8px;min-height:22px;"462 onClick={() => setConfirmDelete(false)}463 >464 no465 </button>466 </div>467 </Show>468 </Show>469 </div>470471 {}472 <Show when={entry().type !== "note" && entry().type !== "card"}>473 <div class="peach-domain" style="position:relative;z-index:1;">474 <Show475 when={editingField() === "url"}476 fallback={477 <div478 class="peach-domain-row peach-inline-clickable"479 title="Click to edit URL"480 onClick={() => startEdit("url", entry().url ?? "")}481 >482 <Show when={faviconSrc()}>483 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />484 </Show>485 <span class="peach-domain-value">486 {displayDomain() || (entry().url ? entry().url : "add a website url")}487 </span>488 <span class="peach-inline-edit-hint">edit</span>489 </div>490 }491 >492 <div493 class="peach-inline-field-wrap peach-inline-field-wrap--url"494 onFocusOut={(event) => handleEditorFocusOut("url", event)}495 >496 <div class="peach-inline-field-row">497 <input498 class="peach-inline-input"499 type="url"500 placeholder="https://"501 value={draft()}502 onInput={(e) => setDraft(e.currentTarget.value)}503 onKeyDown={onEditKeyDown}504 ref={autoFocus}505 disabled={saving()}506 />507 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />508 </div>509 <div class="peach-additional-urls">510 <Show when={additionalUrlsDraft().length > 0}>511 <span class="peach-label peach-additional-urls-label">also matches</span>512 </Show>513 <For each={additionalUrlsDraft()}>514 {(value, i) => (515 <div class="peach-additional-url-row">516 <input517 class="peach-inline-input"518 type="url"519 placeholder="https://"520 value={value}521 onInput={(e) => setAdditionalUrlsDraft((arr) => {522 const next = [...arr];523 next[i()] = e.currentTarget.value;524 return next;525 })}526 disabled={saving()}527 />528 <button529 class="peach-additional-url-remove"530 type="button"531 aria-label="Remove URL"532 onMouseDown={(e) => e.preventDefault()}533 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}534 disabled={saving()}535 >×</button>536 </div>537 )}538 </For>539 <button540 class="peach-add-field-link peach-additional-urls-add"541 type="button"542 onMouseDown={(e) => e.preventDefault()}543 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}544 disabled={saving()}545 >546 + add another url547 </button>548 </div>549 </div>550 </Show>551 </div>552 </Show>553554 {}555 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>556 <div557 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"558 onClick={startEditTags}559 title="Click to edit tags"560 >561 <For each={entry().tags!}>562 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}563 </For>564 </div>565 </Show>566567 {}568 <Show when={entry().type === "note"}>569 <div class="peach-entry-note-toolbar-row">570 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />571 </div>572 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">573 <NoteEditorInline574 entryKey={entry().id}575 initialContent={entry().notes ?? ""}576 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}577 onBlur={saveNoteInline}578 autoFocus={false}579 />580 </section>581 </Show>582583 {}584 <Show when={entry().type === "card"}>585 <section class="peach-fields" style="position:relative;z-index:1;">586 {}587 <Show when={entry().last4}>588 <div class="peach-field-card" style="padding-bottom:4px;">589 <span class="peach-label">590 <Show when={entry().network && entry().network !== "unknown"}>591 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>592 </Show>593 {entry().cardNickname || `•••• ${entry().last4}`}594 </span>595 </div>596 </Show>597598 {}599 <div class="peach-field-card">600 <span class="peach-label">cardholder name</span>601 <div class="peach-field-value-row">602 <span class="peach-field-value">{entry().cardholderName || "—"}</span>603 <Show when={entry().cardholderName}>604 <button605 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}606 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}607 aria-label="Copy cardholder name"608 >609 <CopyGlyph /><span class="peach-copy-sheen" />610 </button>611 </Show>612 </div>613 </div>614615 {}616 <div class="peach-field-card">617 <span class="peach-label">card number</span>618 <div class="peach-field-value-row">619 <span class={`peach-field-value peach-card-number`}>620 <Show when={showCardNumber()} fallback={621 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>622 }>623 <span>{entry().cardNumber ?? ""}</span>624 </Show>625 </span>626 <div class="peach-field-actions">627 <button628 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"629 onClick={() => setShowCardNumber(!showCardNumber())}630 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}631 >632 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>633 <EyeClosedGlyph />634 </Show>635 </button>636 <Show when={entry().cardNumber}>637 <button638 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}639 onClick={(e) => {640 const cardNum = entry().cardNumber!;641 if (entry().canary?.webhookUrl) {642 void sendMessage({643 type: "FIRE_CANARY_PING",644 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },645 });646 }647 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);648 }}649 aria-label="Copy card number"650 >651 <CopyGlyph /><span class="peach-copy-sheen" />652 </button>653 </Show>654 </div>655 </div>656 </div>657658 {}659 <Show when={entry().expMonth || entry().expYear}>660 <div class="peach-field-card">661 <span class="peach-label">expiry</span>662 <div class="peach-field-value-row">663 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>664 <button665 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}666 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}667 aria-label="Copy expiry"668 >669 <CopyGlyph /><span class="peach-copy-sheen" />670 </button>671 </div>672 </div>673 </Show>674675 {}676 <Show when={entry().cvc}>677 <div class="peach-field-card">678 <span class="peach-label">CVC</span>679 <div class="peach-field-value-row">680 <span class="peach-field-value">681 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>682 <span>{entry().cvc}</span>683 </Show>684 </span>685 <div class="peach-field-actions">686 <button687 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"688 onClick={() => setShowCardCvc(!showCardCvc())}689 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}690 >691 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>692 <EyeClosedGlyph />693 </Show>694 </button>695 <button696 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}697 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}698 aria-label="Copy CVC"699 >700 <CopyGlyph /><span class="peach-copy-sheen" />701 </button>702 </div>703 </div>704 </div>705 </Show>706 </section>707 </Show>708709 {}710 <Show when={entry().type !== "note" && entry().type !== "card"}>711 <section class="peach-fields" style="position:relative;z-index:1;">712713 {}714 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>715 <span class="peach-label">username</span>716 <Show717 when={editingField() === "username"}718 fallback={719 <div class="peach-field-value-row">720 <span721 class="peach-field-value peach-inline-clickable"722 title="Click to edit"723 onClick={() => startEdit("username", entry().username ?? "")}724 >725 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}726 </span>727 <Show when={entry().username}>728 <button729 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}730 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}731 aria-label="Copy username"732 >733 <CopyGlyph /><span class="peach-copy-sheen" />734 </button>735 </Show>736 </div>737 }738 >739 <div740 class="peach-inline-field-wrap"741 onFocusOut={(event) => handleEditorFocusOut("username", event)}742 >743 <input744 class="peach-inline-input"745 value={draft()}746 onInput={(e) => setDraft(e.currentTarget.value)}747 onKeyDown={onEditKeyDown}748 ref={autoFocus}749 disabled={saving()}750 autocomplete="off"751 />752 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />753 </div>754 </Show>755 </div>756757 {}758 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>759 <span class="peach-label">password</span>760 <Show761 when={editingField() === "password"}762 fallback={763 <div class="peach-field-value-row">764 <span765 class="peach-password-value"766 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}767 style="cursor:pointer;"768 title="Click to edit password"769 onClick={() => startEdit("password", entry().password ?? "")}770 >771 <Show772 when={showPassword()}773 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}774 >775 <span class="peach-password-text">{entry().password ?? ""}</span>776 </Show>777 </span>778 <div class="peach-field-actions">779 <button780 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"781 onClick={() => setShowPassword(!showPassword())}782 aria-label="Reveal password"783 >784 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>785 <EyeClosedGlyph />786 </Show>787 </button>788 <button789 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}790 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}791 aria-label="Copy password"792 >793 <CopyGlyph /><span class="peach-copy-sheen" />794 </button>795 </div>796 </div>797 }798 >799 {}800 <div801 class="peach-inline-field-wrap"802 onFocusOut={(event) => handleEditorFocusOut("password", event)}803 >804 <div class="peach-password-edit-row">805 <input806 class="peach-inline-input"807 type={showPassword() ? "text" : "password"}808 placeholder="paste or type a password"809 value={draft()}810 onInput={(e) => setDraft(e.currentTarget.value)}811 onKeyDown={(e) => {812 if (e.key === "Enter") { e.preventDefault(); void saveField(); }813 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }814 }}815 ref={autoFocus}816 disabled={saving()}817 autocomplete="new-password"818 />819 <button820 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"821 type="button"822 onClick={() => setShowPassword(!showPassword())}823 aria-label={showPassword() ? "Hide" : "Show"}824 >825 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>826 <EyeClosedGlyph />827 </Show>828 </button>829 </div>830 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />831 <button832 class="peach-generate-toggle-link"833 type="button"834 onClick={() => setShowGenerator(!showGenerator())}835 >836 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">837 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />838 <path d="M9.5 2.5H11V1" />839 </svg>840 {showGenerator() ? "hide generator" : "generate"}841 </button>842 <Show when={showGenerator()}>843 <GeneratorDrawer844 open={true}845 compact={true}846 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}847 />848 </Show>849 </div>850 </Show>851 </div>852853 </section>854 </Show>855856 {}857 <Show when={entry().type !== "note" && entry().type !== "card"}>858 <Show859 when={entry().notes || editingField() === "notes"}860 fallback={861 <button862 class="peach-add-field-link peach-entry-add-link"863 onClick={() => startEdit("notes", "")}864 >865 + notes866 </button>867 }868 >869 <div style="position:relative;z-index:1;margin-top:6px;">870 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>871 <Show872 when={editingField() === "notes"}873 fallback={874 <div875 class="peach-note-content peach-inline-clickable"876 style="cursor:text;white-space:pre-wrap;"877 onClick={() => startEdit("notes", entry().notes ?? "")}878 >879 {entry().notes}880 </div>881 }882 >883 <div884 class="peach-inline-field-wrap"885 onFocusOut={(event) => handleEditorFocusOut("notes", event)}886 >887 <textarea888 class="peach-inline-textarea"889 value={draft()}890 onInput={(e) => setDraft(e.currentTarget.value)}891 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}892 ref={(el) => requestAnimationFrame(() => el?.focus())}893 rows={4}894 disabled={saving()}895 />896 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />897 </div>898 </Show>899 </div>900 </Show>901 </Show>902903 {}904 <Show when={entry().type === "login"}>905 <Show906 when={totpState() || editingField() === "totp"}907 fallback={908 <button909 class="peach-add-field-link peach-entry-add-link"910 onClick={() => startEdit("totp", "")}911 >912 + 2FA913 </button>914 }915 >916 <section class="peach-totp-band">917 <Show918 when={editingField() === "totp"}919 fallback={920 <Show when={totpState()}>921 {(state) => (922 <>923 <div924 class="peach-totp-main"925 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}926 onDblClick={() => startEdit("totp", entry().totp ?? "")}927 title="Click to copy · double-click to edit"928 >929 <span class="peach-label">2-factor</span>930 <div class="peach-totp-code-row">931 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>932 <span class="peach-totp-code-text">933 {state().code.slice(0, 3)} {state().code.slice(3)}934 </span>935 </p>936 </div>937 </div>938 <div class="peach-totp-meta">939 <span class="peach-label">expires</span>940 <div class="peach-totp-countdown">{state().remaining}s</div>941 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">942 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />943 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"944 stroke-dasharray="106.8"945 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}946 />947 </svg>948 </div>949 </>950 )}951 </Show>952 }953 >954 <div955 class="peach-inline-field-wrap peach-inline-field-wrap--totp"956 onFocusOut={(event) => handleEditorFocusOut("totp", event)}957 >958 <span class="peach-label">2-factor</span>959 <input960 class="peach-inline-input"961 value={draft()}962 onInput={(e) => setDraft(e.currentTarget.value)}963 onKeyDown={onEditKeyDown}964 ref={autoFocus}965 disabled={saving()}966 placeholder="otpauth://... or base32 secret"967 autocomplete="off"968 />969 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />970 </div>971 </Show>972 </section>973 </Show>974 </Show>975976 {}977 <Show when={entry().type !== "passkey"}>978 <Show979 when={editingTags()}980 fallback={981 <Show when={!entry().tags || entry().tags!.length === 0}>982 <button983 class="peach-add-field-link peach-entry-add-link"984 onClick={startEditTags}985 >986 + tags987 </button>988 </Show>989 }990 >991 <div992 class="peach-inline-tag-editor"993 onFocusOut={(e) => {994 const next = e.relatedTarget;995 if (next instanceof Node && e.currentTarget.contains(next)) return;996 void saveTags();997 }}998 >999 <span class="peach-label">tags</span>1000 <div class="peach-tag-input-wrap">1001 <For each={tagsDraft()}>1002 {(tag) => (1003 <span class="peach-tag-chip">1004 {tag}1005 <button1006 class="peach-tag-chip-remove"1007 onMouseDown={(e) => e.preventDefault()}1008 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1009 aria-label={`Remove ${tag}`}1010 >×</button>1011 </span>1012 )}1013 </For>1014 <input1015 class="peach-tag-input"1016 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1017 value={tagInput()}1018 onInput={(e) => setTagInput(e.currentTarget.value)}1019 onKeyDown={(e) => {1020 if (e.key === "Enter" || e.key === ",") {1021 e.preventDefault();1022 addTag(tagInput());1023 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1024 setTagsDraft(tagsDraft().slice(0, -1));1025 } else if (e.key === "Escape") {1026 e.preventDefault();1027 setEditingTags(false);1028 setTagInput("");1029 }1030 }}1031 ref={(el) => requestAnimationFrame(() => el?.focus())}1032 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1033 />1034 </div>1035 </div>1036 </Show>1037 </Show>10381039 {}1040 <div style="height:4px;" />1041 </>1042 )}1043 </Show>1044 </div>10451046 </>1047 );1048}1049105010511052function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1053 return (1054 <div class="peach-inline-actions">1055 <button1056 class="peach-inline-save-btn"1057 onClick={props.onSave}1058 disabled={props.saving}1059 aria-label="Save"1060 title="Save (Enter)"1061 >1062 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1063 <path d="M1 4.5L4 7.5L10 1.5" />1064 </svg>1065 </button>1066 <button1067 class="peach-inline-cancel-btn"1068 onClick={props.onCancel}1069 disabled={props.saving}1070 aria-label="Cancel"1071 title="Cancel (Esc)"1072 >1073 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1074 <path d="M1 1L8 8M8 1L1 8" />1075 </svg>1076 </button>1077 </div>1078 );1079}10801081function EmptyVaultState() {1082 return (1083 <>1084 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1085 <div class="peach-entry-name">1086 <span class="peach-entry-name-text">1087 <span class="peach-entry-name-char" style="--char-index:0">e</span>1088 <span class="peach-entry-name-char" style="--char-index:1">m</span>1089 <span class="peach-entry-name-char" style="--char-index:2">p</span>1090 <span class="peach-entry-name-char" style="--char-index:3">t</span>1091 <span class="peach-entry-name-char" style="--char-index:4">y</span>1092 </span>1093 </div>1094 </div>1095 <div class="peach-empty-copy">1096 <span class="peach-label">vault</span>1097 <p>Your vault is ready for its first entry.</p>1098 </div>1099 </>1100 );1101}11021103function CopyGlyph() {1104 return (1105 <span class="peach-copy-icon">1106 <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">1107 <rect x="4" y="4" width="8" height="9" rx="1.5" />1108 <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" />1109 </svg>1110 </span>1111 );1112}11131114function EyeClosedGlyph() {1115 return (1116 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1117 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1118 <path d="M3.5 5.5L2.5 7.5" />1119 <path d="M7 6.5V8.5" />1120 <path d="M10.5 5.5L11.5 7.5" />1121 </svg>1122 );1123}11241125function EyeOpenGlyph() {1126 return (1127 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1128 <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" />1129 <circle cx="7" cy="5" r="1.75" />1130 </svg>1131 );1132}11331134function ChevronGlyph(props: { open: boolean }) {1135 return (1136 <svg1137 width="8"1138 height="8"1139 viewBox="0 0 8 8"1140 fill="none"1141 stroke="currentColor"1142 stroke-width="1.3"1143 stroke-linecap="round"1144 stroke-linejoin="round"1145 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1146 >1147 <path d="M2 1L6 4L2 7" />1148 </svg>1149 );1150}11511152function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1153 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1154 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1155 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1156 const [testResult, setTestResult] = createSignal<string | null>(null);1157 const [testing, setTesting] = createSignal(false);11581159 async function save() {1160 const url = webhookUrl().trim();1161 const nextLabel = label().trim() || undefined;1162 const canary = enabled() && url1163 ? {1164 webhookUrl: url,1165 label: nextLabel,1166 lastPinged: props.entry.canary?.lastPinged,1167 pingCount: props.entry.canary?.pingCount,1168 }1169 : undefined;1170 await sendMessage({1171 type: "UPDATE_ENTRY",1172 payload: { id: props.entry.id, patch: { canary } },1173 });1174 await props.onSaved();1175 }11761177 async function testPing() {1178 const url = webhookUrl().trim();1179 if (!url) return;1180 setTesting(true);1181 setTestResult(null);1182 const res = await sendMessage({1183 type: "FIRE_CANARY_PING",1184 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1185 }) as { ok: boolean; error?: string };1186 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1187 setTesting(false);1188 }11891190 return (1191 <div class="peach-canary-section">1192 <div class="peach-field-value-row">1193 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1194 <button1195 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1196 onClick={() => { setEnabled(!enabled()); void save(); }}1197 aria-label="Toggle canary"1198 >1199 <span class="peach-toggle-knob" />1200 </button>1201 </div>1202 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1203 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1204 </p>1205 <Show when={enabled()}>1206 <div class="peach-inline-field-wrap" style="margin-top:8px;">1207 <input1208 class="peach-inline-input"1209 placeholder="https://hooks.zapier.com/..."1210 value={webhookUrl()}1211 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1212 onBlur={() => void save()}1213 />1214 <input1215 class="peach-inline-input"1216 placeholder="label (optional, e.g. AWS decoy)"1217 value={label()}1218 onInput={(e) => setLabel(e.currentTarget.value)}1219 onBlur={() => void save()}1220 />1221 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1222 <button1223 class="peach-btn peach-btn--ghost peach-btn--sm"1224 onClick={() => void testPing()}1225 disabled={testing() || !webhookUrl().trim()}1226 >1227 {testing() ? "sending..." : "test ping"}1228 </button>1229 <Show when={testResult()}>1230 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1231 </Show>1232 </div>1233 <Show when={props.entry.canary?.lastPinged}>1234 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1235 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1236 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1237 </p>1238 </Show>1239 </div>1240 </Show>1241 </div>1242 );1243}