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

@ -74,9 +74,18 @@ netops-todo-node/
proxy-innlogget; krever nåværende passord kun når man endrer via en app-sesjon. proxy-innlogget; krever nåværende passord kun når man endrer via en app-sesjon.
- `resolveAuth` brukes både i HTTP-middleware og WS (`server.on('upgrade')` + - `resolveAuth` brukes både i HTTP-middleware og WS (`server.on('upgrade')` +
`wss.on('connection')`), så samme identitet/gating gjelder begge. `wss.on('connection')`), så samme identitet/gating gjelder begge.
- Sesjon: signert cookie `b64url(user).exp.HMAC` (ingen server-side lager). - Sesjon: signert cookie `b64url(user).exp.epoch.HMAC` (ingen server-side lager).
Hemmelighet fra `AUTH_SECRET` eller persistert `data/auth.secret`. Cookie er Hemmelighet fra `AUTH_SECRET` eller persistert `data/auth.secret`. Cookie er
HttpOnly, SameSite=Lax, Secure når `X-Forwarded-Proto: https`. HttpOnly, SameSite=Lax, Secure når `X-Forwarded-Proto: https`.
- **Roller + tilbakekalling:** hver bruker i auth.json har `role`
(admin/standard) og `epoch`. Tokenet bærer epoken; `verifyToken` avviser hvis
epoken ikke matcher brukerens nåværende (eller kontoen er slettet). `setPassword`,
admin-reset, `bumpEpoch()` («logg ut alle enheter») og sletting tilbakekaller
dermed alle aktive sesjoner. Migrering: eksisterende brukere → admin, epoch=1.
Ny bruker via proxy-bypass → admin (bootstrap). `isAdminReq()` i server.js
regner proxy-bypass uten konto som admin. Siste admin kan ikke slettes/degraderes.
- **Token-format endret** med herdingen (3- → 4-delt), så alle eksisterende
sesjoner blir ugyldige én gang ved oppgradering — brukerne logger inn på nytt.
### WebSocket-protokoll (server → klient) ### WebSocket-protokoll (server → klient)
@ -156,6 +165,11 @@ hardkodede `CATEGORIES`/`CAT_COLORS`.
muterer `state.plan.items` og kaller `savePlan()` (PUT) som reconciler mot muterer `state.plan.items` og kaller `savePlan()` (PUT) som reconciler mot
serverens validerte svar. Container-nivå drag-lyttere bindes én gang serverens validerte svar. Container-nivå drag-lyttere bindes én gang
(`bindPlanContainer`); per-element bindes ved hver render (`bindPlanDnD`). (`bindPlanContainer`); per-element bindes ved hver render (`bindPlanDnD`).
- **Søkbar planliste** (`derivePlanList()`) bygges på klienten ved å gruppere
`state.planIndex` på (bruker, dato) — ingen eget endepunkt, holder seg i synk
via `plan`-WS-meldingene. «📋 Planer»-knappen åpner en søkbar dropdown.
- **Neon-aksenter** fra login-siden er trukket inn i hoved-GUI via en liten
blokk i style.css (header-glød, primærknapp, aktiv visning, KPI-kanter).
- **Kommentar-sletting er begrenset til egen bruker** (eneste - **Kommentar-sletting er begrenset til egen bruker** (eneste
autorisasjonsregel i appen — alt annet er åpent for alle innloggede). autorisasjonsregel i appen — alt annet er åpent for alle innloggede).
- Chart.js fra CDN; frontend degraderer pent (skjuler grafer) uten nett. - Chart.js fra CDN; frontend degraderer pent (skjuler grafer) uten nett.
@ -193,14 +207,15 @@ strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss:
## Testing-status ## Testing-status
- `npm test`: 34 integrasjonstester (node:test), booter ekte servere på - `npm test`: 40 integrasjonstester (node:test), booter ekte servere på
tilfeldig port med temp-datakatalog (én i proxy-modus, én i app-modus). Dekker tilfeldig port med temp-datakatalog (én i proxy-modus, én i app-modus). Dekker
normalisering, versjonskonflikt, kommentar-autorisasjon, statuskommentar, normalisering, versjonskonflikt, kommentar-autorisasjon, statuskommentar,
dagsplan (CRUD, per-bruker, validering, scrub, dato-fallback, navn, flytt/merge, dagsplan (CRUD, per-bruker, validering, scrub, dato-fallback, navn, flytt/merge,
deling + tilgang, planIndex per viewer), egendefinerte kategorier, aktivitetslogg, deling + tilgang, planIndex per viewer), egendefinerte kategorier, aktivitetslogg,
WS-broadcast (delt vs. privat plan), persistens, cookie-auth, og app-auth WS-broadcast (delt vs. privat plan), persistens, cookie-auth, og app-auth
(401-guard, login-side på navigasjon, proxy-bypass, passord-bootstrap, sesjon, (401-guard, login-side, proxy-bypass, passord-bootstrap, sesjon, feil passord,
feil passord, TRUST_PROXY_HEADER=0). Alle grønne per 2026-06-15 (Node 25). TRUST_PROXY_HEADER=0, roller/admin-CRUD, epoke-tilbakekalling ved passordbytte/
reset/logout-all, vern av siste admin). Alle grønne per 2026-06-15 (Node 25).
- Manuell røyk-test over HTTP: kategori-opprettelse (auto-id + farge), navngitt - Manuell røyk-test over HTTP: kategori-opprettelse (auto-id + farge), navngitt
delt plan, annen brukers tilgang (delt 200 / privat 403), flytt med bevart delt plan, annen brukers tilgang (delt 200 / privat 403), flytt med bevart
navn+delingsstatus, og planIndex verifisert. navn+delingsstatus, og planIndex verifisert.

View file

