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>
178 lines
8 KiB
HTML
178 lines
8 KiB
HTML
<!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>
|