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 <img484 src={faviconSrc()!}485 width="16"486 height="16"487 alt=""488 style="border-radius:3px;margin-right:5px;flex-shrink:0;"489 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}490 />491 </Show>492 <span class="peach-domain-value">493 {displayDomain() || (entry().url ? entry().url : "add a website url")}494 </span>495 <span class="peach-inline-edit-hint">edit</span>496 </div>497 }498 >499 <div500 class="peach-inline-field-wrap peach-inline-field-wrap--url"501 onFocusOut={(event) => handleEditorFocusOut("url", event)}502 >503 <div class="peach-inline-field-row">504 <input505 class="peach-inline-input"506 type="url"507 placeholder="https://"508 value={draft()}509 onInput={(e) => setDraft(e.currentTarget.value)}510 onKeyDown={onEditKeyDown}511 ref={autoFocus}512 disabled={saving()}513 />514 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />515 </div>516 <div class="peach-additional-urls">517 <Show when={additionalUrlsDraft().length > 0}>518 <span class="peach-label peach-additional-urls-label">also matches</span>519 </Show>520 <For each={additionalUrlsDraft()}>521 {(value, i) => (522 <div class="peach-additional-url-row">523 <input524 class="peach-inline-input"525 type="url"526 placeholder="https://"527 value={value}528 onInput={(e) => setAdditionalUrlsDraft((arr) => {529 const next = [...arr];530 next[i()] = e.currentTarget.value;531 return next;532 })}533 disabled={saving()}534 />535 <button536 class="peach-additional-url-remove"537 type="button"538 aria-label="Remove URL"539 onMouseDown={(e) => e.preventDefault()}540 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}541 disabled={saving()}542 >×</button>543 </div>544 )}545 </For>546 <button547 class="peach-add-field-link peach-additional-urls-add"548 type="button"549 onMouseDown={(e) => e.preventDefault()}550 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}551 disabled={saving()}552 >553 + add another url554 </button>555 </div>556 </div>557 </Show>558 </div>559 </Show>560561 {}562 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>563 <div564 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"565 onClick={startEditTags}566 title="Click to edit tags"567 >568 <For each={entry().tags!}>569 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}570 </For>571 </div>572 </Show>573574 {}575 <Show when={entry().type === "note"}>576 <div class="peach-entry-note-toolbar-row">577 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />578 </div>579 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">580 <NoteEditorInline581 entryKey={entry().id}582 initialContent={entry().notes ?? ""}583 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}584 onBlur={saveNoteInline}585 autoFocus={false}586 />587 </section>588 </Show>589590 {}591 <Show when={entry().type === "card"}>592 <section class="peach-fields" style="position:relative;z-index:1;">593 {}594 <Show when={entry().last4}>595 <div class="peach-field-card" style="padding-bottom:4px;">596 <span class="peach-label">597 <Show when={entry().network && entry().network !== "unknown"}>598 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>599 </Show>600 {entry().cardNickname || `•••• ${entry().last4}`}601 </span>602 </div>603 </Show>604605 {}606 <div class="peach-field-card">607 <span class="peach-label">cardholder name</span>608 <div class="peach-field-value-row">609 <span class="peach-field-value">{entry().cardholderName || "—"}</span>610 <Show when={entry().cardholderName}>611 <button612 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}613 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}614 aria-label="Copy cardholder name"615 >616 <CopyGlyph /><span class="peach-copy-sheen" />617 </button>618 </Show>619 </div>620 </div>621622 {}623 <div class="peach-field-card">624 <span class="peach-label">card number</span>625 <div class="peach-field-value-row">626 <span class={`peach-field-value peach-card-number`}>627 <Show when={showCardNumber()} fallback={628 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>629 }>630 <span>{entry().cardNumber ?? ""}</span>631 </Show>632 </span>633 <div class="peach-field-actions">634 <button635 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"636 onClick={() => setShowCardNumber(!showCardNumber())}637 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}638 >639 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>640 <EyeClosedGlyph />641 </Show>642 </button>643 <Show when={entry().cardNumber}>644 <button645 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}646 onClick={(e) => {647 const cardNum = entry().cardNumber!;648 if (entry().canary?.webhookUrl) {649 void sendMessage({650 type: "FIRE_CANARY_PING",651 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },652 });653 }654 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);655 }}656 aria-label="Copy card number"657 >658 <CopyGlyph /><span class="peach-copy-sheen" />659 </button>660 </Show>661 </div>662 </div>663 </div>664665 {}666 <Show when={entry().expMonth || entry().expYear}>667 <div class="peach-field-card">668 <span class="peach-label">expiry</span>669 <div class="peach-field-value-row">670 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>671 <button672 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}673 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}674 aria-label="Copy expiry"675 >676 <CopyGlyph /><span class="peach-copy-sheen" />677 </button>678 </div>679 </div>680 </Show>681682 {}683 <Show when={entry().cvc}>684 <div class="peach-field-card">685 <span class="peach-label">CVC</span>686 <div class="peach-field-value-row">687 <span class="peach-field-value">688 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>689 <span>{entry().cvc}</span>690 </Show>691 </span>692 <div class="peach-field-actions">693 <button694 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"695 onClick={() => setShowCardCvc(!showCardCvc())}696 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}697 >698 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>699 <EyeClosedGlyph />700 </Show>701 </button>702 <button703 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}704 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}705 aria-label="Copy CVC"706 >707 <CopyGlyph /><span class="peach-copy-sheen" />708 </button>709 </div>710 </div>711 </div>712 </Show>713 </section>714 </Show>715716 {}717 <Show when={entry().type !== "note" && entry().type !== "card"}>718 <section class="peach-fields" style="position:relative;z-index:1;">719720 {}721 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>722 <span class="peach-label">username</span>723 <Show724 when={editingField() === "username"}725 fallback={726 <div class="peach-field-value-row">727 <span728 class="peach-field-value peach-inline-clickable"729 title="Click to edit"730 onClick={() => startEdit("username", entry().username ?? "")}731 >732 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}733 </span>734 <Show when={entry().username}>735 <button736 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}737 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}738 aria-label="Copy username"739 >740 <CopyGlyph /><span class="peach-copy-sheen" />741 </button>742 </Show>743 </div>744 }745 >746 <div747 class="peach-inline-field-wrap"748 onFocusOut={(event) => handleEditorFocusOut("username", event)}749 >750 <input751 class="peach-inline-input"752 value={draft()}753 onInput={(e) => setDraft(e.currentTarget.value)}754 onKeyDown={onEditKeyDown}755 ref={autoFocus}756 disabled={saving()}757 autocomplete="off"758 />759 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />760 </div>761 </Show>762 </div>763764 {}765 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>766 <span class="peach-label">password</span>767 <Show768 when={editingField() === "password"}769 fallback={770 <div class="peach-field-value-row">771 <span772 class="peach-password-value"773 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}774 style="cursor:pointer;"775 title="Click to edit password"776 onClick={() => startEdit("password", entry().password ?? "")}777 >778 <Show779 when={showPassword()}780 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}781 >782 <span class="peach-password-text">{entry().password ?? ""}</span>783 </Show>784 </span>785 <div class="peach-field-actions">786 <button787 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"788 onClick={() => setShowPassword(!showPassword())}789 aria-label="Reveal password"790 >791 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>792 <EyeClosedGlyph />793 </Show>794 </button>795 <button796 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}797 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}798 aria-label="Copy password"799 >800 <CopyGlyph /><span class="peach-copy-sheen" />801 </button>802 </div>803 </div>804 }805 >806 {}807 <div808 class="peach-inline-field-wrap"809 onFocusOut={(event) => handleEditorFocusOut("password", event)}810 >811 <div class="peach-password-edit-row">812 <input813 class="peach-inline-input"814 type={showPassword() ? "text" : "password"}815 placeholder="paste or type a password"816 value={draft()}817 onInput={(e) => setDraft(e.currentTarget.value)}818 onKeyDown={(e) => {819 if (e.key === "Enter") { e.preventDefault(); void saveField(); }820 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }821 }}822 ref={autoFocus}823 disabled={saving()}824 autocomplete="new-password"825 />826 <button827 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"828 type="button"829 onClick={() => setShowPassword(!showPassword())}830 aria-label={showPassword() ? "Hide" : "Show"}831 >832 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>833 <EyeClosedGlyph />834 </Show>835 </button>836 </div>837 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />838 <button839 class="peach-generate-toggle-link"840 type="button"841 onClick={() => setShowGenerator(!showGenerator())}842 >843 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">844 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />845 <path d="M9.5 2.5H11V1" />846 </svg>847 {showGenerator() ? "hide generator" : "generate"}848 </button>849 <Show when={showGenerator()}>850 <GeneratorDrawer851 open={true}852 compact={true}853 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}854 />855 </Show>856 </div>857 </Show>858 </div>859860 </section>861 </Show>862863 {}864 <Show when={entry().type !== "note" && entry().type !== "card"}>865 <Show866 when={entry().notes || editingField() === "notes"}867 fallback={868 <button869 class="peach-add-field-link peach-entry-add-link"870 onClick={() => startEdit("notes", "")}871 >872 + notes873 </button>874 }875 >876 <div style="position:relative;z-index:1;margin-top:6px;">877 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>878 <Show879 when={editingField() === "notes"}880 fallback={881 <div882 class="peach-note-content peach-inline-clickable"883 style="cursor:text;white-space:pre-wrap;"884 onClick={() => startEdit("notes", entry().notes ?? "")}885 >886 {entry().notes}887 </div>888 }889 >890 <div891 class="peach-inline-field-wrap"892 onFocusOut={(event) => handleEditorFocusOut("notes", event)}893 >894 <textarea895 class="peach-inline-textarea"896 value={draft()}897 onInput={(e) => setDraft(e.currentTarget.value)}898 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}899 ref={(el) => requestAnimationFrame(() => el?.focus())}900 rows={4}901 disabled={saving()}902 />903 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />904 </div>905 </Show>906 </div>907 </Show>908 </Show>909910 {}911 <Show when={entry().type === "login"}>912 <Show913 when={totpState() || editingField() === "totp"}914 fallback={915 <button916 class="peach-add-field-link peach-entry-add-link"917 onClick={() => startEdit("totp", "")}918 >919 + 2FA920 </button>921 }922 >923 <section class="peach-totp-band">924 <Show925 when={editingField() === "totp"}926 fallback={927 <Show when={totpState()}>928 {(state) => (929 <>930 <div931 class="peach-totp-main"932 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}933 onDblClick={() => startEdit("totp", entry().totp ?? "")}934 title="Click to copy · double-click to edit"935 >936 <span class="peach-label">2-factor</span>937 <div class="peach-totp-code-row">938 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>939 <span class="peach-totp-code-text">940 {state().code.slice(0, 3)} {state().code.slice(3)}941 </span>942 </p>943 </div>944 </div>945 <div class="peach-totp-meta">946 <span class="peach-label">expires</span>947 <div class="peach-totp-countdown">{state().remaining}s</div>948 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">949 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />950 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"951 stroke-dasharray="106.8"952 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}953 />954 </svg>955 </div>956 </>957 )}958 </Show>959 }960 >961 <div962 class="peach-inline-field-wrap peach-inline-field-wrap--totp"963 onFocusOut={(event) => handleEditorFocusOut("totp", event)}964 >965 <span class="peach-label">2-factor</span>966 <input967 class="peach-inline-input"968 value={draft()}969 onInput={(e) => setDraft(e.currentTarget.value)}970 onKeyDown={onEditKeyDown}971 ref={autoFocus}972 disabled={saving()}973 placeholder="otpauth://... or base32 secret"974 autocomplete="off"975 />976 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />977 </div>978 </Show>979 </section>980 </Show>981 </Show>982983 {}984 <Show when={entry().type !== "passkey"}>985 <Show986 when={editingTags()}987 fallback={988 <Show when={!entry().tags || entry().tags!.length === 0}>989 <button990 class="peach-add-field-link peach-entry-add-link"991 onClick={startEditTags}992 >993 + tags994 </button>995 </Show>996 }997 >998 <div999 class="peach-inline-tag-editor"1000 onFocusOut={(e) => {1001 const next = e.relatedTarget;1002 if (next instanceof Node && e.currentTarget.contains(next)) return;1003 void saveTags();1004 }}1005 >1006 <span class="peach-label">tags</span>1007 <div class="peach-tag-input-wrap">1008 <For each={tagsDraft()}>1009 {(tag) => (1010 <span class="peach-tag-chip">1011 {tag}1012 <button1013 class="peach-tag-chip-remove"1014 onMouseDown={(e) => e.preventDefault()}1015 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1016 aria-label={`Remove ${tag}`}1017 >×</button>1018 </span>1019 )}1020 </For>1021 <input1022 class="peach-tag-input"1023 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1024 value={tagInput()}1025 onInput={(e) => setTagInput(e.currentTarget.value)}1026 onKeyDown={(e) => {1027 if (e.key === "Enter" || e.key === ",") {1028 e.preventDefault();1029 addTag(tagInput());1030 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1031 setTagsDraft(tagsDraft().slice(0, -1));1032 } else if (e.key === "Escape") {1033 e.preventDefault();1034 setEditingTags(false);1035 setTagInput("");1036 }1037 }}1038 ref={(el) => requestAnimationFrame(() => el?.focus())}1039 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1040 />1041 </div>1042 </div>1043 </Show>1044 </Show>10451046 {}1047 <div style="height:4px;" />1048 </>1049 )}1050 </Show>1051 </div>10521053 </>1054 );1055}1056105710581059function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1060 return (1061 <div class="peach-inline-actions">1062 <button1063 class="peach-inline-save-btn"1064 onClick={props.onSave}1065 disabled={props.saving}1066 aria-label="Save"1067 title="Save (Enter)"1068 >1069 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1070 <path d="M1 4.5L4 7.5L10 1.5" />1071 </svg>1072 </button>1073 <button1074 class="peach-inline-cancel-btn"1075 onClick={props.onCancel}1076 disabled={props.saving}1077 aria-label="Cancel"1078 title="Cancel (Esc)"1079 >1080 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1081 <path d="M1 1L8 8M8 1L1 8" />1082 </svg>1083 </button>1084 </div>1085 );1086}10871088function EmptyVaultState() {1089 return (1090 <>1091 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1092 <div class="peach-entry-name">1093 <span class="peach-entry-name-text">1094 <span class="peach-entry-name-char" style="--char-index:0">e</span>1095 <span class="peach-entry-name-char" style="--char-index:1">m</span>1096 <span class="peach-entry-name-char" style="--char-index:2">p</span>1097 <span class="peach-entry-name-char" style="--char-index:3">t</span>1098 <span class="peach-entry-name-char" style="--char-index:4">y</span>1099 </span>1100 </div>1101 </div>1102 <div class="peach-empty-copy">1103 <span class="peach-label">vault</span>1104 <p>Your vault is ready for its first entry.</p>1105 </div>1106 </>1107 );1108}11091110function CopyGlyph() {1111 return (1112 <span class="peach-copy-icon">1113 <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">1114 <rect x="4" y="4" width="8" height="9" rx="1.5" />1115 <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" />1116 </svg>1117 </span>1118 );1119}11201121function EyeClosedGlyph() {1122 return (1123 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1124 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1125 <path d="M3.5 5.5L2.5 7.5" />1126 <path d="M7 6.5V8.5" />1127 <path d="M10.5 5.5L11.5 7.5" />1128 </svg>1129 );1130}11311132function EyeOpenGlyph() {1133 return (1134 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1135 <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" />1136 <circle cx="7" cy="5" r="1.75" />1137 </svg>1138 );1139}11401141function ChevronGlyph(props: { open: boolean }) {1142 return (1143 <svg1144 width="8"1145 height="8"1146 viewBox="0 0 8 8"1147 fill="none"1148 stroke="currentColor"1149 stroke-width="1.3"1150 stroke-linecap="round"1151 stroke-linejoin="round"1152 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1153 >1154 <path d="M2 1L6 4L2 7" />1155 </svg>1156 );1157}11581159function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1160 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1161 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1162 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1163 const [testResult, setTestResult] = createSignal<string | null>(null);1164 const [testing, setTesting] = createSignal(false);11651166 async function save() {1167 const url = webhookUrl().trim();1168 const nextLabel = label().trim() || undefined;1169 const canary = enabled() && url1170 ? {1171 webhookUrl: url,1172 label: nextLabel,1173 lastPinged: props.entry.canary?.lastPinged,1174 pingCount: props.entry.canary?.pingCount,1175 }1176 : undefined;1177 await sendMessage({1178 type: "UPDATE_ENTRY",1179 payload: { id: props.entry.id, patch: { canary } },1180 });1181 await props.onSaved();1182 }11831184 async function testPing() {1185 const url = webhookUrl().trim();1186 if (!url) return;1187 setTesting(true);1188 setTestResult(null);1189 const res = await sendMessage({1190 type: "FIRE_CANARY_PING",1191 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1192 }) as { ok: boolean; error?: string };1193 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1194 setTesting(false);1195 }11961197 return (1198 <div class="peach-canary-section">1199 <div class="peach-field-value-row">1200 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1201 <button1202 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1203 onClick={() => { setEnabled(!enabled()); void save(); }}1204 aria-label="Toggle canary"1205 >1206 <span class="peach-toggle-knob" />1207 </button>1208 </div>1209 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1210 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1211 </p>1212 <Show when={enabled()}>1213 <div class="peach-inline-field-wrap" style="margin-top:8px;">1214 <input1215 class="peach-inline-input"1216 placeholder="https://hooks.zapier.com/..."1217 value={webhookUrl()}1218 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1219 onBlur={() => void save()}1220 />1221 <input1222 class="peach-inline-input"1223 placeholder="label (optional, e.g. AWS decoy)"1224 value={label()}1225 onInput={(e) => setLabel(e.currentTarget.value)}1226 onBlur={() => void save()}1227 />1228 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1229 <button1230 class="peach-btn peach-btn--ghost peach-btn--sm"1231 onClick={() => void testPing()}1232 disabled={testing() || !webhookUrl().trim()}1233 >1234 {testing() ? "sending..." : "test ping"}1235 </button>1236 <Show when={testResult()}>1237 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1238 </Show>1239 </div>1240 <Show when={props.entry.canary?.lastPinged}>1241 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1242 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1243 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1244 </p>1245 </Show>1246 </div>1247 </Show>1248 </div>1249 );1250}