@ -58,6 +58,20 @@ Egen neon-login (`/login`) med passord. Sesjon er en signert, langlevd cookie
sømløst med WebSocket. Passord hashes med scrypt (Node-kjerne, ingen sømløst med WebSocket. Passord hashes med scrypt (Node-kjerne, ingen
avhengigheter), lagret i `data/auth.json`. avhengigheter), lagret i `data/auth.json`.
**Roller:** `admin` og `standard`. Admin kan opprette/slette kontoer, tilbakestille
passord og endre roller (brukermenyen oppe til høyre → admin-seksjon). Standard
kan endre eget passord og logge ut alle enheter. Ved oppgradering blir alle
eksisterende brukere automatisk admin, og den første som setter passord via
proxy-bypass blir admin (bootstrap). Den siste administratoren kan ikke slettes
eller degraderes.
**Tilbakekalling (herding):** hver bruker har en «epoke» bakt inn i tokenet.
Passordbytte, admin-reset og **«Logg ut alle enheter»** bumper epoken og dreper
alle eksisterende sesjoner umiddelbart (sletting av konto gjør det samme).
Sesjons-cookien er `HttpOnly` + `SameSite=Lax` + `Secure` (over HTTPS) og signert
med HMAC, så den kan ikke forfalskes. (Etter denne oppgraderingen logges alle ut
én gang, siden token-formatet endret seg.)
**Trusted bypass / migrering fra Basic Auth:** Når `TRUST_PROXY_HEADER=1` **Trusted bypass / migrering fra Basic Auth:** Når `TRUST_PROXY_HEADER=1`
(default) regnes en proxy-identitet (X-Remote-User eller Basic Auth) som (default) regnes en proxy-identitet (X-Remote-User eller Basic Auth) som
innlogget, *selv uten app-passord*. Det gir en trygg overgang: innlogget, *selv uten app-passord*. Det gir en trygg overgang:
@ -174,7 +188,10 @@ WantedBy=multi-user.target
(dra for å sortere, kapasitet i timer, datovelger). Kan navngis, flyttes til (dra for å sortere, kapasitet i timer, datovelger). Kan navngis, flyttes til
en annen dato i ett klikk, og er som hovedregel synlig for alle (med en en annen dato i ett klikk, og er som hovedregel synlig for alle (med en
«Synlig for alle»-toggle for å gjøre den privat). Andres delte planer kan ses «Synlig for alle»-toggle for å gjøre den privat). Andres delte planer kan ses
skrivebeskyttet via «Plan for»-velgeren. skrivebeskyttet via «Plan for»-velgeren. **Søkbar planliste** («📋 Planer») for
å finne en plan på navn/dato/person uten å gjette datoen.
- **Brukerkontoer med roller** (i app-auth-modus) — admin kan opprette kontoer,
resette passord og styre roller; «Logg ut alle enheter» tilbakekaller økter.
- **Plan-badge på oppgaver** — hvert kort/rad viser om oppgaven er planlagt, - **Plan-badge på oppgaver** — hvert kort/rad viser om oppgaven er planlagt,
hvilken dag og av hvem. hvilken dag og av hvem.
- **Egendefinerte kategorier** — legg til nye kategorier fra kategori-nedtrekket - **Egendefinerte kategorier** — legg til nye kategorier fra kategori-nedtrekket
@ -205,8 +222,14 @@ optimistisk låsing — send med ved update, få 409 hvis noen andre har lagret.
| GET | `/api/state` | Bruker, auth-status, oppgaver, aktivitet, kjente brukere, online, kategorier, dagens plan, planIndex | | GET | `/api/state` | Bruker, auth-status, oppgaver, aktivitet, kjente brukere, online, kategorier, dagens plan, planIndex |
| GET | `/api/auth/status` | Auth-modus, innlogget?, bruker, via, har passord | | GET | `/api/auth/status` | Auth-modus, innlogget?, bruker, via, har passord |
| POST | `/api/auth/login` | App-innlogging `{ user, password }` → sesjons-cookie | | POST | `/api/auth/login` | App-innlogging `{ user, password }` → sesjons-cookie |
| POST | `/api/auth/logout` | Tøm sesjons-cookie | | POST | `/api/auth/logout` | Tøm sesjons-cookie (denne enheten) |
| POST | `/api/auth/logout-all` | Tilbakekall alle egne økter (bump epoke), behold denne |
| POST | `/api/auth/password` | Sett/endre eget passord `{ password, current? }` | | POST | `/api/auth/password` | Sett/endre eget passord `{ password, current? }` |
| GET | `/api/auth/users` | (admin) Liste over kontoer |
| POST | `/api/auth/users` | (admin) Opprett konto `{ user, password, role }` |
| POST | `/api/auth/users/:user/password` | (admin) Tilbakestill passord (logger ut brukeren) |
| PUT | `/api/auth/users/:user/role` | (admin) Sett rolle `{ role }` |
| DELETE | `/api/auth/users/:user` | (admin) Slett konto |
| POST | `/api/tasks` | Opprett oppgave (`title` påkrevd) | | POST | `/api/tasks` | Opprett oppgave (`title` påkrevd) |
| PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk; valgfri `comment` festes som status-kommentar) | | PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk; valgfri `comment` festes som status-kommentar) |
| DELETE | `/api/tasks/:id` | Slett | | DELETE | `/api/tasks/:id` | Slett |

View file

