From cad98acbb32c42acc23731c6c8ca768952f0b572 Mon Sep 17 00:00:00 2001 From: schmop Date: Thu, 23 Apr 2026 01:32:53 +0200 Subject: [PATCH] feat: first-run setup page for repo URL, credentials and master password --- manifest.json | 4 + src/options/options.css | 90 ++++++++++++++++++ src/options/options.html | 18 ++++ src/options/options.ts | 198 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 src/options/options.css create mode 100644 src/options/options.html create mode 100644 src/options/options.ts 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 + + + +
+

Passchmop

+

Git-backed, client-side-encrypted password manager.

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