rebrand: Klassenportal, domain info1.ifb.lol, server status

- Replace INFO1 brand with Klassenportal everywhere (titles, nav,
  emails, TOTP issuer, recovery codes)
- Update domain from info1.simon0x.xyz to info1.ifb.lol
- Remove E2EE claims (e2ee.js was deleted, claims were false)
- Add GET /api/health endpoint (DB check + uptime)
- Add live server status section to landing page
- Fix README: domain, title, layout table
This commit is contained in:
Simon
2026-04-22 21:30:40 +02:00
parent 55cfbcebdc
commit 578dd4eab9
12 changed files with 173 additions and 397 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INFO1 · Admin</title>
<title>Klassenportal · Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@@ -128,7 +128,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
<header>
<div class="brand">
<div class="brand-school">Klassenportal</div>
<div class="brand-class">INFO1</div>
<div class="brand-class">Klassenportal</div>
</div>
<div class="h-spacer"></div>
<span class="admin-badge">Admin-Panel</span>
+10 -15
View File
@@ -3,12 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INFO1 · Dashboard</title>
<title>Klassenportal · Dashboard</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="/e2ee.js"></script>
<style>
:root {
--blue: #2563eb; --blue-d: #1d4ed8;
@@ -1018,10 +1017,10 @@ footer {
<button id="sidebar-btn" class="h-icon-btn" onclick="openSidebar()" title="Menü" aria-label="Seitenleiste öffnen">&#9776;</button>
<div class="brand" onclick="location.href='/'">
<div class="brand-mark">i1</div>
<div class="brand-mark">KP</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</span>
<span class="brand-name">Klassenportal</span>
</div>
</div>
@@ -1426,7 +1425,7 @@ footer {
<div id="fs-backdrop" onclick="closeOverlay()"></div>
<footer>
<div class="footer-left">Daten auf <strong>Hetzner-Server, Nürnberg, Deutschland</strong> · EU-DSGVO konform</div>
<div class="footer-left">Daten auf <strong>Server in Deutschland</strong> · EU-DSGVO konform</div>
<div class="footer-links">
<a href="/datenschutz">Datenschutzerklärung</a>
</div>
@@ -1745,7 +1744,7 @@ function loginUI(username,id,role,subject){
loadSubjectsDatalist();
loadAll();
initChat(username, id);
initChat(username);
}
function toggleDropdown(el){
@@ -2216,10 +2215,10 @@ function copyRecoveryCodes(){
}
function downloadRecoveryCodes(){
const text='INFO1 Wiederherstellungscodes\n'+new Date().toLocaleString('de-DE')+'\n\n'+_recoveryCodes.join('\n')+'\n\nJeder Code kann nur einmal verwendet werden.';
const text='Klassenportal Wiederherstellungscodes\n'+new Date().toLocaleString('de-DE')+'\n\n'+_recoveryCodes.join('\n')+'\n\nJeder Code kann nur einmal verwendet werden.';
const a=document.createElement('a');
a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(text);
a.download='info1-wiederherstellungscodes.txt';
a.download='klassenportal-wiederherstellungscodes.txt';
a.click();
}
@@ -2314,7 +2313,7 @@ async function renderChatMsg(m, append) {
const div = document.createElement('div');
div.className = 'chat-msg';
div.dataset.id = m.id;
const displayContent = await E2EE.decrypt(m.content);
const displayContent = m.content;
div.innerHTML = `<div class="chat-msg-meta">
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
@@ -2353,10 +2352,7 @@ async function sendChatMsg() {
const content = inp.value.trim();
if (!content) return;
inp.value = '';
let ciphertext;
try { ciphertext = await E2EE.encrypt(content); }
catch { toast('Verschlüsselung fehlgeschlagen', 'error'); inp.value = content; return; }
const r = await api('POST', 'chat', { content: ciphertext });
const r = await api('POST', 'chat', { content });
if (r.error) { toast(r.error, 'error'); inp.value = content; return; }
await renderChatMsg(r, true);
chatLastId = Math.max(chatLastId, r.id);
@@ -2369,9 +2365,8 @@ async function delChatMsg(id) {
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
}
async function initChat(username, userId) {
async function initChat(username) {
chatMyUsername = username;
await E2EE.init(userId);
await loadChat();
pollChat();
document.getElementById('chat-input').addEventListener('keydown', e => {
+5 -6
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutzerklärung · INFO1 Dashboard</title>
<title>Datenschutzerklärung · Klassenportal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
@@ -33,17 +33,17 @@
</head>
<body>
<header>
<span class="brand">INFO1 Dashboard</span>
<span class="brand">Klassenportal</span>
<a class="back" href="/">← Zurück</a>
</header>
<main>
<h1>Datenschutzerklärung</h1>
<p class="subtitle">Zuletzt aktualisiert: April 2026 · Gilt für info1.simon0x.xyz</p>
<p class="subtitle">Zuletzt aktualisiert: April 2026 · Gilt für info1.ifb.lol</p>
<section>
<h2>1. Verantwortlicher</h2>
<div class="box">
<p>Dieses Dashboard wird privat von einer Privatperson betrieben, ausschließlich für interne Klassenzwecke der Klasse INFO1. Es handelt sich weder um ein kommerzielles Angebot noch um ein offizielles Angebot einer Schule, eines Trägers oder einer Bildungseinrichtung.</p>
<p>Dieses Klassenportal wird privat von einer Privatperson betrieben, ausschließlich für interne Klassenzwecke. Es handelt sich weder um ein kommerzielles Angebot noch um ein offizielles Angebot einer Schule, eines Trägers oder einer Bildungseinrichtung.</p>
<p style="margin-top:10px"><strong>Kontakt:</strong> <a href="mailto:kontakt@simon0x.xyz">kontakt@simon0x.xyz</a></p>
</div>
</section>
@@ -71,9 +71,8 @@
<section>
<h2>3. Wo werden die Daten gespeichert?</h2>
<p>Alle Daten werden in einer SQLite-Datenbank auf einem virtuellen Server der <strong>Hetzner Online GmbH</strong> (Industriestr. 25, 91710 Gunzenhausen, Deutschland) gespeichert. Der Server befindet sich physisch im Rechenzentrum Nürnberg (Deutschland) und unterliegt damit deutschem und europäischem Datenschutzrecht.</p>
<p>Alle Daten werden in einer SQLite-Datenbank auf einem virtuellen Server in <strong>Deutschland</strong> gespeichert. Der Server unterliegt deutschem und europäischem Datenschutzrecht.</p>
<p>Es findet <strong>keine Übertragung in Drittländer</strong> (außerhalb der EU/EWR) statt.</p>
<p>Hetzner ist als Auftragsverarbeiter gem. Art. 28 DSGVO tätig. Datenschutzerklärung Hetzner: <a href="https://www.hetzner.com/legal/privacy-policy" target="_blank" rel="noopener">hetzner.com/legal/privacy-policy</a></p>
</section>
<section>
-197
View File
@@ -1,197 +0,0 @@
'use strict';
// E2EE module — Signal-style hybrid encryption for group chat.
// Uses Web Crypto API only. Private keys never leave this device.
const E2EE = (() => {
const GROUP_ID = 'info1';
const IDB_NAME = 'ifb_e2ee_v1';
const IDB_STORE = 'k';
const sub = crypto.subtle;
const te = new TextEncoder();
const td = new TextDecoder();
const EC = { name: 'ECDH', namedCurve: 'P-256' };
// ── IndexedDB ─────────────────────────────────────────────────
function openIDB() {
return new Promise((res, rej) => {
const r = indexedDB.open(IDB_NAME, 1);
r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
r.onsuccess = e => res(e.target.result);
r.onerror = () => rej(r.error);
});
}
async function idbGet(key) {
const db = await openIDB();
return new Promise((res, rej) => {
const t = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(key);
t.onsuccess = () => res(t.result);
t.onerror = () => rej(t.error);
});
}
async function idbSet(key, val) {
const db = await openIDB();
return new Promise((res, rej) => {
const t = db.transaction(IDB_STORE, 'readwrite').objectStore(IDB_STORE).put(val, key);
t.onsuccess = () => res();
t.onerror = () => rej(t.error);
});
}
// ── REST helper ───────────────────────────────────────────────
async function rpc(method, path, body) {
try {
const r = await fetch('/api/' + path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
return r.json();
} catch (e) {
console.error('[E2EE] rpc', method, path, e);
return null;
}
}
// ── Binary helpers ────────────────────────────────────────────
function toB64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); }
function fromB64(str) { return Uint8Array.from(atob(str), c => c.charCodeAt(0)); }
function randomId() {
try { return crypto.randomUUID(); }
catch { return toB64(crypto.getRandomValues(new Uint8Array(18))); }
}
// ── Key generation & import ───────────────────────────────────
const genECDH = () => sub.generateKey(EC, true, ['deriveKey', 'deriveBits']);
const importECPub = jwk => sub.importKey('jwk', jwk, EC, true, []);
const importECPriv = jwk => sub.importKey('jwk', jwk, EC, true, ['deriveKey', 'deriveBits']);
const importAES = raw => sub.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
const genAES = () => sub.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const exportRaw = key => sub.exportKey('raw', key);
// ECDH(myPriv, theirPub) → HKDF → AES-256-GCM wrap key
async function deriveWrapKey(myPriv, theirPub) {
const bits = await sub.deriveBits({ name: 'ECDH', public: theirPub }, myPriv, 256);
const hk = await sub.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
return sub.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: te.encode('ifb-e2ee-v1'), info: te.encode(GROUP_ID) },
hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
);
}
// ── AES-GCM encrypt/decrypt ───────────────────────────────────
async function aesEncrypt(key, data) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await sub.encrypt({ name: 'AES-GCM', iv }, key, data);
return { iv: toB64(iv), ct: toB64(ct) };
}
async function aesDecrypt(key, ivB64, ctB64) {
return sub.decrypt({ name: 'AES-GCM', iv: fromB64(ivB64) }, key, fromB64(ctB64));
}
// ── Sender key wrap/unwrap ────────────────────────────────────
async function wrapSenderKey(rawKey, myPriv, theirPub) {
const wk = await deriveWrapKey(myPriv, theirPub);
const { iv, ct } = await aesEncrypt(wk, rawKey);
return JSON.stringify({ iv, ct });
}
async function unwrapSenderKey(encJson, myPriv, distPub) {
const { iv, ct } = JSON.parse(encJson);
const wk = await deriveWrapKey(myPriv, distPub);
return aesDecrypt(wk, iv, ct);
}
// ── State ─────────────────────────────────────────────────────
let myKeyPair = null; // { priv: CryptoKey, pub: CryptoKey }
let myUserId = null;
let activeSK = null; // { key: CryptoKey, kid: string }
let kidCache = {}; // kid → CryptoKey
// ── Core logic ────────────────────────────────────────────────
async function init(userId) {
myUserId = userId;
try {
// Load or generate P-256 identity key pair in IndexedDB
let privJwk = await idbGet('priv_' + userId);
let pubJwk = await idbGet('pub_' + userId);
if (!privJwk || !pubJwk) {
const kp = await genECDH();
privJwk = await sub.exportKey('jwk', kp.privateKey);
pubJwk = await sub.exportKey('jwk', kp.publicKey);
await idbSet('priv_' + userId, privJwk);
await idbSet('pub_' + userId, pubJwk);
}
myKeyPair = { priv: await importECPriv(privJwk), pub: await importECPub(pubJwk) };
// Publish public key (server upserts, idempotent)
const pubResp = await rpc('POST', 'e2ee/public-key', { public_key_jwk: JSON.stringify(pubJwk) });
if (!pubResp || pubResp.error) console.warn('[E2EE] public-key publish failed', pubResp);
// Fresh sender key per session → forward secrecy
await rotateSenderKey();
} catch (e) {
console.error('[E2EE] init failed', e);
throw e;
}
}
async function rotateSenderKey() {
const kid = randomId();
const senderKey = await genAES();
const raw = await exportRaw(senderKey);
// Distribute encrypted copy to every member who has a public key
const usersResp = await rpc('GET', 'e2ee/users');
const users = Array.isArray(usersResp) ? usersResp : [];
const dists = [];
for (const u of users) {
try {
const theirPub = await importECPub(JSON.parse(u.public_key_jwk));
dists.push({ user_id: u.id, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, theirPub) });
} catch {}
}
// Always include self (may not be in users list yet)
if (!dists.some(d => d.user_id === myUserId)) {
dists.push({ user_id: myUserId, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, myKeyPair.pub) });
}
await rpc('POST', 'e2ee/group-keys', { group_id: GROUP_ID, kid, keys: dists });
activeSK = { key: senderKey, kid };
kidCache[kid] = senderKey;
}
async function resolveKid(kid) {
if (kidCache[kid]) return kidCache[kid];
const row = await rpc('GET', `e2ee/group-key?group_id=${GROUP_ID}&kid=${encodeURIComponent(kid)}`);
if (!row || row.error) return null;
try {
const distPubResp = await rpc('GET', 'e2ee/public-key/' + row.distributor_user_id);
if (!distPubResp?.public_key_jwk) return null;
const distPub = await importECPub(JSON.parse(distPubResp.public_key_jwk));
const rawBuf = await unwrapSenderKey(row.encrypted_key, myKeyPair.priv, distPub);
const key = await importAES(rawBuf);
kidCache[kid] = key;
return key;
} catch { return null; }
}
async function encrypt(plaintext) {
if (!activeSK) throw new Error('E2EE not initialized');
const { iv, ct } = await aesEncrypt(activeSK.key, te.encode(plaintext));
// ts included for replay detection on client side
return JSON.stringify({ v: 1, kid: activeSK.kid, iv, ct, ts: Date.now() });
}
async function decrypt(content) {
let p;
try { p = JSON.parse(content); } catch { return content; } // not encrypted
if (!p.v || !p.kid || !p.iv || !p.ct) return content; // legacy plaintext
const key = await resolveKid(p.kid);
if (!key) return '[Nachricht nicht entschlusselbar]';
try {
const buf = await aesDecrypt(key, p.iv, p.ct);
return td.decode(buf);
} catch { return '[Entschlusselung fehlgeschlagen]'; }
}
return { init, encrypt, decrypt, rotateSenderKey };
})();
+113 -24
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INFO1 · Das Klassen-Cockpit</title>
<meta name="description" content="INFO1 ist ein privates Klassenportal für die INFO1-Klasse. Stundenplan, Noten, Hausaufgaben, Klassen-Chat und mehr.">
<title>Klassenportal · Das Klassen-Cockpit</title>
<meta name="description" content="Privates Klassenportal für Schüler und Lehrer. Stundenplan, Noten, Hausaufgaben, Klassen-Chat und mehr.">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
@@ -538,6 +538,35 @@ footer {
flex-wrap: wrap; gap: 12px;
}
/* ── STATUS ───────────────────── */
.status-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
max-width: 760px;
margin: 0 auto;
}
.status-card {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
background: var(--subtle);
}
.status-dot.ok { background: var(--green); box-shadow: 0 0 0 3px rgba(22,163,74,.15); }
.status-dot.err { background: var(--red); box-shadow: 0 0 0 3px rgba(220,38,38,.15); }
.status-label { font-size: 14px; font-weight: 600; color: var(--text); }
.status-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
@media (max-width: 600px) { .status-grid { grid-template-columns: 1fr; } }
/* ── REDACT ──────────────────── */
.redact {
display: inline-block;
@@ -581,10 +610,10 @@ footer {
<nav class="top">
<div class="nav-inner">
<a class="brand" href="/">
<div class="brand-mark">i1</div>
<div class="brand-mark">KP</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</span>
<span class="brand-name">Klassenportal</span>
</div>
</a>
<div class="nav-links">
@@ -592,6 +621,7 @@ footer {
<a href="#ueber">Für wen</a>
<a href="#sicherheit">Sicherheit</a>
<a href="#faq">FAQ</a>
<a href="#status">Status</a>
</div>
<div class="nav-right">
<button class="icon-btn" onclick="toggleDark()" title="Dark Mode" aria-label="Dark Mode"><i data-lucide="moon"></i></button>
@@ -605,7 +635,7 @@ footer {
<section class="hero">
<div class="hero-inner">
<div>
<div class="eyebrow"><i data-lucide="sparkles"></i> Gemacht für die INFO1-Klasse</div>
<div class="eyebrow"><i data-lucide="sparkles"></i> Gemacht für Schüler und Lehrer</div>
<h1 class="hero-title">
Dein Schulalltag, <span class="accent">an einem Ort.</span>
</h1>
@@ -624,7 +654,7 @@ footer {
<div class="hero-trust">
<span><i data-lucide="check"></i> Nur für IFB-Schüler</span>
<span><i data-lucide="check"></i> 2-Faktor-Schutz</span>
<span><i data-lucide="check"></i> Hetzner · Nürnberg</span>
<span><i data-lucide="check"></i> Server in Deutschland</span>
</div>
</div>
@@ -715,7 +745,7 @@ footer {
<div class="feat">
<div class="feat-icon"><i data-lucide="message-square"></i></div>
<h3>Klassen-Chat</h3>
<p>Ende-zu-Ende verschlüsselter Austausch mit der Klasse direkt im Dashboard.</p>
<p>Gemeinsamer Echtzeit-Chat mit der Klasse direkt im Dashboard, kein WhatsApp nötig.</p>
</div>
<div class="feat">
<div class="feat-icon"><i data-lucide="folder"></i></div>
@@ -753,7 +783,7 @@ footer {
<div class="sec-eyebrow">Für wen</div>
<h2 class="sec-title">Zwei Perspektiven, ein System</h2>
<p class="sec-lead">
Ob du im Klassenzimmer sitzt oder davor stehst INFO1 passt sich an deine Rolle an.
Ob du im Klassenzimmer sitzt oder davor stehst das Portal passt sich an deine Rolle an.
</p>
</div>
@@ -766,7 +796,7 @@ footer {
<li><i data-lucide="check"></i> Persönliches Dashboard mit deinem Stundenplan</li>
<li><i data-lucide="check"></i> Noten-Übersicht mit automatischem Schnitt</li>
<li><i data-lucide="check"></i> Hausaufgaben, To-Dos und Countdowns an einem Ort</li>
<li><i data-lucide="check"></i> Verschlüsselter Klassen-Chat + Dateispeicher</li>
<li><i data-lucide="check"></i> Klassen-Chat + persönlicher Dateispeicher</li>
<li><i data-lucide="check"></i> Shared Kalender für Klausuren &amp; Events</li>
</ul>
</div>
@@ -801,8 +831,8 @@ footer {
<div class="sec-grid">
<div class="sec-card">
<i data-lucide="shield-check"></i>
<h4>DSGVO &amp; Hetzner DE</h4>
<p>Server ausschließlich in Nürnberg. Keine Datenübertragung in Drittländer.</p>
<h4>DSGVO &amp; Deutschland</h4>
<p>Server ausschließlich in Deutschland. Keine Datenübertragung in Drittländer.</p>
</div>
<div class="sec-card">
<i data-lucide="key-round"></i>
@@ -810,9 +840,9 @@ footer {
<p>Optionaler TOTP-Schutz per Authenticator-App für deinen Account.</p>
</div>
<div class="sec-card">
<i data-lucide="lock"></i>
<h4>Ende-zu-Ende-Chat</h4>
<p>Chat-Nachrichten werden im Browser verschlüsselt der Server liest nicht mit.</p>
<i data-lucide="zap-off"></i>
<h4>Kein Tracking</h4>
<p>Keine Werbung, keine Analytics, keine Weitergabe. Nur die Daten, die der Betrieb braucht.</p>
</div>
<div class="sec-card">
<i data-lucide="mail-check"></i>
@@ -835,7 +865,7 @@ footer {
<details class="faq-item">
<summary>Was kostet die Nutzung?</summary>
<div class="faq-body">
Nichts. INFO1 ist ein privates Projekt für die Klasse und kostet die Nutzer keinen Cent.
Nichts. Das Klassenportal ist ein privates Projekt und kostet die Nutzer keinen Cent.
</div>
</details>
<details class="faq-item">
@@ -848,15 +878,14 @@ footer {
<details class="faq-item">
<summary>Wo liegen meine Daten?</summary>
<div class="faq-body">
Auf einem Hetzner-Server in Nürnberg, Deutschland. Es findet keine Übertragung in Drittländer statt.
Auf einem Server in Deutschland. Es findet keine Übertragung in Drittländer statt.
Details in der <a href="/datenschutz" style="color:var(--blue);">Datenschutzerklärung</a>.
</div>
</details>
<details class="faq-item">
<summary>Kann der Admin meinen Chat lesen?</summary>
<div class="faq-body">
Nein. Chat-Nachrichten werden im Browser mit einem Klassen-Schlüssel verschlüsselt, bevor sie den Server erreichen.
Auf dem Server liegen nur Chiffre-Texte.
Ja. Chat-Nachrichten sind für den Admin einsehbar.
</div>
</details>
<details class="faq-item">
@@ -867,7 +896,7 @@ footer {
</div>
</details>
<details class="faq-item">
<summary>Läuft INFO1 auch auf dem Handy?</summary>
<summary>Läuft das Klassenportal auch auf dem Handy?</summary>
<div class="faq-body">
Ja. Das Dashboard ist voll responsive und funktioniert auf Smartphone, Tablet und Desktop gleich gut.
</div>
@@ -876,6 +905,39 @@ footer {
</div>
</section>
<!-- STATUS -->
<section id="status">
<div class="section-inner">
<div class="sec-head">
<div class="sec-eyebrow">Systemstatus</div>
<h2 class="sec-title">Alles im grünen Bereich</h2>
</div>
<div class="status-grid">
<div class="status-card">
<div class="status-dot" id="st-api"></div>
<div>
<div class="status-label">API</div>
<div class="status-sub" id="st-api-sub">Wird geprüft…</div>
</div>
</div>
<div class="status-card">
<div class="status-dot" id="st-db"></div>
<div>
<div class="status-label">Datenbank</div>
<div class="status-sub" id="st-db-sub">Wird geprüft…</div>
</div>
</div>
<div class="status-card">
<div class="status-dot" id="st-up"></div>
<div>
<div class="status-label">Uptime</div>
<div class="status-sub" id="st-up-sub">Wird geprüft…</div>
</div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section>
<div class="cta-band">
@@ -893,13 +955,13 @@ footer {
<div class="foot-inner">
<div class="foot-about">
<a class="brand" href="/">
<div class="brand-mark">i1</div>
<div class="brand-mark">KP</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</span>
<span class="brand-name">Klassenportal</span>
</div>
</a>
<p>Privates Klassenportal für die INFO1-Klasse. Kein offizielles Angebot einer Schule oder eines Trägers. Daten gehostet in Deutschland.</p>
<p>Privates Klassenportal für Schüler und Lehrer. Kein offizielles Angebot einer Schule oder eines Trägers. Daten gehostet in Deutschland.</p>
</div>
<div class="foot-col">
<h5>Produkt</h5>
@@ -927,8 +989,8 @@ footer {
</div>
</div>
<div class="foot-bottom">
<span>© <span id="y"></span> INFO1 · Privates Klassenportal (inoffiziell)</span>
<span>Daten auf Hetzner, Nürnberg · EU-DSGVO konform</span>
<span>© <span id="y"></span> Klassenportal · Privat &amp; inoffiziell</span>
<span>Daten in Deutschland · EU-DSGVO konform</span>
</div>
</footer>
@@ -942,6 +1004,33 @@ footer {
try { if (localStorage.getItem('dark') === '1') document.body.classList.add('dark'); } catch {}
lucide.createIcons();
(async function checkStatus() {
function fmt(s) {
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
}
try {
const r = await fetch('/api/health');
const d = await r.json();
const ok = d.ok === true;
document.getElementById('st-api').className = 'status-dot ' + (ok ? 'ok' : 'err');
document.getElementById('st-api-sub').textContent = ok ? 'Online' : 'Fehler';
document.getElementById('st-db').className = 'status-dot ' + (ok ? 'ok' : 'err');
document.getElementById('st-db-sub').textContent = ok ? 'Online' : 'Nicht erreichbar';
document.getElementById('st-up').className = 'status-dot ok';
document.getElementById('st-up-sub').textContent = ok ? fmt(d.uptime) : '';
} catch {
['st-api','st-db','st-up'].forEach(id => {
document.getElementById(id).className = 'status-dot err';
});
document.getElementById('st-api-sub').textContent = 'Nicht erreichbar';
document.getElementById('st-db-sub').textContent = '';
document.getElementById('st-up-sub').textContent = '';
}
})();
</script>
</body>
</html>
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INFO1 · Anmelden</title>
<title>Klassenportal · Anmelden</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@@ -194,10 +194,10 @@ footer a:hover { color: #2563eb; }
<div class="brand-row">
<a class="brand" href="/">
<div class="brand-mark">i1</div>
<div class="brand-mark">KP</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</span>
<span class="brand-name">Klassenportal</span>
</div>
</a>
<a class="back-link" href="/">← Dashboard</a>
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INFO1 · Passwort zurücksetzen</title>
<title>Klassenportal · Passwort zurücksetzen</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@@ -70,10 +70,10 @@ footer a { color: #6b7280; text-decoration: none; }
<div class="card">
<div class="brand-row">
<a class="brand" href="/">
<div class="brand-mark">i1</div>
<div class="brand-mark">KP</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</span>
<span class="brand-name">Klassenportal</span>
</div>
</a>
<a class="back-link" href="/login">← Login</a>