netops/netops-todo-node/public/login.html
Jon Vanvik f6268440d9 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>
2026-06-15 14:27:13 +02:00

178 lines
8 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>