Innebygd innlogging (AUTH_MODE=app) med neon-login og proxy-bypass

Valgfri app-auth ved siden av proxy-modus (default uendret). Signert
sesjons-cookie (scrypt-passord, HMAC, ingen avhengigheter), gating av API +
WebSocket, og en selvstendig neon-login (public/login.html) for uautentiserte.

Trusted bypass: en proxy-identitet (X-Remote-User / Basic Auth) regnes som
innlogget uten app-passord (TRUST_PROXY_HEADER, default på), så man kan
bootstrappe: logg inn via NPM Basic Auth → sett app-passord i konto-modalen →
skru av Basic Auth. Konto-UI for sett/endre passord + logg ut.

AUTH_MODE leses per createServer-instans (testbart). 34 integrasjonstester
(8 nye for app-auth), alle grønne. README/CLAUDE.md/setup-skript oppdatert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-15 14:27:13 +02:00
parent 7287f33e47
commit f6268440d9
9 changed files with 711 additions and 42 deletions

View file

@ -48,6 +48,7 @@ function catName(id) { return (catById(id) || {}).name || id; }
const state = {
user: 'anon',
canSetName: false,
auth: { mode: 'proxy', via: null, hasPassword: false },
tasks: [],
activity: [],
knownUsers: [],
@ -111,6 +112,8 @@ async function api(method, path, body = null) {
}
const data = await res.json();
if (!res.ok) {
// Sesjon utløpt / ikke innlogget i app-modus → til login-siden.
if (res.status === 401 && data && data.login) { location.href = '/login'; }
const err = new Error(data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
@ -301,6 +304,7 @@ async function refreshState() {
const data = await api('GET', '/api/state');
state.user = data.user;
state.canSetName = !!data.canSetName;
if (data.auth) state.auth = data.auth;
state.tasks = data.tasks || [];
state.activity = data.activity || [];
state.knownUsers = data.known_users || [];
@ -439,7 +443,9 @@ function renderAll() {
function renderHeader() {
$('user-chip').textContent = state.user;
$('user-chip').className = 'user-chip' + (state.canSetName ? ' clickable' : '');
const chipClickable = state.canSetName || state.auth.mode === 'app';
$('user-chip').className = 'user-chip' + (chipClickable ? ' clickable' : '');
$('user-chip').title = state.auth.mode === 'app' ? 'Konto: passord / logg ut' : 'Pålogget bruker';
$('anon-banner').className = 'anon-banner' + (state.user === 'anon' && state.canSetName ? ' show' : '');
const others = state.online.filter(u => u !== state.user);
$('online-row').innerHTML = others.length
@ -1388,6 +1394,52 @@ async function setName() {
} catch (e) { toast(e.message, { kind: 'err' }); }
}
// ---------- Konto (app-innlogging) ----------
function openAccount() {
const viaProxy = state.auth.via === 'proxy';
const has = state.auth.hasPassword;
const status = $('account-status');
if (viaProxy && !has) {
status.textContent = `Du er innlogget via proxy (${state.user}). Sett et app-passord her, så kan du logge inn uten proxy og skru av Basic Auth.`;
} else if (viaProxy && has) {
status.textContent = `Innlogget via proxy som ${state.user}. Du har allerede et app-passord — du kan endre det her.`;
} else {
status.textContent = `Innlogget som ${state.user} (app-sesjon).`;
}
status.className = 'modal-warning show';
// Nåværende passord kreves kun når du endrer via en app-sesjon
$('account-current-field').style.display = (has && state.auth.via === 'session') ? '' : 'none';
$('account-pw-label').textContent = has ? 'Nytt passord' : 'Velg passord';
$('account-current').value = '';
$('account-newpw').value = '';
$('account-newpw2').value = '';
$('account-modal').classList.add('open');
setTimeout(() => $('account-newpw').focus(), 50);
}
function closeAccount() { $('account-modal').classList.remove('open'); }
async function saveAccountPassword() {
const pw = $('account-newpw').value;
const pw2 = $('account-newpw2').value;
if (pw.length < 6) { toast('Passord må være minst 6 tegn', { kind: 'warn' }); return; }
if (pw !== pw2) { toast('Passordene er ikke like', { kind: 'warn' }); return; }
const body = { password: pw };
if ($('account-current-field').style.display !== 'none') body.current = $('account-current').value;
try {
await api('POST', '/api/auth/password', body);
state.auth.hasPassword = true;
state.auth.via = 'session';
closeAccount();
renderHeader();
toast('Passord lagret — du er nå innlogget med app-sesjon');
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
async function logout() {
try { await api('POST', '/api/auth/logout'); } catch { /* uansett til login */ }
location.href = '/login';
}
// ---------- Import / eksport / seed ----------
function exportJSON() {
const blob = new Blob([JSON.stringify(state.tasks, null, 2)], { type: 'application/json' });
@ -1488,7 +1540,18 @@ function bindUI() {
});
$('seedBtn').addEventListener('click', seedSamples);
$('setNameBtn').addEventListener('click', setName);
$('user-chip').addEventListener('click', () => { if (state.canSetName) setName(); });
$('user-chip').addEventListener('click', () => {
if (state.auth.mode === 'app') openAccount();
else if (state.canSetName) setName();
});
// Konto-modal
$('account-close').addEventListener('click', closeAccount);
$('account-cancel').addEventListener('click', closeAccount);
$('account-save').addEventListener('click', saveAccountPassword);
$('account-logout').addEventListener('click', logout);
$('account-modal').addEventListener('click', e => { if (e.target.id === 'account-modal') closeAccount(); });
$('account-newpw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveAccountPassword(); });
// Sortering (tabell)
document.querySelectorAll('thead th[data-sort]').forEach(th => {
@ -1510,6 +1573,7 @@ function bindUI() {
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
if (e.key === 'Escape') {
if ($('status-modal').classList.contains('open')) { resolveStatus(null); return; }
if ($('account-modal').classList.contains('open')) { closeAccount(); return; }
closeModal(); $('drawer').classList.remove('open'); return;
}
if (typing) return;