From 3f69cd4248fe741848687fe017e8d58c740826f1 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Mon, 15 Jun 2026 14:56:39 +0200 Subject: [PATCH] =?UTF-8?q?Auth-herding:=20roller,=20kontoadmin,=20token-r?= =?UTF-8?q?evokering=20+=20s=C3=B8kbar=20planliste=20+=20neon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- netops-todo-node/CLAUDE.md | 23 ++++- netops-todo-node/README.md | 27 +++++- netops-todo-node/lib/auth.js | 85 +++++++++++++++--- netops-todo-node/public/app.js | 138 ++++++++++++++++++++++++++++- netops-todo-node/public/index.html | 34 +++++-- netops-todo-node/public/login.html | 57 ++++++++++-- netops-todo-node/public/style.css | 49 ++++++++++ netops-todo-node/server.js | 72 ++++++++++++++- netops-todo-node/test/api.test.js | 69 +++++++++++++++ 9 files changed, 518 insertions(+), 36 deletions(-) diff --git a/netops-todo-node/CLAUDE.md b/netops-todo-node/CLAUDE.md index 7cac4e7..6ba22d8 100644 --- a/netops-todo-node/CLAUDE.md +++ b/netops-todo-node/CLAUDE.md @@ -74,9 +74,18 @@ netops-todo-node/ 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')` + `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 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) @@ -156,6 +165,11 @@ hardkodede `CATEGORIES`/`CAT_COLORS`. muterer `state.plan.items` og kaller `savePlan()` (PUT) som reconciler mot serverens validerte svar. Container-nivå drag-lyttere bindes én gang (`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 autorisasjonsregel i appen — alt annet er åpent for alle innloggede). - 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 -- `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 normalisering, versjonskonflikt, kommentar-autorisasjon, statuskommentar, dagsplan (CRUD, per-bruker, validering, scrub, dato-fallback, navn, flytt/merge, deling + tilgang, planIndex per viewer), egendefinerte kategorier, aktivitetslogg, WS-broadcast (delt vs. privat plan), persistens, cookie-auth, og app-auth - (401-guard, login-side på navigasjon, proxy-bypass, passord-bootstrap, sesjon, - feil passord, TRUST_PROXY_HEADER=0). Alle grønne per 2026-06-15 (Node 25). + (401-guard, login-side, proxy-bypass, passord-bootstrap, sesjon, feil passord, + 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 delt plan, annen brukers tilgang (delt 200 / privat 403), flytt med bevart navn+delingsstatus, og planIndex verifisert. diff --git a/netops-todo-node/README.md b/netops-todo-node/README.md index 4460e0e..802bada 100644 --- a/netops-todo-node/README.md +++ b/netops-todo-node/README.md @@ -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 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` (default) regnes en proxy-identitet (X-Remote-User eller Basic Auth) som 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 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 - 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, hvilken dag og av hvem. - **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/auth/status` | Auth-modus, innlogget?, bruker, via, har passord | | 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? }` | +| 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) | | PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk; valgfri `comment` festes som status-kommentar) | | DELETE | `/api/tasks/:id` | Slett | diff --git a/netops-todo-node/lib/auth.js b/netops-todo-node/lib/auth.js index 148616c..20c8bd5 100644 --- a/netops-todo-node/lib/auth.js +++ b/netops-todo-node/lib/auth.js @@ -49,6 +49,15 @@ class Auth { if (d && typeof d === 'object' && !Array.isArray(d)) this.users = d; } 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). if (!this.secret) { 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); } 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 (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(); + 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) { @@ -81,9 +142,9 @@ class Auth { return !!(u && u.password && verifyPassword(password, u.password)); } - // Signert sesjons-token. user → ".." + // Signert sesjons-token. 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'); return `${payload}.${sig}`; } @@ -91,16 +152,18 @@ class Auth { verifyToken(token) { if (typeof token !== 'string') return null; const parts = token.split('.'); - if (parts.length !== 3) return null; - const payload = `${parts[0]}.${parts[1]}`; + if (parts.length !== 4) return null; // gamle 3-delte tokens er ugyldige etter herding + const payload = `${parts[0]}.${parts[1]}.${parts[2]}`; 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); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; - const exp = Number(parts[1]); - if (!exp || Date.now() > exp) return null; - try { return Buffer.from(parts[0], 'base64url').toString('utf8') || null; } - catch { return null; } + if (!Number(parts[1]) || Date.now() > Number(parts[1])) return null; + let user; + try { user = Buffer.from(parts[0], 'base64url').toString('utf8'); } 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); } diff --git a/netops-todo-node/public/app.js b/netops-todo-node/public/app.js index 9034bde..409c2ca 100644 --- a/netops-todo-node/public/app.js +++ b/netops-todo-node/public/app.js @@ -48,7 +48,7 @@ function catName(id) { return (catById(id) || {}).name || id; } const state = { user: 'anon', canSetName: false, - auth: { mode: 'proxy', via: null, hasPassword: false }, + auth: { mode: 'proxy', via: null, hasPassword: false, role: 'standard', isAdmin: false }, tasks: [], activity: [], knownUsers: [], @@ -845,6 +845,65 @@ function renderPlan() { 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 = `
${q ? 'Ingen planer matcher søket.' : 'Ingen planer ennå.'}
`; + 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 `
+ ${d} + ${p.name ? escapeHtml(p.name) : '(uten navn)'} + ${me ? 'meg' : escapeHtml(p.user)} + ${p.count} +
`; + }).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() { const sel = $('plan-view-user'); const users = new Set([state.user, ...state.knownUsers, ...state.online]); @@ -1413,11 +1472,78 @@ function openAccount() { $('account-current').value = ''; $('account-newpw').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'); setTimeout(() => $('account-newpw').focus(), 50); } 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 `
+ ${escapeHtml(u.user)} + ${u.role} + ${me ? 'deg' : `
+ + + +
`} +
`; + }).join(''); + } catch (e) { box.innerHTML = `
Kunne ikke laste brukere: ${escapeHtml(e.message)}
`; } +} + +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() { const pw = $('account-newpw').value; const pw2 = $('account-newpw2').value; @@ -1426,9 +1552,10 @@ async function saveAccountPassword() { const body = { password: pw }; if ($('account-current-field').style.display !== 'none') body.current = $('account-current').value; try { - await api('POST', '/api/auth/password', body); + const r = await api('POST', '/api/auth/password', body); state.auth.hasPassword = true; state.auth.via = 'session'; + if (r.role) { state.auth.role = r.role; state.auth.isAdmin = r.role === 'admin'; } closeAccount(); renderHeader(); 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-shared').addEventListener('change', togglePlanShared); $('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); bindPlanContainer(); @@ -1550,6 +1681,9 @@ function bindUI() { $('account-cancel').addEventListener('click', closeAccount); $('account-save').addEventListener('click', saveAccountPassword); $('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-newpw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveAccountPassword(); }); diff --git a/netops-todo-node/public/index.html b/netops-todo-node/public/index.html index 398c08e..fbac192 100644 --- a/netops-todo-node/public/index.html +++ b/netops-todo-node/public/index.html @@ -146,6 +146,13 @@
+
+ +
+ +
+
+
📅
diff --git a/netops-todo-node/public/login.html b/netops-todo-node/public/login.html index 1acfdcb..f4024a6 100644 --- a/netops-todo-node/public/login.html +++ b/netops-todo-node/public/login.html @@ -47,21 +47,54 @@ .card { position: relative; z-index: 2; width: 100%; max-width: 420px; 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; - 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); + 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.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: "› "; } + .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 { - font-size: 40px; font-weight: 800; letter-spacing: 1px; line-height: 1; - 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); - animation: flicker 4.5s infinite; + font-size: 42px; font-weight: 800; letter-spacing: 1px; line-height: 1; position: relative; + color: #eafcff; + 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 { 0%,18%,22%,25%,53%,57%,100% { opacity: 1; } 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 b { color: var(--text); font-weight: 600; } .ticker { font-family: var(--mono); font-size: 11px; color: var(--green); @@ -87,7 +120,8 @@ background: linear-gradient(90deg, var(--cyan), #7fe9ff); 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; } .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; } @@ -105,7 +139,12 @@
-
network-engineer@nms:~$ ssh netops
+ + +
+
network-engineer@nms:~$ ssh netops
+ ● LOCKED +

NetOps//

Bak denne porten puster nettet. Pakker flyr, rutene konvergerer, diff --git a/netops-todo-node/public/style.css b/netops-todo-node/public/style.css index c7793d4..d2fdd3f 100644 --- a/netops-todo-node/public/style.css +++ b/netops-todo-node/public/style.css @@ -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 input[type="date"] { padding: 5px 7px; 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 > 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; } @@ -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.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-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200; 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 */ 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: 1100px) { .chart-row { grid-template-columns: 1fr 1fr; } } @media (max-width: 820px) { .planner { grid-template-columns: 1fr; } } diff --git a/netops-todo-node/server.js b/netops-todo-node/server.js index cfb129f..8c8c0d6 100644 --- a/netops-todo-node/server.js +++ b/netops-todo-node/server.js @@ -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; diff --git a/netops-todo-node/test/api.test.js b/netops-todo-node/test/api.test.js index cb92d83..9f37def 100644 --- a/netops-todo-node/test/api.test.js +++ b/netops-todo-node/test/api.test.js @@ -508,3 +508,72 @@ test('proxy-modus (default): ingen guard, alt åpent', async () => { assert.equal(res.status, 200); 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'); +});