diff --git a/manifest.json b/manifest.json
index 34486d7..8ee2cc9 100644
--- a/manifest.json
+++ b/manifest.json
@@ -8,6 +8,10 @@
},
"permissions": ["storage", "clipboardWrite", "alarms"],
"host_permissions": ["https://*/*"],
+ "options_ui": {
+ "page": "src/options/options.html",
+ "open_in_tab": true
+ },
"browser_specific_settings": {
"gecko": {
"id": "passchmop@schmop",
diff --git a/src/options/options.css b/src/options/options.css
new file mode 100644
index 0000000..2d2ab69
--- /dev/null
+++ b/src/options/options.css
@@ -0,0 +1,90 @@
+:root {
+ --bg: #1d1f21;
+ --panel: #282a2e;
+ --fg: #c5c8c6;
+ --muted: #888;
+ --accent: #81a2be;
+ --danger: #cc6666;
+ --ok: #b5bd68;
+ --border: #373b41;
+}
+* { box-sizing: border-box; }
+body {
+ background: var(--bg);
+ color: var(--fg);
+ font-family: ui-sans-serif, system-ui, sans-serif;
+ margin: 0;
+ padding: 0 24px 48px;
+}
+header {
+ padding: 24px 0;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 24px;
+}
+h1 { margin: 0; font-size: 24px; }
+.subtitle { color: var(--muted); margin: 4px 0 0; font-size: 13px; }
+main {
+ max-width: 640px;
+ margin: 0 auto;
+}
+h2 { font-size: 18px; margin: 32px 0 12px; }
+label {
+ display: block;
+ font-size: 12px;
+ color: var(--muted);
+ margin-bottom: 4px;
+}
+input, select, textarea {
+ width: 100%;
+ padding: 8px 10px;
+ background: var(--panel);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-size: 14px;
+ font-family: inherit;
+}
+input:focus, select:focus, textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.field { margin-bottom: 14px; }
+.row { display: flex; gap: 12px; }
+.row > .field { flex: 1; }
+button {
+ padding: 8px 14px;
+ background: var(--accent);
+ color: #1d1f21;
+ border: none;
+ border-radius: 4px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+}
+button:hover { filter: brightness(1.1); }
+button.secondary { background: var(--panel); color: var(--fg); border: 1px solid var(--border); }
+button.danger { background: var(--danger); color: white; }
+button:disabled { opacity: 0.5; cursor: not-allowed; }
+.tabs { display: flex; gap: 0; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
+.tab {
+ padding: 8px 14px;
+ cursor: pointer;
+ color: var(--muted);
+ border-bottom: 2px solid transparent;
+ font-size: 14px;
+}
+.tab.active { color: var(--fg); border-bottom-color: var(--accent); }
+.status { font-size: 13px; margin-top: 12px; }
+.status.err { color: var(--danger); }
+.status.ok { color: var(--ok); }
+.muted { color: var(--muted); font-size: 13px; }
+.kv { display: grid; grid-template-columns: 160px 1fr; gap: 8px 16px; font-size: 13px; }
+.kv .k { color: var(--muted); }
+.kv .v { word-break: break-all; font-family: ui-monospace, monospace; }
+.card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 16px;
+}
+.actions { display: flex; gap: 8px; margin-top: 16px; }
diff --git a/src/options/options.html b/src/options/options.html
new file mode 100644
index 0000000..f1d6342
--- /dev/null
+++ b/src/options/options.html
@@ -0,0 +1,18 @@
+
+
+
+
+ Passchmop — Options
+
+
+
+
+
+
+
+
+
+
diff --git a/src/options/options.ts b/src/options/options.ts
new file mode 100644
index 0000000..e9a7fe0
--- /dev/null
+++ b/src/options/options.ts
@@ -0,0 +1,198 @@
+import { api } from '../common/messages.js';
+import type { VaultMeta } from '../common/types.js';
+
+const root = document.getElementById('root') as HTMLElement;
+
+type Mode = 'new' | 'existing';
+
+interface State {
+ configured: boolean;
+ mode: Mode;
+}
+
+const state: State = {
+ configured: false,
+ mode: 'new',
+};
+
+type HProps = {
+ class?: string;
+ title?: string;
+ placeholder?: string;
+ type?: string;
+ value?: string;
+ on?: Record void>;
+ [attr: string]: unknown;
+};
+
+function h(
+ tag: K,
+ props: HProps = {},
+ ...children: (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));
+ }
+ }
+ for (const c of children) {
+ if (c == null || c === false) continue;
+ el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
+ }
+ return el;
+}
+
+function errText(e: unknown): string {
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function render(): Promise {
+ state.configured = await api.isConfigured();
+ root.innerHTML = '';
+ root.appendChild(state.configured ? renderConfigured() : renderSetup());
+}
+
+function renderSetup(): HTMLElement {
+ const container = h('div');
+ container.appendChild(h('h2', {}, 'Set up your vault'));
+ container.appendChild(h('p', { class: 'muted' },
+ 'Your master password never leaves this device. The git remote only sees encrypted blobs.'));
+
+ const tabs = h('div', { class: 'tabs' },
+ h('div', {
+ class: `tab ${state.mode === 'new' ? 'active' : ''}`,
+ on: { click: () => { state.mode = 'new'; render(); } },
+ }, 'Create new vault'),
+ h('div', {
+ class: `tab ${state.mode === 'existing' ? 'active' : ''}`,
+ on: { click: () => { state.mode = 'existing'; render(); } },
+ }, 'Join existing vault'),
+ );
+ container.appendChild(tabs);
+
+ const form = h('div', { class: 'card' });
+ const existing = state.mode === 'existing';
+
+ const repoUrlInput = inputField('Git repo URL (HTTPS)', { placeholder: 'https://git.example.com/you/passchmop-vault.git' });
+ const usernameInput = inputField('Git username', { placeholder: 'your-username' });
+ const tokenInput = inputField('Personal access token', { type: 'password', placeholder: 'ghp_…' });
+ const masterInput = inputField('Master password', { type: 'password' });
+ const masterConfirm = !existing ? inputField('Confirm master password', { type: 'password' }) : null;
+ const proxyInput = inputField('CORS proxy URL (optional)', { placeholder: 'https://cors.isomorphic-git.org' });
+
+ form.appendChild(repoUrlInput.wrap);
+ form.appendChild(usernameInput.wrap);
+ form.appendChild(tokenInput.wrap);
+ form.appendChild(masterInput.wrap);
+ if (masterConfirm) form.appendChild(masterConfirm.wrap);
+ form.appendChild(proxyInput.wrap);
+
+ const status = h('div', { class: 'status' });
+ const submit = h('button', {}, existing ? 'Unlock existing vault' : 'Create vault');
+
+ submit.addEventListener('click', async () => {
+ const args = {
+ repoUrl: repoUrlInput.el.value.trim(),
+ username: usernameInput.el.value.trim(),
+ token: tokenInput.el.value,
+ masterPassword: masterInput.el.value,
+ existing,
+ corsProxyUrl: proxyInput.el.value.trim(),
+ };
+ if (!args.repoUrl) return setStatus(status, 'repo URL is required', 'err');
+ if (!args.masterPassword) return setStatus(status, 'master password is required', 'err');
+ if (!existing) {
+ if (masterConfirm && args.masterPassword !== masterConfirm.el.value) {
+ return setStatus(status, 'master passwords do not match', 'err');
+ }
+ if (args.masterPassword.length < 8) {
+ return setStatus(status, 'master password must be at least 8 characters', 'err');
+ }
+ }
+ submit.disabled = true;
+ setStatus(status, existing ? 'cloning and verifying…' : 'initializing and pushing…', null);
+ try {
+ await api.setup(args);
+ setStatus(status, 'success. Vault ready.', 'ok');
+ setTimeout(render, 700);
+ } catch (e) {
+ setStatus(status, errText(e), 'err');
+ } finally {
+ submit.disabled = false;
+ }
+ });
+
+ form.appendChild(h('div', { class: 'actions' }, submit));
+ form.appendChild(status);
+ container.appendChild(form);
+ return container;
+}
+
+function renderConfigured(): HTMLElement {
+ const container = h('div');
+ container.appendChild(h('h2', {}, 'Vault configured'));
+ const card = h('div', { class: 'card' });
+ const kv = h('div', { class: 'kv' });
+ container.appendChild(card);
+ card.appendChild(kv);
+
+ (async () => {
+ const stored = await browser.storage.local.get([
+ 'repoUrl', 'deviceId', 'corsProxyUrl', 'vaultMeta',
+ ]) as {
+ repoUrl?: string;
+ deviceId?: string;
+ corsProxyUrl?: string;
+ vaultMeta?: VaultMeta;
+ };
+ kv.appendChild(h('div', { class: 'k' }, 'Repo URL'));
+ kv.appendChild(h('div', { class: 'v' }, stored.repoUrl || ''));
+ kv.appendChild(h('div', { class: 'k' }, 'Device ID'));
+ kv.appendChild(h('div', { class: 'v' }, stored.deviceId || ''));
+ kv.appendChild(h('div', { class: 'k' }, 'CORS proxy'));
+ kv.appendChild(h('div', { class: 'v' }, stored.corsProxyUrl || '(none)'));
+ kv.appendChild(h('div', { class: 'k' }, 'KDF iterations'));
+ kv.appendChild(h('div', { class: 'v' }, String(stored.vaultMeta?.kdf.iterations ?? '?')));
+ })();
+
+ const status = h('div', { class: 'status' });
+ const resetBtn = h('button', { class: 'danger' }, 'Reset vault on this device');
+ resetBtn.addEventListener('click', async () => {
+ if (!confirm('This will clear the local cache and stored credentials on this device. The remote repo is not touched. Continue?')) return;
+ try {
+ await api.reset();
+ setStatus(status, 'reset. Reloading…', 'ok');
+ setTimeout(() => location.reload(), 600);
+ } catch (e) {
+ setStatus(status, errText(e), 'err');
+ }
+ });
+
+ card.appendChild(h('div', { class: 'actions' }, resetBtn));
+ card.appendChild(status);
+ return container;
+}
+
+interface Field { el: HTMLInputElement; wrap: HTMLElement }
+
+function inputField(label: string, opts: { type?: string; placeholder?: string } = {}): Field {
+ const el = h('input', { type: opts.type || 'text', placeholder: opts.placeholder || '' });
+ const wrap = h('div', { class: 'field' },
+ h('label', {}, label),
+ el,
+ );
+ return { el, wrap };
+}
+
+function setStatus(el: HTMLElement, msg: string, kind: 'err' | 'ok' | null): void {
+ el.className = `status ${kind || ''}`;
+ el.textContent = msg;
+}
+
+render();