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 <noreply@anthropic.com>
217 lines
11 KiB
HTML
217 lines
11 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.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;
|
||
}
|
||
@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: 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.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);
|
||
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 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; }
|
||
.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">
|
||
<span class="corner tl"></span><span class="corner tr"></span>
|
||
<span class="corner bl"></span><span class="corner br"></span>
|
||
<div class="hud">
|
||
<div class="prompt">network-engineer@nms:~$ ssh netops</div>
|
||
<span class="lock">● LOCKED</span>
|
||
</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>
|