Add multi-user accounts with per-user data scoping and 2FA

Projects were previously global to anyone who could reach the server.
Adds email/password accounts with httpOnly cookie sessions, scopes
every project (both SQLite and localStorage) to the signed-in user,
and auto-claims pre-existing unowned projects for whoever registers
first. Also adds optional TOTP two-factor auth with backup codes,
managed from a new Account Settings page, since there's no
password-reset flow to fall back on otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-06 08:49:33 -04:00
parent bf83dc7cc4
commit 5140958305
13 changed files with 1524 additions and 127 deletions
+55 -2
View File
@@ -15,16 +15,17 @@ async function request(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
const data = await res.json().catch(() => null);
if (!res.ok) {
return { ok: false, error: data?.error ?? `HTTP ${res.status}` };
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` };
}
return { ok: true, data };
} catch (err) {
return { ok: false, error: err?.message ?? 'Network error' };
return { ok: false, status: null, error: err?.message ?? 'Network error' };
}
}
@@ -75,4 +76,56 @@ export async function isServerReachable() {
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
/**
* Returns the signed-in user, or null if not signed in.
* Returns { ok: false } (with status: null) only on a genuine network failure,
* so callers can distinguish "not logged in" from "server unreachable".
*/
export async function getCurrentUser() {
const result = await request('GET', '/auth/me');
if (result.ok) return { ok: true, user: result.data };
if (result.status === 401) return { ok: true, user: null };
return result;
}
export async function registerUser(email, password) {
return request('POST', '/auth/register', { email, password });
}
export async function loginUser(email, password) {
return request('POST', '/auth/login', { email, password });
}
export async function logoutUser() {
return request('POST', '/auth/logout');
}
/** Submits the code from the auth gate's post-password MFA step. Pass token or backupCode. */
export async function verifyMfaLogin({ token, backupCode }) {
return request('POST', '/auth/mfa/verify', { token, backupCode });
}
// ---------------------------------------------------------------------------
// Two-factor auth setup (Account Settings page)
// ---------------------------------------------------------------------------
/** Starts 2FA setup: returns { secret, qrCodeDataUrl } for the user to scan. */
export async function startMfaSetup() {
return request('POST', '/auth/mfa/setup');
}
/** Confirms the scanned code and turns 2FA on. Returns { backupCodes } (shown once). */
export async function confirmMfaSetup(token) {
return request('POST', '/auth/mfa/enable', { token });
}
/** Turns 2FA off. Requires the current password as a safety check. */
export async function disableMfa(password) {
return request('POST', '/auth/mfa/disable', { password });
}