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

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

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

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

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

View file

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

View file

@ -326,6 +326,36 @@
</div>
</div>
<div class="modal-overlay" id="account-modal">
<div class="modal" style="max-width:440px">
<div class="modal-head">
<h2>Konto</h2>
<button class="icon-btn" id="account-close" style="font-size:18px">×</button>
</div>
<div class="modal-warning" id="account-status"></div>
<div class="modal-body" style="grid-template-columns:1fr">
<div class="form-field full" id="account-current-field" style="display:none">
<label>Nåværende passord</label>
<input type="password" id="account-current" autocomplete="current-password">
</div>
<div class="form-field full">
<label id="account-pw-label">Nytt passord</label>
<input type="password" id="account-newpw" autocomplete="new-password" placeholder="minst 6 tegn">
</div>
<div class="form-field full">
<label>Bekreft passord</label>
<input type="password" id="account-newpw2" autocomplete="new-password">
</div>
</div>
<div class="modal-foot">
<button class="danger" id="account-logout">Logg ut</button>
<div style="flex:1"></div>
<button id="account-cancel">Avbryt</button>
<button class="primary" id="account-save">Lagre passord</button>
</div>
</div>
</div>
<div class="toast-stack" id="toast-stack"></div>
<script src="app.js"></script>

View file

@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetOps · innlogging</title>
<style>
:root {
--bg: #06080d;
--cyan: #4fe6ff;
--magenta: #ff5cf4;
--green: #4ade80;
--text: #e6edf3;
--dim: #8b98a9;
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text); overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px;
}
/* Bevegelig neon-rutenett i bakgrunnen */
.grid-bg {
position: fixed; inset: -50%; z-index: 0;
background-image:
linear-gradient(rgba(79,230,255,0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,92,244,0.06) 1px, transparent 1px);
background-size: 44px 44px;
transform: perspective(420px) rotateX(58deg);
animation: drift 18s linear infinite;
}
@keyframes drift { from { background-position: 0 0; } to { background-position: 0 440px; } }
/* Glød-orbs */
.orb { position: fixed; border-radius: 50%; filter: blur(90px); opacity: 0.5; z-index: 0; }
.orb.a { width: 380px; height: 380px; background: var(--cyan); top: -120px; left: -100px; animation: float1 12s ease-in-out infinite; }
.orb.b { width: 320px; height: 320px; background: var(--magenta); bottom: -120px; right: -80px; animation: float2 14s ease-in-out infinite; }
@keyframes float1 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(40px,30px); } }
@keyframes float2 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(-30px,-40px); } }
/* Scanline-sveip */
.scan { position: fixed; left: 0; right: 0; height: 140px; z-index: 1; pointer-events: none;
background: linear-gradient(180deg, transparent, rgba(79,230,255,0.06), transparent);
animation: sweep 6s linear infinite; }
@keyframes sweep { from { top: -140px; } to { top: 100%; } }
.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);
}
.prompt { font-family: var(--mono); font-size: 12px; color: var(--cyan); letter-spacing: 1px; margin-bottom: 8px; opacity: 0.85; }
.prompt::before { content: " "; }
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;
}
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); }
@keyframes flicker {
0%,18%,22%,25%,53%,57%,100% { opacity: 1; }
20%,24%,55% { opacity: 0.72; }
}
.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);
border-left: 2px solid rgba(74,222,128,0.5); padding-left: 9px; margin: 0 0 24px;
min-height: 1.4em; text-shadow: 0 0 8px rgba(74,222,128,0.5); }
.ticker .cursor { animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
form { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
label { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--dim); }
input {
background: rgba(2,6,12,0.8); color: var(--text); font-size: 15px; font-family: var(--mono);
border: 1px solid rgba(79,230,255,0.22); border-radius: 8px; padding: 13px 14px; transition: all 0.18s;
}
input:focus {
outline: none; border-color: var(--cyan);
box-shadow: 0 0 0 1px var(--cyan), 0 0 18px rgba(79,230,255,0.4);
}
button {
margin-top: 6px; padding: 14px; border-radius: 8px; cursor: pointer; font-size: 15px; font-weight: 700;
letter-spacing: 0.5px; color: #02121a; border: none;
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: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; }
.foot { margin-top: 18px; text-align: center; font-family: var(--mono); font-size: 11px; color: #45525f; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green);
box-shadow: 0 0 8px var(--green); margin-right: 5px; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (max-width: 480px) { h1 { font-size: 32px; } .card { padding: 28px 22px; } }
</style>
</head>
<body>
<div class="grid-bg"></div>
<div class="orb a"></div>
<div class="orb b"></div>
<div class="scan"></div>
<div class="card">
<div class="prompt">network-engineer@nms:~$ ssh netops</div>
<h1>NetOps<span class="x">//</span></h1>
<p class="teaser">
Bak denne porten <b>puster nettet</b>. Pakker flyr, rutene konvergerer,
og noen P1-er venter på akkurat deg. <b>Logg inn</b> for å se hva som rører seg.
</p>
<div class="ticker" id="ticker"><span class="cursor"></span></div>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="user">Brukernavn</label>
<input type="text" id="user" name="username" autocomplete="username" autocapitalize="none" autofocus required>
</div>
<div class="field">
<label for="password">Passord</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" id="submit">Koble til ⟶</button>
<div class="msg" id="msg"></div>
</form>
<div class="foot"><span class="dot"></span>system online · venter på autentisering</div>
</div>
<script>
// Teaser-ticker: skriv ut linjer som om noe skjer bak porten.
const lines = [
'link up · 10GbE · core-sw01 ↔ dist-sw03',
'bgp: 4 naboer etablert, 812k prefiks',
'ospf: alle adjacencies FULL',
'queue: 7 åpne oppgaver venter …',
'tilgang kreves for å fortsette_',
];
const tickerEl = document.getElementById('ticker');
let li = 0, ci = 0;
function type() {
if (li >= lines.length) return;
const line = lines[li];
ci++;
tickerEl.innerHTML = line.slice(0, ci) + '<span class="cursor"></span>';
if (ci < line.length) { setTimeout(type, 28 + Math.random() * 40); }
else { li++; ci = 0; setTimeout(type, 1100); }
}
setTimeout(type, 500);
const form = document.getElementById('login-form');
const msg = document.getElementById('msg');
const btn = document.getElementById('submit');
form.addEventListener('submit', async e => {
e.preventDefault();
msg.textContent = '';
btn.disabled = true; btn.textContent = 'Autentiserer …';
try {
const res = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user: document.getElementById('user').value.trim(),
password: document.getElementById('password').value,
}),
});
const data = await res.json().catch(() => ({}));
if (res.ok) { msg.style.color = 'var(--green)'; msg.textContent = '✓ tilgang innvilget'; location.href = '/'; return; }
msg.textContent = data.error || 'Innlogging feilet';
} catch {
msg.textContent = 'Ingen kontakt med server';
}
btn.disabled = false; btn.textContent = 'Koble til ⟶';
});
</script>
</body>
</html>