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:
parent
7287f33e47
commit
f6268440d9
9 changed files with 711 additions and 42 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
178
netops-todo-node/public/login.html
Normal file
178
netops-todo-node/public/login.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue