Auth-herding: roller, kontoadmin, token-revokering + søkbar planliste + neon

Auth (app-modus):
- Roller admin/standard. Admin oppretter/sletter kontoer, resetter passord,
  endrer roller (konto-modal). Eksisterende brukere → admin ved migrering;
  bootstrap-bruker via proxy-bypass → admin. Siste admin vernet.
- Tilbakekalling via per-bruker epoke i tokenet: passordbytte, admin-reset,
  «Logg ut alle enheter» og sletting dreper alle aktive sesjoner. Token-format
  endret (3- → 4-delt) — alle logges ut én gang ved oppgradering.

Dagsplan: søkbar/velgbar planliste («📋 Planer») avledet fra planIndex —
finn plan på navn/dato/person uten å gjette dato.

GUI: login-neon trukket inn i hovedappen (header/knapper/KPI-kanter), og
login-siden fikk mer tech (HUD-hjørner, glitch-tittel, scanlines).

40 integrasjonstester (6 nye), alle grønne. README + CLAUDE.md oppdatert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-15 14:56:39 +02:00
parent f6268440d9
commit 3f69cd4248
9 changed files with 518 additions and 36 deletions

View file

@ -99,6 +99,13 @@ function createServer({
return { user: null, authed: false, via: null };
}
// Admin = lagret admin-rolle, ELLER proxy-bypass uten konto (bootstrap).
function isAdminReq(req) {
if (!req.authed) return false;
if (auth.isAdmin(req.user)) return true;
return req.authVia === 'proxy' && !auth.exists(req.user);
}
function sessionCookie(req, token, maxAgeSec) {
const secure = (req.headers['x-forwarded-proto'] === 'https' || req.socket.encrypted) ? '; Secure' : '';
const age = maxAgeSec != null ? `; Max-Age=${maxAgeSec}` : '';
@ -216,6 +223,8 @@ function createServer({
user: req.user,
via: req.authVia,
hasPassword: req.authed ? auth.hasPassword(req.user) : false,
role: req.authed ? (auth.role(req.user) || (isAdminReq(req) ? 'admin' : 'standard')) : null,
isAdmin: isAdminReq(req),
});
});
@ -234,8 +243,18 @@ function createServer({
res.json({ ok: true });
});
// Logg ut alle enheter: bump epoke (tilbakekaller alle tokens), behold denne.
app.post('/api/auth/logout-all', (req, res) => {
if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' });
if (!req.authed || !auth.exists(req.user)) return res.status(401).json({ error: 'Ikke innlogget' });
auth.bumpEpoch(req.user);
res.setHeader('Set-Cookie', sessionCookie(req, auth.makeToken(req.user), auth.ttlSeconds()));
res.json({ ok: true });
});
// Sett/endre eget passord. Krever innlogging (sesjon eller proxy-bypass).
// Har du allerede passord og er innlogget via sesjon, kreves nåværende passord.
// Ny bruker via proxy-bypass blir admin (bootstrap av første konto).
app.post('/api/auth/password', (req, res) => {
if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' });
if (!req.authed) return res.status(401).json({ error: 'Ikke innlogget' });
@ -245,11 +264,12 @@ function createServer({
return res.status(403).json({ error: 'Feil nåværende passord' });
}
}
try { auth.setPassword(user, req.body && req.body.password); }
const bootstrapRole = (!auth.exists(user) && req.authVia === 'proxy') ? 'admin' : undefined;
try { auth.setPassword(user, req.body && req.body.password, bootstrapRole); }
catch (e) { return res.status(400).json({ error: e.message }); }
// Logg inn via sesjon umiddelbart (så proxy-auth kan skrus av etterpå).
res.setHeader('Set-Cookie', sessionCookie(req, auth.makeToken(user), auth.ttlSeconds()));
res.json({ ok: true, user });
res.json({ ok: true, user, role: auth.role(user) });
});
// ----- Guard: alt under her krever innlogging (kun i app-modus) -----
@ -270,7 +290,10 @@ function createServer({
res.json({
user: req.user,
canSetName: COOKIE_AUTH_ENABLED && authMode !== 'app',
auth: { mode: authMode, via: req.authVia, hasPassword: auth.hasPassword(req.user) },
auth: {
mode: authMode, via: req.authVia, hasPassword: auth.hasPassword(req.user),
role: auth.role(req.user) || (isAdminReq(req) ? 'admin' : 'standard'), isAdmin: isAdminReq(req),
},
tasks: store.list(),
activity: store.activity,
known_users: [...store.knownUsers].sort(),
@ -281,6 +304,49 @@ function createServer({
});
});
// ----- Kontoadministrasjon (kun admin, kun app-modus) -----
function requireAdmin(req, res, next) {
if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' });
if (!isAdminReq(req)) return res.status(403).json({ error: 'Krever administrator' });
next();
}
app.get('/api/auth/users', requireAdmin, (req, res) => res.json({ users: auth.listUsers() }));
app.post('/api/auth/users', requireAdmin, (req, res) => {
const user = sanitizeUser(req.body && req.body.user);
if (!user) return res.status(400).json({ error: 'Ugyldig brukernavn (a-z, 0-9, . _ @ -)' });
const role = req.body && req.body.role === 'admin' ? 'admin' : 'standard';
try { auth.createUser(user, req.body && req.body.password, role); }
catch (e) { return res.status(400).json({ error: e.message }); }
store.rememberUser(user);
res.status(201).json({ user, role });
});
app.post('/api/auth/users/:user/password', requireAdmin, (req, res) => {
const user = sanitizeUser(req.params.user);
if (!auth.exists(user)) return res.status(404).json({ error: 'Ukjent bruker' });
// Admin-reset bumper epoke implisitt (setPassword) → logger ut den brukeren.
try { auth.setPassword(user, req.body && req.body.password); }
catch (e) { return res.status(400).json({ error: e.message }); }
res.json({ ok: true, user });
});
app.put('/api/auth/users/:user/role', requireAdmin, (req, res) => {
const user = sanitizeUser(req.params.user);
try { auth.setRole(user, req.body && req.body.role); }
catch (e) { return res.status(400).json({ error: e.message }); }
res.json({ ok: true, user, role: auth.role(user) });
});
app.delete('/api/auth/users/:user', requireAdmin, (req, res) => {
const user = sanitizeUser(req.params.user);
if (user === req.user) return res.status(400).json({ error: 'Du kan ikke slette din egen konto' });
try { auth.deleteUser(user); }
catch (e) { return res.status(400).json({ error: e.message }); }
res.json({ ok: true });
});
app.get('/api/plan', (req, res) => {
const date = validDate(req.query.date) ? req.query.date : todayStr();
const owner = typeof req.query.user === 'string' && req.query.user ? sanitizeUser(req.query.user) : req.user;