@ -49,6 +49,15 @@ class Auth {
if (d && typeof d === 'object' && !Array.isArray(d)) this.users = d; if (d && typeof d === 'object' && !Array.isArray(d)) this.users = d;
} catch { /* ingen brukere ennå */ } } catch { /* ingen brukere ennå */ }
// Migrering: eksisterende brukere (med passord) blir admin; alle får epoke.
let changed = false;
for (const u of Object.values(this.users)) {
if (!u || typeof u !== 'object') continue;
if (u.password && u.role !== 'admin' && u.role !== 'standard') { u.role = 'admin'; changed = true; }
if (typeof u.epoch !== 'number') { u.epoch = 1; changed = true; }
}
if (changed) { try { this._save(); } catch { /* ignorer */ } }
// Hemmelighet: env > persistert fil > nygenerert (persistert). // Hemmelighet: env > persistert fil > nygenerert (persistert).
if (!this.secret) { if (!this.secret) {
try { this.secret = fs.readFileSync(this.secretFile, 'utf8').trim(); } catch { /* genereres under */ } try { this.secret = fs.readFileSync(this.secretFile, 'utf8').trim(); } catch { /* genereres under */ }
@ -68,12 +77,64 @@ class Auth {
hasPassword(user) { return !!(user && this.users[user] && this.users[user].password); } hasPassword(user) { return !!(user && this.users[user] && this.users[user].password); }
anyPassword() { return Object.values(this.users).some(u => u && u.password); } anyPassword() { return Object.values(this.users).some(u => u && u.password); }
exists(user) { return !!(user && this.users[user]); }
role(user) { return (this.users[user] && this.users[user].role) || null; }
isAdmin(user) { return this.role(user) === 'admin'; }
epochOf(user) { return (this.users[user] && this.users[user].epoch) || 1; }
adminCount() { return Object.values(this.users).filter(u => u && u.role === 'admin').length; }
setPassword(user, password) { // role: 'admin' | 'standard'. Udefinert → behold eksisterende, ellers 'standard'.
// Hver passord-setting bumper epoke, så alle gamle tokens for brukeren dør.
setPassword(user, password, role) {
if (!user) throw new Error('Mangler bruker'); if (!user) throw new Error('Mangler bruker');
if (typeof password !== 'string' || password.length < 6) throw new Error('Passord må være minst 6 tegn'); if (typeof password !== 'string' || password.length < 6) throw new Error('Passord må være minst 6 tegn');
this.users[user] = { ...(this.users[user] || {}), password: hashPassword(password), updated: new Date().toISOString() }; const ex = this.users[user] || {};
this.users[user] = {
...ex,
password: hashPassword(password),
role: role || ex.role || 'standard',
epoch: (ex.epoch || 0) + 1,
updated: new Date().toISOString(),
};
this._save(); this._save();
return this.users[user];
}
// Tilbakekall alle aktive sesjoner for brukeren (logg ut overalt).
bumpEpoch(user) {
if (!this.users[user]) return;
this.users[user].epoch = (this.users[user].epoch || 0) + 1;
this._save();
}
setRole(user, role) {
if (!this.users[user]) throw new Error('Ukjent bruker');
if (role !== 'admin' && role !== 'standard') throw new Error('Ugyldig rolle');
if (role !== 'admin' && this.users[user].role === 'admin' && this.adminCount() <= 1) {
throw new Error('Kan ikke fjerne den siste administratoren');
}
this.users[user].role = role;
this._save();
}
createUser(user, password, role = 'standard') {
if (this.users[user]) throw new Error('Brukeren finnes allerede');
return this.setPassword(user, password, role === 'admin' ? 'admin' : 'standard');
}
deleteUser(user) {
if (!this.users[user]) throw new Error('Ukjent bruker');
if (this.users[user].role === 'admin' && this.adminCount() <= 1) {
throw new Error('Kan ikke slette den siste administratoren');
}
delete this.users[user];
this._save();
}
listUsers() {
return Object.entries(this.users)
.map(([user, u]) => ({ user, role: u.role || 'standard', hasPassword: !!u.password, updated: u.updated || null }))
.sort((a, b) => a.user.localeCompare(b.user));
} }
verify(user, password) { verify(user, password) {
@ -81,9 +142,9 @@ class Auth {
return !!(u && u.password && verifyPassword(password, u.password)); return !!(u && u.password && verifyPassword(password, u.password));
} }
// Signert sesjons-token. user → "<b64url(user)>.<exp>.<hmac>" // Signert sesjons-token. user → "<b64url(user)>.<exp>.<epoch>.<hmac>"
makeToken(user) { makeToken(user) {
const payload = `${Buffer.from(String(user)).toString('base64url')}.${Date.now() + this.ttlMs}`; const payload = `${Buffer.from(String(user)).toString('base64url')}.${Date.now() + this.ttlMs}.${this.epochOf(user)}`;
const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url'); const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url');
return `${payload}.${sig}`; return `${payload}.${sig}`;
} }
@ -91,16 +152,18 @@ class Auth {
verifyToken(token) { verifyToken(token) {
if (typeof token !== 'string') return null; if (typeof token !== 'string') return null;
const parts = token.split('.'); const parts = token.split('.');
if (parts.length !== 3) return null; if (parts.length !== 4) return null; // gamle 3-delte tokens er ugyldige etter herding
const payload = `${parts[0]}.${parts[1]}`; const payload = `${parts[0]}.${parts[1]}.${parts[2]}`;
const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url'); const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url');
const a = Buffer.from(parts[2]); const a = Buffer.from(parts[3]);
const b = Buffer.from(expected); const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
const exp = Number(parts[1]); if (!Number(parts[1]) || Date.now() > Number(parts[1])) return null;
if (!exp || Date.now() > exp) return null; let user;
try { return Buffer.from(parts[0], 'base64url').toString('utf8') || null; } try { user = Buffer.from(parts[0], 'base64url').toString('utf8'); } catch { return null; }
catch { return null; } if (!user || !this.users[user]) return null; // konto slettet → token dør
if (Number(parts[2]) !== this.epochOf(user)) return null; // epoke bumpet → token dør
return user;
} }
ttlSeconds() { return Math.floor(this.ttlMs / 1000); } ttlSeconds() { return Math.floor(this.ttlMs / 1000); }

View file

@ -48,7 +48,7 @@ function catName(id) { return (catById(id) || {}).name || id; }
const state = { const state = {
user: 'anon', user: 'anon',
canSetName: false, canSetName: false,
auth: { mode: 'proxy', via: null, hasPassword: false }, auth: { mode: 'proxy', via: null, hasPassword: false, role: 'standard', isAdmin: false },
tasks: [], tasks: [],
activity: [], activity: [],
knownUsers: [], knownUsers: [],
@ -845,6 +845,65 @@ function renderPlan() {
if (own) bindPlanDnD(); if (own) bindPlanDnD();
} }
// ---------- Søkbar planliste ----------
// Avled plansammendrag fra badge-indeksen (allerede filtrert til synlige planer).
function derivePlanList() {
const map = new Map();
for (const entries of Object.values(state.planIndex)) {
for (const e of entries) {
const key = e.user + '|' + e.date;
let g = map.get(key);
if (!g) { g = { user: e.user, date: e.date, name: e.name || '', count: 0 }; map.set(key, g); }
g.count++;
if (!g.name && e.name) g.name = e.name;
}
}
return [...map.values()].sort((a, b) => {
const am = a.user === state.user ? 0 : 1, bm = b.user === state.user ? 0 : 1;
if (am !== bm) return am - bm; // mine planer først
return b.date.localeCompare(a.date); // så nyeste dato først
});
}
function renderPlanPicker(filter = '') {
const q = filter.trim().toLowerCase();
const list = derivePlanList().filter(p => {
if (!q) return true;
return [p.name, p.date, p.user === state.user ? 'meg' : p.user].join(' ').toLowerCase().includes(q);
});
const el = $('plan-picker-list');
if (!list.length) {
el.innerHTML = `<div class="plan-pick-empty">${q ? 'Ingen planer matcher søket.' : 'Ingen planer ennå.'}</div>`;
return;
}
el.innerHTML = list.map(p => {
const me = p.user === state.user;
const d = p.date.slice(8, 10) + '.' + p.date.slice(5, 7) + '.' + p.date.slice(0, 4);
return `<div class="plan-pick-row" onclick="pickPlan('${escapeHtml(p.user)}','${p.date}')">
<span class="pp-date">${d}</span>
<span class="pp-name ${p.name ? '' : 'unnamed'}">${p.name ? escapeHtml(p.name) : '(uten navn)'}</span>
<span class="pp-who ${me ? 'me' : ''}">${me ? 'meg' : escapeHtml(p.user)}</span>
<span class="pp-count">${p.count}</span>
</div>`;
}).join('');
}
function togglePlanPicker(open) {
const panel = $('plan-picker-panel');
const show = open != null ? open : !panel.classList.contains('open');
panel.classList.toggle('open', show);
if (show) {
$('plan-picker-search').value = '';
renderPlanPicker('');
setTimeout(() => $('plan-picker-search').focus(), 30);
}
}
window.pickPlan = function (user, date) {
togglePlanPicker(false);
refreshPlan(date, user);
};
function populatePlanUserSelect() { function populatePlanUserSelect() {
const sel = $('plan-view-user'); const sel = $('plan-view-user');
const users = new Set([state.user, ...state.knownUsers, ...state.online]); const users = new Set([state.user, ...state.knownUsers, ...state.online]);
@ -1413,11 +1472,78 @@ function openAccount() {
$('account-current').value = ''; $('account-current').value = '';
$('account-newpw').value = ''; $('account-newpw').value = '';
$('account-newpw2').value = ''; $('account-newpw2').value = '';
// Logg ut alle enheter krever en lagret konto (ikke ren proxy-bypass)
$('account-logout-all').style.display = has ? '' : 'none';
// Rolle-badge
const badge = $('account-role-badge');
badge.textContent = state.auth.role || 'standard';
badge.className = 'role-badge ' + (state.auth.isAdmin ? 'admin' : 'standard');
badge.style.display = '';
// Admin-seksjon
$('account-admin').style.display = state.auth.isAdmin ? '' : 'none';
if (state.auth.isAdmin) loadUserList();
$('account-modal').classList.add('open'); $('account-modal').classList.add('open');
setTimeout(() => $('account-newpw').focus(), 50); setTimeout(() => $('account-newpw').focus(), 50);
} }
function closeAccount() { $('account-modal').classList.remove('open'); } function closeAccount() { $('account-modal').classList.remove('open'); }
// ---------- Kontoadministrasjon (admin) ----------
async function loadUserList() {
const box = $('admin-userlist');
try {
const { users } = await api('GET', '/api/auth/users');
box.innerHTML = users.map(u => {
const me = u.user === state.user;
const toRole = u.role === 'admin' ? 'standard' : 'admin';
return `<div class="admin-user">
<span class="au-name">${escapeHtml(u.user)}</span>
<span class="role-badge ${u.role}">${u.role}</span>
${me ? '<span class="au-me">deg</span>' : `<div class="au-actions">
<button class="icon-btn" title="Gjør til ${toRole}" onclick="adminSetRole('${escapeHtml(u.user)}','${toRole}')"></button>
<button class="icon-btn" title="Tilbakestill passord" onclick="adminResetPassword('${escapeHtml(u.user)}')">🔑</button>
<button class="icon-btn del" title="Slett konto" onclick="adminDeleteUser('${escapeHtml(u.user)}')">🗑</button>
</div>`}
</div>`;
}).join('');
} catch (e) { box.innerHTML = `<div class="plan-empty">Kunne ikke laste brukere: ${escapeHtml(e.message)}</div>`; }
}
async function adminCreateUser() {
const user = $('admin-new-user').value.trim();
const password = $('admin-new-pass').value;
const role = $('admin-new-role').value;
if (!user) { toast('Skriv et brukernavn', { kind: 'warn' }); return; }
if (password.length < 6) { toast('Passord må være minst 6 tegn', { kind: 'warn' }); return; }
try {
await api('POST', '/api/auth/users', { user, password, role });
$('admin-new-user').value = ''; $('admin-new-pass').value = '';
loadUserList();
toast(`Konto «${user}» (${role}) opprettet`);
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
window.adminSetRole = async function (user, role) {
try { await api('PUT', `/api/auth/users/${encodeURIComponent(user)}/role`, { role }); loadUserList(); toast(`${user} er nå ${role}`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
window.adminResetPassword = async function (user) {
const pw = prompt(`Nytt passord for ${user} (min 6 tegn). Brukeren blir logget ut overalt:`);
if (pw == null) return;
try { await api('POST', `/api/auth/users/${encodeURIComponent(user)}/password`, { password: pw }); toast(`Passord tilbakestilt for ${user}`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
window.adminDeleteUser = async function (user) {
if (!confirm(`Slette kontoen «${user}»? Dette kan ikke angres.`)) return;
try { await api('DELETE', `/api/auth/users/${encodeURIComponent(user)}`); loadUserList(); toast(`Konto «${user}» slettet`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
async function logoutAll() {
if (!confirm('Logg ut alle enheter? Du forblir innlogget her, men alle andre økter avsluttes.')) return;
try { await api('POST', '/api/auth/logout-all'); toast('Alle andre økter er logget ut'); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
async function saveAccountPassword() { async function saveAccountPassword() {
const pw = $('account-newpw').value; const pw = $('account-newpw').value;
const pw2 = $('account-newpw2').value; const pw2 = $('account-newpw2').value;
@ -1426,9 +1552,10 @@ async function saveAccountPassword() {
const body = { password: pw }; const body = { password: pw };
if ($('account-current-field').style.display !== 'none') body.current = $('account-current').value; if ($('account-current-field').style.display !== 'none') body.current = $('account-current').value;
try { try {
await api('POST', '/api/auth/password', body); const r = await api('POST', '/api/auth/password', body);
state.auth.hasPassword = true; state.auth.hasPassword = true;
state.auth.via = 'session'; state.auth.via = 'session';
if (r.role) { state.auth.role = r.role; state.auth.isAdmin = r.role === 'admin'; }
closeAccount(); closeAccount();
renderHeader(); renderHeader();
toast('Passord lagret — du er nå innlogget med app-sesjon'); toast('Passord lagret — du er nå innlogget med app-sesjon');
@ -1479,6 +1606,10 @@ function bindUI() {
$('plan-name').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } }); $('plan-name').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } });
$('plan-shared').addEventListener('change', togglePlanShared); $('plan-shared').addEventListener('change', togglePlanShared);
$('plan-move-btn').addEventListener('click', movePlanToDate); $('plan-move-btn').addEventListener('click', movePlanToDate);
$('plan-picker-btn').addEventListener('click', e => { e.stopPropagation(); togglePlanPicker(); });
$('plan-picker-search').addEventListener('input', e => renderPlanPicker(e.target.value));
$('plan-picker-panel').addEventListener('click', e => e.stopPropagation());
document.addEventListener('click', () => togglePlanPicker(false)); // klikk utenfor lukker
$('fieldBtn').addEventListener('click', toggleFieldMode); $('fieldBtn').addEventListener('click', toggleFieldMode);
bindPlanContainer(); bindPlanContainer();
@ -1550,6 +1681,9 @@ function bindUI() {
$('account-cancel').addEventListener('click', closeAccount); $('account-cancel').addEventListener('click', closeAccount);
$('account-save').addEventListener('click', saveAccountPassword); $('account-save').addEventListener('click', saveAccountPassword);
$('account-logout').addEventListener('click', logout); $('account-logout').addEventListener('click', logout);
$('account-logout-all').addEventListener('click', logoutAll);
$('admin-create-btn').addEventListener('click', adminCreateUser);
$('admin-new-pass').addEventListener('keydown', e => { if (e.key === 'Enter') adminCreateUser(); });
$('account-modal').addEventListener('click', e => { if (e.target.id === 'account-modal') closeAccount(); }); $('account-modal').addEventListener('click', e => { if (e.target.id === 'account-modal') closeAccount(); });
$('account-newpw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveAccountPassword(); }); $('account-newpw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveAccountPassword(); });

View file

@ -146,6 +146,13 @@
<div class="plan-col plan-today"> <div class="plan-col plan-today">
<div class="plan-today-head"> <div class="plan-today-head">
<div class="plan-date-row"> <div class="plan-date-row">
<div class="plan-picker">
<button id="plan-picker-btn" type="button" title="Bla i / søk planer">📋 Planer ▾</button>
<div class="plan-picker-panel" id="plan-picker-panel">
<input type="text" id="plan-picker-search" placeholder="Søk navn, dato, person …" autocomplete="off">
<div class="plan-picker-list" id="plan-picker-list"></div>
</div>
</div>
<span class="plan-cal">📅</span> <span class="plan-cal">📅</span>
<input type="date" id="plan-date" title="Velg dag å planlegge"> <input type="date" id="plan-date" title="Velg dag å planlegge">
<input type="text" id="plan-name" class="plan-name-input" maxlength="80" autocomplete="off" <input type="text" id="plan-name" class="plan-name-input" maxlength="80" autocomplete="off"
@ -327,9 +334,9 @@
</div> </div>
<div class="modal-overlay" id="account-modal"> <div class="modal-overlay" id="account-modal">
<div class="modal" style="max-width:440px"> <div class="modal" style="max-width:520px">
<div class="modal-head"> <div class="modal-head">
<h2>Konto</h2> <h2>Konto <span id="account-role-badge" class="role-badge" style="display:none"></span></h2>
<button class="icon-btn" id="account-close" style="font-size:18px">×</button> <button class="icon-btn" id="account-close" style="font-size:18px">×</button>
</div> </div>
<div class="modal-warning" id="account-status"></div> <div class="modal-warning" id="account-status"></div>
@ -346,12 +353,29 @@
<label>Bekreft passord</label> <label>Bekreft passord</label>
<input type="password" id="account-newpw2" autocomplete="new-password"> <input type="password" id="account-newpw2" autocomplete="new-password">
</div> </div>
<div class="form-field full" style="flex-direction:row;gap:8px;align-items:center;margin-top:2px">
<button class="primary" id="account-save">Lagre passord</button>
<button id="account-logout">Logg ut</button>
<button class="danger" id="account-logout-all" title="Tilbakekall alle aktive økter">Logg ut alle enheter</button>
</div>
<div id="account-admin" style="display:none">
<div class="admin-divider">Administrer kontoer</div>
<div class="admin-create">
<input type="text" id="admin-new-user" placeholder="brukernavn" autocomplete="off">
<input type="password" id="admin-new-pass" placeholder="passord (min 6)" autocomplete="new-password">
<select id="admin-new-role">
<option value="standard">Standard</option>
<option value="admin">Admin</option>
</select>
<button class="primary" id="admin-create-btn">Opprett</button>
</div>
<div id="admin-userlist" class="admin-userlist"></div>
</div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button class="danger" id="account-logout">Logg ut</button>
<div style="flex:1"></div> <div style="flex:1"></div>
<button id="account-cancel">Avbryt</button> <button id="account-cancel">Lukk</button>
<button class="primary" id="account-save">Lagre passord</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -47,21 +47,54 @@
.card { .card {
position: relative; z-index: 2; width: 100%; max-width: 420px; position: relative; z-index: 2; width: 100%; max-width: 420px;
background: rgba(10,14,22,0.72); backdrop-filter: blur(12px); background: rgba(10,14,22,0.72); backdrop-filter: blur(12px);
border: 1px solid rgba(79,230,255,0.25); border-radius: 14px; padding: 34px 30px 28px; border: 1px solid rgba(79,230,255,0.35); border-radius: 14px; padding: 34px 30px 28px;
box-shadow: 0 0 0 1px rgba(255,92,244,0.08), 0 24px 70px rgba(0,0,0,0.6), 0 0 60px rgba(79,230,255,0.12); box-shadow: 0 0 0 1px rgba(255,92,244,0.1), 0 24px 70px rgba(0,0,0,0.6), 0 0 60px rgba(79,230,255,0.12);
animation: borderpulse 3.6s ease-in-out infinite;
} }
.prompt { font-family: var(--mono); font-size: 12px; color: var(--cyan); letter-spacing: 1px; margin-bottom: 8px; opacity: 0.85; } @keyframes borderpulse {
0%,100% { border-color: rgba(79,230,255,0.35); box-shadow: 0 0 0 1px rgba(255,92,244,0.1), 0 24px 70px rgba(0,0,0,0.6), 0 0 50px rgba(79,230,255,0.12); }
50% { border-color: rgba(79,230,255,0.6); box-shadow: 0 0 0 1px rgba(255,92,244,0.18), 0 24px 70px rgba(0,0,0,0.6), 0 0 80px rgba(79,230,255,0.28); }
}
/* Tynn scanline over kortet + svak CRT-støy */
.card::before {
content: ""; position: absolute; inset: 0; border-radius: 14px; pointer-events: none;
background: repeating-linear-gradient(0deg, transparent 0, transparent 2px, rgba(79,230,255,0.025) 3px, transparent 4px);
mix-blend-mode: screen;
}
/* HUD-hjørner */
.corner { position: absolute; width: 18px; height: 18px; border: 2px solid var(--cyan);
filter: drop-shadow(0 0 4px var(--cyan)); pointer-events: none; }
.corner.tl { top: -1px; left: -1px; border-right: 0; border-bottom: 0; border-top-left-radius: 14px; }
.corner.tr { top: -1px; right: -1px; border-left: 0; border-bottom: 0; border-top-right-radius: 14px; border-color: var(--magenta); filter: drop-shadow(0 0 4px var(--magenta)); }
.corner.bl { bottom: -1px; left: -1px; border-right: 0; border-top: 0; border-bottom-left-radius: 14px; border-color: var(--magenta); filter: drop-shadow(0 0 4px var(--magenta)); }
.corner.br { bottom: -1px; right: -1px; border-left: 0; border-top: 0; border-bottom-right-radius: 14px; }
.hud { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.prompt { font-family: var(--mono); font-size: 12px; color: var(--cyan); letter-spacing: 1px; opacity: 0.85; }
.prompt::before { content: " "; } .prompt::before { content: " "; }
.lock { font-family: var(--mono); font-size: 10px; letter-spacing: 1.5px; color: var(--magenta);
border: 1px solid rgba(255,92,244,0.4); border-radius: 4px; padding: 3px 7px;
text-shadow: 0 0 8px rgba(255,92,244,0.7); animation: lockblink 2.4s steps(1) infinite; }
@keyframes lockblink { 0%,70%,100% { opacity: 1; } 78%,88% { opacity: 0.3; } }
h1 { h1 {
font-size: 40px; font-weight: 800; letter-spacing: 1px; line-height: 1; font-size: 42px; font-weight: 800; letter-spacing: 1px; line-height: 1; position: relative;
color: #eafcff; text-shadow: 0 0 8px var(--cyan), 0 0 22px rgba(79,230,255,0.7), 0 0 40px rgba(79,230,255,0.4); color: #eafcff;
animation: flicker 4.5s infinite; text-shadow: -1.5px 0 rgba(255,92,244,0.55), 1.5px 0 rgba(79,230,255,0.55),
0 0 8px var(--cyan), 0 0 22px rgba(79,230,255,0.75), 0 0 44px rgba(79,230,255,0.45);
animation: flicker 4.5s infinite, glitch 7s steps(1) infinite;
} }
h1 .x { color: var(--magenta); text-shadow: 0 0 8px var(--magenta), 0 0 22px rgba(255,92,244,0.7), 0 0 40px rgba(255,92,244,0.4); } h1 .x { color: var(--magenta); text-shadow: 0 0 8px var(--magenta), 0 0 22px rgba(255,92,244,0.75), 0 0 44px rgba(255,92,244,0.45); }
@keyframes flicker { @keyframes flicker {
0%,18%,22%,25%,53%,57%,100% { opacity: 1; } 0%,18%,22%,25%,53%,57%,100% { opacity: 1; }
20%,24%,55% { opacity: 0.72; } 20%,24%,55% { opacity: 0.72; }
} }
/* Sporadisk kromatisk «glitch»-forskyvning */
@keyframes glitch {
0%,90%,100% { transform: translate(0,0); text-shadow: -1.5px 0 rgba(255,92,244,0.55), 1.5px 0 rgba(79,230,255,0.55), 0 0 8px var(--cyan), 0 0 22px rgba(79,230,255,0.75); }
91% { transform: translate(2px,-1px); text-shadow: -3px 0 var(--magenta), 3px 0 var(--cyan), 0 0 26px rgba(79,230,255,0.9); }
93% { transform: translate(-2px,1px); text-shadow: 3px 0 var(--magenta), -3px 0 var(--cyan), 0 0 26px rgba(79,230,255,0.9); }
95% { transform: translate(1px,0); }
}
.teaser { color: var(--dim); font-size: 13.5px; line-height: 1.6; margin: 14px 0 22px; } .teaser { color: var(--dim); font-size: 13.5px; line-height: 1.6; margin: 14px 0 22px; }
.teaser b { color: var(--text); font-weight: 600; } .teaser b { color: var(--text); font-weight: 600; }
.ticker { font-family: var(--mono); font-size: 11px; color: var(--green); .ticker { font-family: var(--mono); font-size: 11px; color: var(--green);
@ -87,7 +120,8 @@
background: linear-gradient(90deg, var(--cyan), #7fe9ff); background: linear-gradient(90deg, var(--cyan), #7fe9ff);
box-shadow: 0 0 22px rgba(79,230,255,0.5); transition: all 0.18s; box-shadow: 0 0 22px rgba(79,230,255,0.5); transition: all 0.18s;
} }
button:hover { box-shadow: 0 0 34px rgba(79,230,255,0.85); transform: translateY(-1px); } button:hover { box-shadow: 0 0 40px rgba(79,230,255,1); transform: translateY(-1px); letter-spacing: 1.5px; }
button::after { content: ""; }
button:disabled { opacity: 0.6; cursor: wait; } button:disabled { opacity: 0.6; cursor: wait; }
.msg { min-height: 1.2em; font-size: 13px; color: var(--magenta); font-family: var(--mono); .msg { min-height: 1.2em; font-size: 13px; color: var(--magenta); font-family: var(--mono);
text-shadow: 0 0 10px rgba(255,92,244,0.5); text-align: center; } text-shadow: 0 0 10px rgba(255,92,244,0.5); text-align: center; }
@ -105,7 +139,12 @@
<div class="scan"></div> <div class="scan"></div>
<div class="card"> <div class="card">
<div class="prompt">network-engineer@nms:~$ ssh netops</div> <span class="corner tl"></span><span class="corner tr"></span>
<span class="corner bl"></span><span class="corner br"></span>
<div class="hud">
<div class="prompt">network-engineer@nms:~$ ssh netops</div>
<span class="lock">● LOCKED</span>
</div>
<h1>NetOps<span class="x">//</span></h1> <h1>NetOps<span class="x">//</span></h1>
<p class="teaser"> <p class="teaser">
Bak denne porten <b>puster nettet</b>. Pakker flyr, rutene konvergerer, Bak denne porten <b>puster nettet</b>. Pakker flyr, rutene konvergerer,

View file

@ -387,6 +387,26 @@ tbody tr.status-done .task-title { text-decoration: line-through; }
.plan-move label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; } .plan-move label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; }
.plan-move input[type="date"] { padding: 5px 7px; font-size: 12px; } .plan-move input[type="date"] { padding: 5px 7px; font-size: 12px; }
.plan-move button { padding: 6px 10px; font-size: 12px; } .plan-move button { padding: 6px 10px; font-size: 12px; }
/* Søkbar planvelger */
.plan-picker { position: relative; }
.plan-picker > button { padding: 6px 10px; font-size: 12px; }
.plan-picker-panel { display: none; position: absolute; z-index: 30; top: calc(100% + 6px); left: 0;
width: 320px; max-width: 80vw; background: var(--card); border: 1px solid var(--border-strong);
border-radius: 8px; box-shadow: 0 16px 40px rgba(0,0,0,0.5); padding: 10px; }
.plan-picker-panel.open { display: block; }
.plan-picker-panel input { width: 100%; margin-bottom: 8px; }
.plan-picker-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.plan-pick-row { display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 6px;
cursor: pointer; background: var(--bg-soft); border: 1px solid var(--border); font-size: 13px; }
.plan-pick-row:hover { border-color: var(--accent); background: var(--card-hover); }
.plan-pick-row .pp-date { font-family: var(--mono); font-size: 12px; color: var(--accent); white-space: nowrap; }
.plan-pick-row .pp-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-pick-row .pp-name.unnamed { color: var(--text-faint); font-style: italic; }
.plan-pick-row .pp-who { font-family: var(--mono); font-size: 11px; color: var(--purple); }
.plan-pick-row .pp-who.me { color: var(--ok); }
.plan-pick-row .pp-count { font-family: var(--mono); font-size: 11px; color: var(--text-faint); }
.plan-pick-empty { color: var(--text-faint); font-size: 12px; font-style: italic; padding: 12px 8px; text-align: center; }
.plan-owner-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .plan-owner-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.plan-owner-row > label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; } .plan-owner-row > label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; }
.plan-owner-row select { padding: 5px 8px; font-size: 12px; } .plan-owner-row select { padding: 5px 8px; font-size: 12px; }
@ -404,6 +424,22 @@ tbody tr.status-done .task-title { text-decoration: line-through; }
.plan-badge.me { background: rgba(74,222,128,0.1); color: var(--ok); border-color: rgba(74,222,128,0.3); } .plan-badge.me { background: rgba(74,222,128,0.1); color: var(--ok); border-color: rgba(74,222,128,0.3); }
.plan-badge.more { opacity: 0.8; } .plan-badge.more { opacity: 0.8; }
/* ---------- Konto / admin ---------- */
.role-badge { font-family: var(--mono); font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.5px; vertical-align: middle; }
.role-badge.admin { background: rgba(255,92,244,0.14); color: #ff8af6; border: 1px solid rgba(255,92,244,0.35); }
.role-badge.standard { background: rgba(139,152,169,0.12); color: var(--text-dim); border: 1px solid var(--border); }
.admin-divider { grid-column: 1 / -1; margin-top: 10px; padding-top: 12px; border-top: 1px solid var(--border);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); font-weight: 600; }
.admin-create { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
.admin-create input { flex: 1; min-width: 110px; }
.admin-userlist { display: flex; flex-direction: column; gap: 6px; max-height: 220px; overflow-y: auto; }
.admin-user { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--bg-soft);
border: 1px solid var(--border); border-radius: 6px; font-size: 13px; }
.admin-user .au-name { font-family: var(--mono); flex: 1; }
.admin-user .au-actions { display: flex; gap: 4px; }
.admin-user .au-me { color: var(--text-faint); font-size: 11px; font-family: var(--mono); }
/* ---------- Toast ---------- */ /* ---------- Toast ---------- */
.toast-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200; .toast-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200;
display: flex; flex-direction: column; gap: 8px; align-items: flex-end; } display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
@ -466,6 +502,19 @@ body.field-mode .row-actions { gap: 8px; }
/* Modaler fyller skjermen bedre på mobil */ /* Modaler fyller skjermen bedre på mobil */
body.field-mode .modal { max-width: 100%; } body.field-mode .modal { max-width: 100%; }
/* ---------- Neon-aksenter (samme tema som login) ---------- */
header h1 { text-shadow: 0 0 10px rgba(79,195,247,0.4), 0 0 26px rgba(79,195,247,0.18); }
#conn-dot.live { box-shadow: 0 0 12px var(--ok), 0 0 20px rgba(74,222,128,0.5); }
button.primary { box-shadow: 0 0 16px rgba(79,195,247,0.35); }
button.primary:hover { box-shadow: 0 0 28px rgba(79,195,247,0.6); }
button.active-view { box-shadow: inset 0 0 0 1px rgba(79,195,247,0.4), 0 0 14px rgba(79,195,247,0.22); }
.user-chip { box-shadow: 0 0 14px rgba(79,195,247,0.18); }
.kpi::before { box-shadow: 0 0 10px rgba(79,195,247,0.45); }
.kpi.kpi-p1::before { box-shadow: 0 0 12px var(--p1); }
.kpi.kpi-ok::before { box-shadow: 0 0 12px var(--ok); }
.kpi.kpi-me::before { box-shadow: 0 0 12px var(--purple); }
.badge.prio-P1 { box-shadow: 0 0 10px rgba(255,83,112,0.3); }
@media (max-width: 1250px) { .board { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 1250px) { .board { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 1100px) { .chart-row { grid-template-columns: 1fr 1fr; } } @media (max-width: 1100px) { .chart-row { grid-template-columns: 1fr 1fr; } }
@media (max-width: 820px) { .planner { grid-template-columns: 1fr; } } @media (max-width: 820px) { .planner { grid-template-columns: 1fr; } }

View file

@ -99,6 +99,13 @@ function createServer({
return { user: null, authed: false, via: null }; 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) { function sessionCookie(req, token, maxAgeSec) {
const secure = (req.headers['x-forwarded-proto'] === 'https' || req.socket.encrypted) ? '; Secure' : ''; const secure = (req.headers['x-forwarded-proto'] === 'https' || req.socket.encrypted) ? '; Secure' : '';
const age = maxAgeSec != null ? `; Max-Age=${maxAgeSec}` : ''; const age = maxAgeSec != null ? `; Max-Age=${maxAgeSec}` : '';
@ -216,6 +223,8 @@ function createServer({
user: req.user, user: req.user,
via: req.authVia, via: req.authVia,
hasPassword: req.authed ? auth.hasPassword(req.user) : false, 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 }); 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). // Sett/endre eget passord. Krever innlogging (sesjon eller proxy-bypass).
// Har du allerede passord og er innlogget via sesjon, kreves nåværende passord. // 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) => { app.post('/api/auth/password', (req, res) => {
if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' }); 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' }); 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' }); 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 }); } catch (e) { return res.status(400).json({ error: e.message }); }
// Logg inn via sesjon umiddelbart (så proxy-auth kan skrus av etterpå). // Logg inn via sesjon umiddelbart (så proxy-auth kan skrus av etterpå).
res.setHeader('Set-Cookie', sessionCookie(req, auth.makeToken(user), auth.ttlSeconds())); 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) ----- // ----- Guard: alt under her krever innlogging (kun i app-modus) -----
@ -270,7 +290,10 @@ function createServer({
res.json({ res.json({
user: req.user, user: req.user,
canSetName: COOKIE_AUTH_ENABLED && authMode !== 'app', 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(), tasks: store.list(),
activity: store.activity, activity: store.activity,
known_users: [...store.knownUsers].sort(), 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) => { app.get('/api/plan', (req, res) => {
const date = validDate(req.query.date) ? req.query.date : todayStr(); 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; const owner = typeof req.query.user === 'string' && req.query.user ? sanitizeUser(req.query.user) : req.user;

View file

@ -508,3 +508,72 @@ test('proxy-modus (default): ingen guard, alt åpent', async () => {
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal((await res.json()).auth.mode, 'proxy'); assert.equal((await res.json()).auth.mode, 'proxy');
}); });
// ---------- Roller, admin og tilbakekalling ----------
// jon fikk admin via bootstrap (proxy-bypass) i en tidligere test; passord = 'hemmelig123'.
test('app-auth: bootstrap-bruker ble admin', async () => {
const res = await aReq('GET', '/api/auth/users', undefined, { 'X-Remote-User': 'jon' });
assert.equal(res.status, 200);
const { users } = await res.json();
const jon = users.find(u => u.user === 'jon');
assert.equal(jon.role, 'admin');
});
test('app-auth: admin oppretter standard-bruker; standard er ikke admin', async () => {
let res = await aReq('POST', '/api/auth/users', { user: 'kari', password: 'karipass1', role: 'standard' }, { 'X-Remote-User': 'jon' });
assert.equal(res.status, 201);
// kari (via proxy-bypass, men finnes som standard) får ikke admin-endepunkt
res = await aReq('GET', '/api/auth/users', undefined, { 'X-Remote-User': 'kari' });
assert.equal(res.status, 403);
});
test('app-auth: passordbytte tilbakekaller gamle tokens', async () => {
let res = await aReq('POST', '/api/auth/login', { user: 'jon', password: 'hemmelig123' });
const oldCookie = cookieFrom(res);
assert.ok(oldCookie);
res = await aReq('POST', '/api/auth/password', { current: 'hemmelig123', password: 'jonpass2' }, { Cookie: oldCookie });
assert.equal(res.status, 200);
const newCookie = cookieFrom(res);
// Gammel cookie er nå ugyldig (epoke bumpet), ny virker
res = await aReq('GET', '/api/state', undefined, { Cookie: oldCookie });
assert.equal(res.status, 401);
res = await aReq('GET', '/api/state', undefined, { Cookie: newCookie });
assert.equal(res.status, 200);
});
test('app-auth: logg ut alle enheter dreper andre økter, beholder denne', async () => {
const a = cookieFrom(await aReq('POST', '/api/auth/login', { user: 'jon', password: 'jonpass2' }));
const b = cookieFrom(await aReq('POST', '/api/auth/login', { user: 'jon', password: 'jonpass2' }));
const res = await aReq('POST', '/api/auth/logout-all', undefined, { Cookie: a });
const aNew = cookieFrom(res);
// b (eldre epoke) er død, a fornyet virker
assert.equal((await aReq('GET', '/api/state', undefined, { Cookie: b })).status, 401);
assert.equal((await aReq('GET', '/api/state', undefined, { Cookie: aNew })).status, 200);
});
test('app-auth: kan ikke slette egen konto eller demote siste admin', async () => {
let res = await aReq('DELETE', '/api/auth/users/jon', undefined, { 'X-Remote-User': 'jon' });
assert.equal(res.status, 400);
res = await aReq('PUT', '/api/auth/users/jon/role', { role: 'standard' }, { 'X-Remote-User': 'jon' });
assert.equal(res.status, 400); // siste admin
});
test('app-auth: admin endrer rolle og resetter passord (logger ut brukeren)', async () => {
// kari logger inn
const kariCookie = cookieFrom(await aReq('POST', '/api/auth/login', { user: 'kari', password: 'karipass1' }));
assert.ok(kariCookie);
// jon (admin) resetter karis passord → karis token dør
let res = await aReq('POST', '/api/auth/users/kari/password', { password: 'nyttkari1' }, { 'X-Remote-User': 'jon' });
assert.equal(res.status, 200);
assert.equal((await aReq('GET', '/api/state', undefined, { Cookie: kariCookie })).status, 401);
// jon gjør kari til admin
res = await aReq('PUT', '/api/auth/users/kari/role', { role: 'admin' }, { 'X-Remote-User': 'jon' });
assert.equal(res.status, 200);
assert.equal((await res.json()).role, 'admin');
});