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();