From 009149a88b858c9a3d2c055b85472a9549bc5b61 Mon Sep 17 00:00:00 2001 From: schmop Date: Thu, 23 Apr 2026 01:33:04 +0200 Subject: [PATCH] feat: vault popup with search, autofill and clipboard copy - Sorts entries by match with the active tab's origin so the right one is at the top. - Autofill button injects a form-heuristic filler into the active tab via scripting.executeScript. - Copied credentials auto-clear from the clipboard after 30 seconds. --- manifest.json | 6 +- src/popup/autofill.ts | 61 +++++ src/popup/popup.css | 173 +++++++++++++ src/popup/popup.html | 12 + src/popup/popup.ts | 589 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 src/popup/autofill.ts create mode 100644 src/popup/popup.css create mode 100644 src/popup/popup.html create mode 100644 src/popup/popup.ts diff --git a/manifest.json b/manifest.json index 8ee2cc9..65d63b3 100644 --- a/manifest.json +++ b/manifest.json @@ -6,8 +6,12 @@ "background": { "scripts": ["build/background.js"] }, - "permissions": ["storage", "clipboardWrite", "alarms"], + "permissions": ["storage", "clipboardWrite", "alarms", "activeTab", "tabs", "scripting"], "host_permissions": ["https://*/*"], + "action": { + "default_title": "Passchmop", + "default_popup": "src/popup/popup.html" + }, "options_ui": { "page": "src/options/options.html", "open_in_tab": true diff --git a/src/popup/autofill.ts b/src/popup/autofill.ts new file mode 100644 index 0000000..8f851ee --- /dev/null +++ b/src/popup/autofill.ts @@ -0,0 +1,61 @@ +// Injected into the active tab via browser.scripting.executeScript. +// Runs in the page's content-script world, so it only has access to +// DOM + standard web APIs. Keep it self-contained — it's serialized +// by `func` and re-parsed in the target page. + +export function fillCredentialsInPage( + username: string, + password: string, +): { ok: boolean; reason?: string } { + const pwInputs = Array.from(document.querySelectorAll('input[type="password"]')) as HTMLInputElement[]; + if (pwInputs.length === 0) { + return { ok: false, reason: 'no password field found' }; + } + + // Prefer a visible password field over an off-screen one. + const visible = (el: HTMLInputElement): boolean => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + let pwEl: HTMLInputElement | null = pwInputs.find(visible) ?? pwInputs[0] ?? null; + if (!pwEl) return { ok: false, reason: 'no password field found' }; + + // Locate a username field: nearest preceding text/email/tel input in + // the same form. Fall back to the first visible candidate on the page. + const usernameTypes = ['text', 'email', 'tel', 'url', '']; + let userEl: HTMLInputElement | null = null; + const form = pwEl.form; + if (form) { + const inputs = Array.from(form.querySelectorAll('input')) as HTMLInputElement[]; + const pwIdx = inputs.indexOf(pwEl); + for (let i = pwIdx - 1; i >= 0; i--) { + const el = inputs[i]; + if (!el) continue; + const t = (el.type || 'text').toLowerCase(); + if (usernameTypes.includes(t)) { userEl = el; break; } + } + } + if (!userEl) { + const candidates = Array.from(document.querySelectorAll( + 'input[type="text"], input[type="email"], input[type="tel"], input[type="url"], input:not([type])', + )) as HTMLInputElement[]; + userEl = candidates.find(visible) ?? null; + } + + // Use the value-property setter from the element's prototype so that + // React / Vue / Angular controlled inputs observe the change. + const setValue = (el: HTMLInputElement, value: string): void => { + const proto = Object.getPrototypeOf(el); + const desc = Object.getOwnPropertyDescriptor(proto, 'value'); + if (desc && desc.set) desc.set.call(el, value); + else el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }; + + if (userEl && username) setValue(userEl, username); + setValue(pwEl, password); + // Move focus to the password field so Enter submits the form. + pwEl.focus(); + return { ok: true }; +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..62647b8 --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,173 @@ +:root { + --bg: #1d1f21; + --panel: #282a2e; + --fg: #c5c8c6; + --muted: #888; + --accent: #81a2be; + --danger: #cc6666; + --ok: #b5bd68; + --warn: #f0c674; + --border: #373b41; +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + width: 420px; + min-height: 500px; + background: var(--bg); + color: var(--fg); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 14px; +} +#root { display: flex; flex-direction: column; min-height: 500px; } + +header { + padding: 12px 14px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +header .headline { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +header .title { font-weight: 700; font-size: 16px; } +header .status { + color: var(--muted); + font-size: 11px; + min-height: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} +header .status.err { color: var(--danger); } +header .status.ok { color: var(--ok); } +header .header-right { display: flex; gap: 6px; align-items: center; } + +main { flex: 1; padding: 10px 0; display: flex; flex-direction: column; gap: 10px; } +.pad { padding: 4px 14px; display: flex; flex-direction: column; gap: 10px; } +.pad-horizontal { padding: 0 14px; } + +footer { + border-top: 1px solid var(--border); + padding: 8px 14px; + display: flex; + justify-content: space-between; + align-items: center; +} + +input, textarea { + width: 100%; + padding: 8px 12px; + background: var(--panel); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 5px; + font-size: 14px; + font-family: inherit; +} +textarea { resize: vertical; min-height: 70px; } +input:focus, textarea:focus { outline: none; border-color: var(--accent); } + +button { + padding: 8px 14px; + background: var(--panel); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 5px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +button:hover { border-color: var(--accent); } +button.primary { + background: var(--accent); + color: #1d1f21; + border-color: var(--accent); + font-weight: 600; +} +button.primary:hover { filter: brightness(1.1); } +button.ghost { + background: transparent; + border-color: transparent; + color: var(--muted); +} +button.ghost:hover { color: var(--fg); border-color: var(--border); } +button.danger { background: var(--danger); color: white; border-color: var(--danger); } +button:disabled { opacity: 0.5; cursor: not-allowed; } + +.entry-list { + display: flex; + flex-direction: column; + gap: 5px; + max-height: 380px; + overflow-y: auto; + padding: 0 14px; +} +.entry { + padding: 10px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 5px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 3px; +} +.entry.conflict { border-color: var(--warn); } +.entry .title { font-weight: 600; font-size: 14px; } +.entry .sub { color: var(--muted); font-size: 12px; } +.entry .badge { + display: inline-block; + padding: 1px 6px; + background: var(--warn); + color: #1d1f21; + border-radius: 3px; + font-size: 10px; + margin-left: 6px; + font-weight: 600; +} +.entry-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 6px; + align-items: center; +} + +.empty { + color: var(--muted); + text-align: center; + padding: 40px 16px; + font-size: 13px; +} +.field { display: flex; flex-direction: column; gap: 4px; } +.field label { + font-size: 12px; + color: var(--muted); + font-weight: 500; +} +.row { display: flex; gap: 6px; align-items: stretch; } +.row > input { flex: 1; } +.row-actions { display: flex; gap: 8px; margin-top: 6px; } +.row-actions > button { flex: 1; } + +.input-combo { position: relative; display: flex; } +.input-combo input { flex: 1; padding-right: 30px; } +.input-combo .clear-btn { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + padding: 2px 8px; + background: transparent; + border: none; + color: var(--muted); + font-size: 16px; + line-height: 1; + cursor: pointer; +} +.input-combo .clear-btn:hover { color: var(--fg); } + +.copy-feedback { color: var(--ok); font-size: 12px; margin-left: 4px; } +.err { color: var(--danger); font-size: 12px; } diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..a2fd64f --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,12 @@ + + + + + Passchmop + + + +
+ + + diff --git a/src/popup/popup.ts b/src/popup/popup.ts new file mode 100644 index 0000000..d72e729 --- /dev/null +++ b/src/popup/popup.ts @@ -0,0 +1,589 @@ +import { api } from '../common/messages.js'; +import { fillCredentialsInPage } from './autofill.js'; +import type { EntryWithMeta } from '../common/types.js'; + +const root = document.getElementById('root') as HTMLElement; + +type Mode = 'loading' | 'setup' | 'locked' | 'list' | 'edit'; + +interface EditingEntry { + id: string; + title: string; + url: string; + username: string; + password: string; + notes: string; + created_at?: string; +} + +interface View { + mode: Mode; + query: string; + entries: EntryWithMeta[]; + editing: EditingEntry | null; + status: string | null; + statusKind: 'err' | 'ok' | null; + currentTabUrl: string; +} + +const view: View = { + mode: 'loading', + query: '', + entries: [], + editing: null, + status: null, + statusKind: null, + currentTabUrl: '', +}; + +// DOM refs for list mode so typing into the search box only re-renders +// the entry list, not the input (which would kill focus). +interface ListUI { wrap: HTMLElement; search: HTMLInputElement; body: HTMLElement; updateBody: () => void } +let listUI: ListUI | null = null; + +// Row collapsers for mutually-exclusive expansion. Cleared whenever the +// list body rebuilds. +const rowCollapsers = new Set<() => void>(); + +// ---- Tiny DOM helper ---- + +type Children = Array; +type HProps = { + class?: string; + title?: string; + placeholder?: string; + type?: string; + value?: string; + on?: Record void>; + style?: string; + [attr: string]: unknown; +}; + +function h( + tag: K, + props: HProps = {}, + ...children: (Node | string | null | false | undefined | (Node | string | null | false | undefined)[])[] +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (k === 'class' && typeof v === 'string') el.className = v; + else if (k === 'on' && v && typeof v === 'object') { + for (const [ev, fn] of Object.entries(v as Record void>)) { + el.addEventListener(ev, fn); + } + } else if (v !== undefined && v !== null && v !== false) { + el.setAttribute(k, v === true ? '' : String(v)); + } + } + const flat: Children = ([] as Children).concat(...(children as Children[])); + for (const c of flat) { + if (c == null || c === false) continue; + el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return el; +} + +// ---- Helpers ---- + +function openOptions(): void { + const url = browser.runtime.getURL('src/options/options.html'); + browser.tabs.create({ url }); + window.close(); +} + +async function getActiveTab(): Promise { + try { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + return tabs[0] ?? null; + } catch { + return null; + } +} + +function safeOrigin(url: string | null | undefined): string | null { + if (!url) return null; + try { return new URL(url).origin; } catch { return null; } +} + +function safeHost(url: string | null | undefined): string | null { + if (!url) return null; + try { return new URL(url).hostname; } catch { return null; } +} + +// ---- Lifecycle ---- + +async function boot(): Promise { + const tab = await getActiveTab(); + view.currentTabUrl = tab?.url ?? ''; + try { + const configured = await api.isConfigured(); + if (!configured) { view.mode = 'setup'; render(); return; } + const unlocked = await api.isUnlocked(); + if (!unlocked) { view.mode = 'locked'; render(); return; } + + view.mode = 'list'; + await loadEntries(); + render(); + await runSync(); + } catch (e) { + setStatus(errText(e), 'err'); + view.mode = 'locked'; + render(); + } +} + +async function runSync(): Promise { + setStatus('syncing…', null); + try { + await api.sync(); + await loadEntries(); + refreshList(); + setStatus('synced', 'ok'); + setTimeout(() => { if (view.statusKind === 'ok') setStatus('', null); }, 1200); + } catch (e) { + setStatus(errText(e), 'err'); + } +} + +async function loadEntries(): Promise { + view.entries = await api.list(); +} + +function errText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function setStatus(msg: string | null, kind: 'err' | 'ok' | null): void { + view.status = msg; + view.statusKind = kind; + renderStatus(); +} + +function renderStatus(): void { + const el = document.querySelector('header .status'); + if (!el) return; + el.textContent = view.status || ''; + el.className = `status ${view.statusKind || ''}`; +} + +// ---- Render ---- + +function render(): void { + listUI = null; + rowCollapsers.clear(); + root.innerHTML = ''; + root.appendChild(renderHeader()); + const main = h('main'); + root.appendChild(main); + + switch (view.mode) { + case 'loading': main.appendChild(h('div', { class: 'empty' }, 'loading…')); break; + case 'setup': main.appendChild(renderSetupHint()); break; + case 'locked': main.appendChild(renderUnlock()); break; + case 'list': main.appendChild(renderList()); break; + case 'edit': main.appendChild(renderEdit()); break; + } + + if (view.mode === 'list' || view.mode === 'locked') { + root.appendChild(renderFooter()); + } +} + +function refreshList(): void { + if (view.mode === 'list' && listUI) listUI.updateBody(); + else render(); +} + +function renderHeader(): HTMLElement { + const left = h('div', { class: 'headline' }, + h('span', { class: 'title' }, 'Passchmop'), + h('span', { class: 'status' }, view.status || ''), + ); + const right = h('div', { class: 'header-right' }); + + if (view.mode === 'list') { + right.appendChild( + h('button', { + class: 'primary', + title: 'Add entry', + on: { click: () => { + const origin = safeOrigin(view.currentTabUrl) || ''; + view.editing = { id: '', title: '', url: origin, username: '', password: '', notes: '' }; + view.mode = 'edit'; + render(); + } }, + }, '+ Add'), + ); + } else if (view.mode === 'edit') { + right.appendChild( + h('button', { + class: 'ghost', + title: 'Back', + on: { click: () => { view.editing = null; view.mode = 'list'; render(); } }, + }, '← Back'), + ); + } + + return h('header', {}, left, right); +} + +function renderFooter(): HTMLElement { + const left = h('div'); + const right = h('div'); + + if (view.mode === 'list') { + left.appendChild( + h('button', { + class: 'ghost', + title: 'Lock vault (requires master password to unlock)', + on: { click: async () => { + await api.lock(); + view.mode = 'locked'; + render(); + } }, + }, '🔒 Lock'), + ); + } + + right.appendChild( + h('button', { + class: 'ghost', + title: 'Settings', + on: { click: openOptions }, + }, '⚙ Settings'), + ); + + return h('footer', {}, left, right); +} + +function renderSetupHint(): HTMLElement { + const wrap = h('div', { class: 'pad' }); + wrap.appendChild(h('p', {}, 'No vault configured yet.')); + wrap.appendChild(h('button', { class: 'primary', on: { click: openOptions } }, 'Open setup')); + return wrap; +} + +function renderUnlock(): HTMLElement { + const wrap = h('div', { class: 'pad' }); + const passInput = h('input', { type: 'password', placeholder: 'master password' }); + const err = h('div', { class: 'err' }); + const btn = h('button', { class: 'primary' }, '🔓 Unlock'); + + const submit = async (): Promise => { + btn.disabled = true; + err.textContent = ''; + try { + await api.unlock(passInput.value); + await loadEntries(); + view.mode = 'list'; + render(); + await runSync(); + } catch (e) { + err.textContent = errText(e); + } finally { + btn.disabled = false; + } + }; + btn.addEventListener('click', submit); + passInput.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') submit(); }); + + wrap.appendChild(h('div', { class: 'field' }, h('label', {}, 'Master password'), passInput)); + wrap.appendChild(h('div', { class: 'row-actions' }, btn)); + wrap.appendChild(err); + setTimeout(() => passInput.focus(), 0); + return wrap; +} + +function renderList(): HTMLElement { + const wrap = h('div'); + + const search = h('input', { placeholder: 'search…', value: view.query }); + wrap.appendChild(h('div', { class: 'pad-horizontal' }, search)); + + const body = h('div', { class: 'list-body' }); + wrap.appendChild(body); + + const updateBody = (): void => { + body.replaceChildren(buildListBody()); + }; + + search.addEventListener('input', () => { + view.query = search.value; + updateBody(); + }); + + listUI = { wrap, search, body, updateBody }; + updateBody(); + return wrap; +} + +function buildListBody(): HTMLElement { + rowCollapsers.clear(); + const q = view.query.toLowerCase().trim(); + const filtered = view.entries.filter(e => matchesQuery(e, q)); + const sorted = sortEntries(filtered); + + if (sorted.length === 0) { + return h('div', { class: 'empty' }, + view.entries.length ? 'no matches' : 'no entries yet — click "+ Add" to create one'); + } + const list = h('div', { class: 'entry-list' }); + for (const e of sorted) list.appendChild(renderEntryRow(e)); + return list; +} + +function matchesQuery(e: EntryWithMeta, q: string): boolean { + if (!q) return true; + const hay = `${e.title} ${e.url} ${e.username}`.toLowerCase(); + return hay.includes(q); +} + +// Sort priority: +// 0. entry URL origin === current tab origin +// 1. partial URL / hostname overlap with current tab +// 2. alphabetical by title (then username) +function sortEntries(entries: EntryWithMeta[]): EntryWithMeta[] { + const currentUrl = view.currentTabUrl || ''; + const currentOrigin = safeOrigin(currentUrl); + const currentHost = safeHost(currentUrl); + + const score = (e: EntryWithMeta): number => { + const entryOrigin = safeOrigin(e.url); + if (currentOrigin && entryOrigin && entryOrigin === currentOrigin) return 0; + if (currentUrl && e.url) { + const a = e.url.toLowerCase(); + const b = currentUrl.toLowerCase(); + if (a.includes(b) || b.includes(a)) return 1; + const hostA = safeHost(e.url); + if (currentHost && hostA && (hostA.endsWith(currentHost) || currentHost.endsWith(hostA))) return 1; + } + return 2; + }; + + return entries.slice().sort((a, b) => { + const sa = score(a); + const sb = score(b); + if (sa !== sb) return sa - sb; + const ka = (a.title || a.username || '').toLowerCase(); + const kb = (b.title || b.username || '').toLowerCase(); + return ka.localeCompare(kb); + }); +} + +function renderEntryRow(e: EntryWithMeta): HTMLElement { + const row = h('div', { class: `entry ${e._conflict ? 'conflict' : ''}` }); + const title = h('div', { class: 'title' }, e.title || '(untitled)'); + if (e._conflict) title.appendChild(h('span', { class: 'badge' }, 'conflict')); + row.appendChild(title); + row.appendChild(h('div', { class: 'sub' }, [e.username, e.url].filter(Boolean).join(' · ') || '—')); + + const actions = h('div', { class: 'entry-actions' }); + let expanded = false; + + const feedback = h('span', { class: 'copy-feedback' }); + + actions.appendChild(h('button', { + on: { click: (ev: Event) => { ev.stopPropagation(); copy(e.username, feedback, 'username copied'); } }, + }, 'Copy user')); + actions.appendChild(h('button', { + on: { click: (ev: Event) => { ev.stopPropagation(); copy(e.password, feedback, 'password copied'); } }, + }, 'Copy pass')); + actions.appendChild(h('button', { + class: 'primary', + title: 'Autofill username and password in the active tab', + on: { click: async (ev: Event) => { + ev.stopPropagation(); + await autofill(e, feedback); + } }, + }, '✎ Autofill')); + actions.appendChild(h('button', { + class: 'ghost', + on: { click: (ev: Event) => { + ev.stopPropagation(); + view.editing = { + id: e.id, title: e.title, url: e.url, username: e.username, + password: e.password, notes: e.notes, created_at: e.created_at, + }; + view.mode = 'edit'; + render(); + } }, + }, '✏ Edit')); + actions.appendChild(h('button', { + class: 'danger', + on: { click: async (ev: Event) => { + ev.stopPropagation(); + if (!confirm(`Delete "${e.title || 'entry'}"?`)) return; + try { + await api.delete(e.id, e._file); + await loadEntries(); + refreshList(); + } catch (err) { setStatus(errText(err), 'err'); } + } }, + }, '🗑 Delete')); + actions.appendChild(feedback); + + const collapse = (): void => { + if (!expanded) return; + actions.remove(); + expanded = false; + rowCollapsers.delete(collapse); + }; + const expand = (): void => { + for (const c of Array.from(rowCollapsers)) c(); + row.appendChild(actions); + expanded = true; + rowCollapsers.add(collapse); + }; + + row.addEventListener('click', () => { + if (expanded) collapse(); + else expand(); + }); + + return row; +} + +async function copy(text: string, feedbackEl: HTMLElement, msg: string): Promise { + try { + await navigator.clipboard.writeText(text || ''); + feedbackEl.textContent = msg; + setTimeout(() => { feedbackEl.textContent = ''; }, 1500); + setTimeout(async () => { + try { + const current = await navigator.clipboard.readText(); + if (current === text) await navigator.clipboard.writeText(''); + } catch { /* readText often blocked; ignore */ } + }, 30000); + } catch { + feedbackEl.textContent = 'copy failed'; + } +} + +// firefox-webext-browser types `func` as () => void. The real API serialises +// the function's return value into `InjectionResult.result`; wrap here so we +// keep a typed return path instead of scattering `as any`. +const execInTab = browser.scripting.executeScript as unknown as ( + opts: { target: { tabId: number }; func: (...args: A) => R; args: A }, +) => Promise>; + +async function autofill(entry: EntryWithMeta, feedbackEl: HTMLElement): Promise { + const tab = await getActiveTab(); + if (!tab?.id) { feedbackEl.textContent = 'no active tab'; return; } + try { + const results = await execInTab({ + target: { tabId: tab.id }, + func: fillCredentialsInPage, + args: [entry.username, entry.password], + }); + const first = results[0]; + const result = first?.result; + if (!result) { + feedbackEl.textContent = 'autofill failed'; + return; + } + if (!result.ok) { + feedbackEl.textContent = result.reason || 'autofill failed'; + return; + } + feedbackEl.textContent = 'filled'; + window.close(); + } catch (e) { + feedbackEl.textContent = errText(e); + } +} + +function renderEdit(): HTMLElement { + const e = view.editing!; + const isNew = !e.id; + const wrap = h('div', { class: 'pad' }); + + const title = inputField('Title', e.title); + const url = inputField('URL', e.url); + const clearUrl = h('button', { + class: 'ghost', + title: 'Clear URL', + on: { click: () => { url.el.value = ''; url.el.focus(); } }, + }, 'Clear'); + const user = inputField('Username', e.username); + + const pw = inputField('Password', e.password, 'password'); + const showBtn = h('button', { class: 'ghost', title: 'Show / hide' }, 'show'); + showBtn.addEventListener('click', () => { + pw.el.type = pw.el.type === 'password' ? 'text' : 'password'; + showBtn.textContent = pw.el.type === 'password' ? 'show' : 'hide'; + }); + const genBtn = h('button', { class: 'ghost', title: 'Generate a strong password' }, '↻ gen'); + genBtn.addEventListener('click', () => { pw.el.value = randomPassword(20); }); + + const notes = h('textarea', { placeholder: 'notes' }); + notes.value = e.notes || ''; + + wrap.appendChild(title.wrap); + wrap.appendChild(h('div', { class: 'field' }, + h('label', {}, 'URL'), + h('div', { class: 'row' }, url.el, clearUrl), + )); + wrap.appendChild(user.wrap); + wrap.appendChild(h('div', { class: 'field' }, + h('label', {}, 'Password'), + h('div', { class: 'row' }, pw.el, showBtn, genBtn), + )); + wrap.appendChild(h('div', { class: 'field' }, h('label', {}, 'Notes'), notes)); + + const err = h('div', { class: 'err' }); + const save = h('button', { class: 'primary' }, isNew ? '✓ Create' : '💾 Save'); + save.addEventListener('click', async () => { + save.disabled = true; + err.textContent = ''; + try { + await api.put({ + id: e.id || undefined, + title: title.el.value, + url: url.el.value, + username: user.el.value, + password: pw.el.value, + notes: notes.value, + created_at: e.created_at, + }); + await loadEntries(); + view.editing = null; + view.mode = 'list'; + render(); + } catch (ex) { + err.textContent = errText(ex); + } finally { + save.disabled = false; + } + }); + + const cancel = h('button', { + class: 'ghost', + on: { click: () => { view.editing = null; view.mode = 'list'; render(); } }, + }, '✕ Cancel'); + + wrap.appendChild(h('div', { class: 'row-actions' }, save, cancel)); + wrap.appendChild(err); + return wrap; +} + +interface Field { el: HTMLInputElement; wrap: HTMLElement } + +function inputField(label: string, value: string | undefined, type = 'text'): Field { + const el = h('input', { type }); + el.value = value || ''; + const wrap = h('div', { class: 'field' }, h('label', {}, label), el); + return { el, wrap }; +} + +function randomPassword(len = 20): string { + const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_=+'; + const buf = crypto.getRandomValues(new Uint8Array(len)); + let out = ''; + for (const b of buf) { + const ch = alphabet[b % alphabet.length]; + if (ch) out += ch; + } + return out; +} + +boot();