feat: first-run setup page for repo URL, credentials and master password

This commit is contained in:
schmop 2026-04-23 01:32:53 +02:00
parent c965d0f4ad
commit cad98acbb3
4 changed files with 310 additions and 0 deletions

View File

@ -8,6 +8,10 @@
}, },
"permissions": ["storage", "clipboardWrite", "alarms"], "permissions": ["storage", "clipboardWrite", "alarms"],
"host_permissions": ["https://*/*"], "host_permissions": ["https://*/*"],
"options_ui": {
"page": "src/options/options.html",
"open_in_tab": true
},
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "passchmop@schmop", "id": "passchmop@schmop",

90
src/options/options.css Normal file
View File

@ -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; }

18
src/options/options.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Passchmop — Options</title>
<link rel="stylesheet" href="options.css" />
</head>
<body>
<header>
<h1>Passchmop</h1>
<p class="subtitle">Git-backed, client-side-encrypted password manager.</p>
</header>
<main id="root"></main>
<script src="../../build/options.js"></script>
</body>
</html>

198
src/options/options.ts Normal file
View File

@ -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<string, (e: Event) => void>;
[attr: string]: unknown;
};
function h<K extends keyof HTMLElementTagNameMap>(
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<string, (e: Event) => 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<void> {
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();