feat: first-run setup page for repo URL, credentials and master password
This commit is contained in:
parent
c965d0f4ad
commit
cad98acbb3
|
|
@ -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",
|
||||
|
|
|
|||
90
src/options/options.css
Normal file
90
src/options/options.css
Normal 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
18
src/options/options.html
Normal 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
198
src/options/options.ts
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user