clean initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data.db
|
||||
storage/
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
const routes = require('./src/routes');
|
||||
const { router: filesRouter } = require('./src/files');
|
||||
|
||||
const app = express();
|
||||
const PORT = 3010;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/api', routes);
|
||||
app.use('/api/files', filesRouter);
|
||||
|
||||
const html = f => (req, res) => res.sendFile(path.join(__dirname, 'public', f));
|
||||
|
||||
app.get('/login', html('login.html'));
|
||||
app.get('/admin', html('admin.html'));
|
||||
app.get('/datenschutz', html('datenschutz.html'));
|
||||
app.get('/{*path}', html('index.html'));
|
||||
|
||||
app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`));
|
||||
Generated
+1533
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "info1",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IFB · 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">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--blue: #2563eb; --blue-d: #1d4ed8; --blue-50: #eff6ff;
|
||||
--slate-50: #f8fafc; --slate-100: #f1f5f9; --slate-200: #e2e8f0;
|
||||
--slate-400: #94a3b8; --slate-500: #64748b; --slate-600: #475569; --slate-900: #0f172a;
|
||||
--green: #16a34a; --red: #dc2626; --amber: #d97706;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.07), 0 4px 12px rgba(0,0,0,.04);
|
||||
--shadow-lg: 0 4px 20px rgba(0,0,0,.10), 0 1px 4px rgba(0,0,0,.06);
|
||||
--r: 12px; --r-sm: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--slate-50); color: var(--slate-900); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
header { background: #fff; border-bottom: 1px solid var(--slate-200); padding: 0 28px; height: 58px; display: flex; align-items: center; gap: 14px; position: sticky; top: 0; z-index: 100; }
|
||||
.brand { display: flex; flex-direction: column; }
|
||||
.brand-school { font-size: 10px; color: var(--slate-400); font-weight: 600; letter-spacing: .6px; text-transform: uppercase; }
|
||||
.brand-class { font-size: 20px; font-weight: 800; color: var(--blue); letter-spacing: -1px; line-height: 1.1; }
|
||||
.h-spacer { flex: 1; }
|
||||
.back-link { font-size: 13px; color: var(--slate-500); text-decoration: none; font-weight: 500; padding: 6px 12px; border-radius: var(--r-sm); border: 1.5px solid var(--slate-200); }
|
||||
.back-link:hover { background: var(--slate-100); }
|
||||
.admin-badge { background: #fef3c7; color: #92400e; font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 99px; border: 1px solid #fde68a; }
|
||||
|
||||
.page-title { padding: 24px 28px 0; }
|
||||
.page-title h1 { font-size: 22px; font-weight: 800; }
|
||||
.page-title p { font-size: 13px; color: var(--slate-500); margin-top: 4px; }
|
||||
|
||||
/* TABS */
|
||||
.tabs { display: flex; gap: 4px; padding: 16px 28px 0; border-bottom: 1px solid var(--slate-200); background: var(--slate-50); position: sticky; top: 58px; z-index: 90; }
|
||||
.tab { padding: 8px 16px; font-size: 13px; font-weight: 600; border: none; background: none; cursor: pointer; color: var(--slate-500); border-bottom: 2px solid transparent; margin-bottom: -1px; font-family: inherit; border-radius: var(--r-sm) var(--r-sm) 0 0; transition: color .15s; }
|
||||
.tab:hover { color: var(--blue); background: var(--slate-100); }
|
||||
.tab.active { color: var(--blue); border-bottom-color: var(--blue); }
|
||||
.tab-badge { background: var(--red); color: #fff; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 99px; margin-left: 5px; }
|
||||
|
||||
.content { padding: 24px 28px; flex: 1; }
|
||||
|
||||
/* SECTION */
|
||||
.section { display: none; }
|
||||
.section.active { display: block; }
|
||||
|
||||
/* FILTER BAR */
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-bar input, .filter-bar select { padding: 7px 12px; border: 1.5px solid var(--slate-200); border-radius: var(--r-sm); font-size: 13px; font-family: inherit; outline: none; background: #fff; }
|
||||
.filter-bar input:focus, .filter-bar select:focus { border-color: var(--blue); }
|
||||
.filter-bar input { flex: 1; min-width: 180px; }
|
||||
.filter-count { font-size: 12px; color: var(--slate-400); font-weight: 500; margin-left: auto; }
|
||||
|
||||
/* TABLE */
|
||||
.tbl-wrap { overflow-x: auto; border-radius: var(--r); border: 1px solid var(--slate-200); background: #fff; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
thead th { background: var(--slate-50); padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 700; color: var(--slate-500); letter-spacing: .4px; text-transform: uppercase; border-bottom: 1px solid var(--slate-200); white-space: nowrap; }
|
||||
tbody tr { border-bottom: 1px solid var(--slate-100); transition: background .1s; }
|
||||
tbody tr:last-child { border-bottom: none; }
|
||||
tbody tr:hover { background: var(--slate-50); }
|
||||
tbody td { padding: 10px 14px; vertical-align: middle; }
|
||||
.td-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* BADGES */
|
||||
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 99px; white-space: nowrap; }
|
||||
.badge-green { background: #dcfce7; color: #166534; }
|
||||
.badge-red { background: #fee2e2; color: #991b1b; }
|
||||
.badge-orange { background: #fef3c7; color: #92400e; }
|
||||
.badge-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-gray { background: var(--slate-100); color: var(--slate-500); }
|
||||
.badge-purple { background: #f3e8ff; color: #6b21a8; }
|
||||
|
||||
/* BUTTONS */
|
||||
.btn { font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: var(--r-sm); border: none; cursor: pointer; font-family: inherit; white-space: nowrap; transition: opacity .15s; }
|
||||
.btn:hover { opacity: .85; }
|
||||
.btn-green { background: #dcfce7; color: #166534; }
|
||||
.btn-red { background: #fee2e2; color: #991b1b; }
|
||||
.btn-orange { background: #fef3c7; color: #92400e; }
|
||||
.btn-blue { background: var(--blue-50); color: var(--blue); }
|
||||
.btn-gray { background: var(--slate-100); color: var(--slate-600); }
|
||||
|
||||
/* STATS ROW */
|
||||
.stats-row { display: flex; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.stat-card { background: #fff; border: 1px solid var(--slate-200); border-radius: var(--r); padding: 14px 18px; flex: 1; min-width: 120px; }
|
||||
.stat-val { font-size: 24px; font-weight: 800; color: var(--blue); }
|
||||
.stat-label { font-size: 12px; color: var(--slate-500); margin-top: 2px; }
|
||||
|
||||
/* TICKET */
|
||||
.ticket-msg { font-size: 12px; color: var(--slate-500); max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* LOG */
|
||||
.log-action { font-family: monospace; font-size: 12px; background: var(--slate-100); padding: 2px 6px; border-radius: 4px; }
|
||||
.log-details { font-size: 11px; color: var(--slate-400); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* EMPTY */
|
||||
.empty { text-align: center; color: var(--slate-300); font-size: 13px; padding: 32px; font-weight: 500; }
|
||||
|
||||
/* TOAST */
|
||||
#toasts { position: fixed; bottom: 24px; right: 24px; z-index: 400; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
||||
.toast { background: #1e293b; color: #fff; font-size: 13px; font-weight: 500; padding: 10px 16px; border-radius: var(--r-sm); box-shadow: var(--shadow-lg); opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; pointer-events: none; max-width: 280px; }
|
||||
.toast.show { opacity: 1; transform: none; }
|
||||
.toast.success { background: #166534; }
|
||||
.toast.error { background: #991b1b; }
|
||||
|
||||
/* CONFIRM MODAL */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 300; display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
.modal { background: #fff; border-radius: var(--r); padding: 24px; width: 100%; max-width: 380px; box-shadow: var(--shadow-lg); }
|
||||
.modal h3 { font-size: 16px; font-weight: 700; margin-bottom: 10px; }
|
||||
.modal p { font-size: 13px; color: var(--slate-600); margin-bottom: 18px; }
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.btn-cancel { padding: 8px 16px; border: 1.5px solid var(--slate-200); border-radius: var(--r-sm); background: none; cursor: pointer; font-size: 13px; font-family: inherit; color: var(--slate-600); }
|
||||
.btn-confirm { padding: 8px 16px; background: var(--red); color: #fff; border: none; border-radius: var(--r-sm); font-size: 13px; font-weight: 600; font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* TICKET DETAIL */
|
||||
.ticket-detail { background: var(--slate-50); border: 1px solid var(--slate-200); border-radius: var(--r-sm); padding: 12px 14px; margin-top: 8px; font-size: 13px; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; }
|
||||
|
||||
/* Icons */
|
||||
.lucide { display: inline-block; vertical-align: -0.125em; flex-shrink: 0; width: 1em; height: 1em; stroke-width: 2; }
|
||||
.tab .lucide { width: 14px; height: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="brand-school">IFB-Berufsfachschule Rosenheim</div>
|
||||
<div class="brand-class">INFO1</div>
|
||||
</div>
|
||||
<div class="h-spacer"></div>
|
||||
<span class="admin-badge">Admin-Panel</span>
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
</header>
|
||||
|
||||
<div class="page-title">
|
||||
<h1>Admin-Dashboard</h1>
|
||||
<p id="admin-username-line">Eingeloggt als …</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('users')"><i data-lucide="users" aria-hidden="true"></i> Benutzer <span id="tab-badge-users" class="tab-badge" style="display:none"></span></button>
|
||||
<button class="tab" onclick="switchTab('pending')"><i data-lucide="clock" aria-hidden="true"></i> Ausstehend <span id="tab-badge-pending" class="tab-badge" style="display:none"></span></button>
|
||||
<button class="tab" onclick="switchTab('tickets')"><i data-lucide="life-buoy" aria-hidden="true"></i> Tickets <span id="tab-badge-tickets" class="tab-badge" style="display:none"></span></button>
|
||||
<button class="tab" onclick="switchTab('usage')"><i data-lucide="bar-chart-2" aria-hidden="true"></i> Datennutzung</button>
|
||||
<button class="tab" onclick="switchTab('logs')"><i data-lucide="clipboard" aria-hidden="true"></i> Aktionslog</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<!-- USERS TAB -->
|
||||
<div class="section active" id="section-users">
|
||||
<div class="stats-row" id="stats-row"></div>
|
||||
<div class="filter-bar">
|
||||
<input type="search" id="user-search" placeholder="Suche nach Benutzername / E-Mail …" oninput="filterUsers()">
|
||||
<select id="user-role-filter" onchange="filterUsers()">
|
||||
<option value="">Alle Rollen</option>
|
||||
<option value="student">Student</option>
|
||||
<option value="teacher">Lehrer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<select id="user-status-filter" onchange="filterUsers()">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="pending">Ausstehend</option>
|
||||
<option value="banned">Gesperrt</option>
|
||||
<option value="rejected">Abgelehnt</option>
|
||||
</select>
|
||||
<span class="filter-count" id="user-count"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>ID</th><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th>Registriert</th><th>Aktionen</th>
|
||||
</tr></thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PENDING TAB -->
|
||||
<div class="section" id="section-pending">
|
||||
<p style="font-size:13px;color:var(--slate-500);margin-bottom:16px">Lehrerkkonten warten auf Genehmigung.</p>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>ID</th><th>Benutzername</th><th>E-Mail</th><th>Registriert</th><th>Aktionen</th>
|
||||
</tr></thead>
|
||||
<tbody id="pending-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TICKETS TAB -->
|
||||
<div class="section" id="section-tickets">
|
||||
<div class="filter-bar">
|
||||
<select id="ticket-status-filter" onchange="filterTickets()">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="in_progress">In Bearbeitung</option>
|
||||
<option value="closed">Geschlossen</option>
|
||||
</select>
|
||||
<span class="filter-count" id="ticket-count"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>ID</th><th>Von</th><th>Betreff</th><th>Status</th><th>Erstellt</th><th>Aktionen</th>
|
||||
</tr></thead>
|
||||
<tbody id="tickets-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USAGE TAB -->
|
||||
<div class="section" id="section-usage">
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Benutzername</th><th>Rolle</th><th>Stundenplan</th><th>Hausaufgaben</th><th>Noten</th><th>Fehlzeiten</th><th>Todos</th><th>Countdowns</th><th>Links</th><th>Tickets</th>
|
||||
</tr></thead>
|
||||
<tbody id="usage-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOGS TAB -->
|
||||
<div class="section" id="section-logs">
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Zeit</th><th>Admin</th><th>Aktion</th><th>Ziel-ID</th><th>Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="logs-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- CONFIRM MODAL -->
|
||||
<div class="overlay" id="confirm-overlay" style="display:none">
|
||||
<div class="modal">
|
||||
<h3 id="confirm-title">Bestätigung</h3>
|
||||
<p id="confirm-msg"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeConfirm()">Abbrechen</button>
|
||||
<button class="btn-confirm" id="confirm-ok">Bestätigen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TICKET DETAIL MODAL -->
|
||||
<div class="overlay" id="ticket-overlay" style="display:none" onclick="if(event.target===this)closeTicket()">
|
||||
<div class="modal" style="max-width:540px">
|
||||
<h3 id="ticket-modal-title" style="word-break:break-word"></h3>
|
||||
<p id="ticket-modal-meta" style="font-size:12px;color:var(--slate-500);margin-bottom:8px"></p>
|
||||
<div id="ticket-modal-thread" style="display:flex;flex-direction:column;gap:8px;max-height:320px;overflow-y:auto;padding-right:2px;margin-bottom:10px"></div>
|
||||
<div id="ticket-modal-reply-area" style="display:flex;gap:8px;margin-bottom:4px">
|
||||
<textarea id="ticket-modal-reply" placeholder="Als Admin antworten…" rows="2" maxlength="5000"
|
||||
style="flex:1;border:1.5px solid var(--slate-200);border-radius:var(--r-sm);padding:8px 10px;font-size:13px;font-family:inherit;resize:none;outline:none"></textarea>
|
||||
<button class="btn btn-blue" style="align-self:flex-end;padding:7px 14px" onclick="sendAdminReply()">Senden</button>
|
||||
</div>
|
||||
<div class="modal-actions" style="margin-top:8px">
|
||||
<button class="btn-cancel" onclick="closeTicket()">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toasts"></div>
|
||||
|
||||
<script>
|
||||
function esc(s){ return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function toast(msg, type='success'){
|
||||
const el=document.createElement('div');
|
||||
el.className=`toast ${type}`; el.textContent=msg;
|
||||
document.getElementById('toasts').appendChild(el);
|
||||
requestAnimationFrame(()=>requestAnimationFrame(()=>el.classList.add('show')));
|
||||
setTimeout(()=>{ el.classList.remove('show'); setTimeout(()=>el.remove(),300); },3200);
|
||||
}
|
||||
|
||||
async function api(method, path, body){
|
||||
const r=await fetch('/api/'+path,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
|
||||
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||r.status); }
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ── AUTH GUARD ─────────────────────────────────────────────────
|
||||
async function authGuard(){
|
||||
const r = await fetch('/api/me');
|
||||
if(!r.ok){ location.href='/login'; return false; }
|
||||
const d = await r.json();
|
||||
if(d.role !== 'admin'){ location.href='/'; return false; }
|
||||
document.getElementById('admin-username-line').textContent = `Eingeloggt als ${d.username}`;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── TABS ───────────────────────────────────────────────────────
|
||||
let currentTab = 'users';
|
||||
function switchTab(name){
|
||||
document.querySelectorAll('.tab').forEach((t,i)=>{
|
||||
const tabs=['users','pending','tickets','usage','logs'];
|
||||
t.classList.toggle('active', tabs[i]===name);
|
||||
});
|
||||
document.querySelectorAll('.section').forEach(s=>s.classList.remove('active'));
|
||||
document.getElementById('section-'+name).classList.add('active');
|
||||
currentTab=name;
|
||||
}
|
||||
|
||||
// ── DATA STORES ────────────────────────────────────────────────
|
||||
let allUsers=[], allTickets=[], allLogs=[], allUsage=[];
|
||||
|
||||
// ── USERS ──────────────────────────────────────────────────────
|
||||
async function loadUsers(){
|
||||
allUsers = await api('GET','admin/users');
|
||||
renderStats();
|
||||
renderUsers();
|
||||
renderPending();
|
||||
}
|
||||
|
||||
function renderStats(){
|
||||
const total=allUsers.length;
|
||||
const active=allUsers.filter(u=>u.status==='active').length;
|
||||
const pending=allUsers.filter(u=>u.status==='pending').length;
|
||||
const banned=allUsers.filter(u=>u.status==='banned').length;
|
||||
document.getElementById('stats-row').innerHTML=`
|
||||
<div class="stat-card"><div class="stat-val">${total}</div><div class="stat-label">Benutzer gesamt</div></div>
|
||||
<div class="stat-card"><div class="stat-val" style="color:var(--green)">${active}</div><div class="stat-label">Aktiv</div></div>
|
||||
<div class="stat-card"><div class="stat-val" style="color:var(--amber)">${pending}</div><div class="stat-label">Ausstehend</div></div>
|
||||
<div class="stat-card"><div class="stat-val" style="color:var(--red)">${banned}</div><div class="stat-label">Gesperrt</div></div>
|
||||
`;
|
||||
const pb=document.getElementById('tab-badge-pending');
|
||||
pb.textContent=pending; pb.style.display=pending?'':'none';
|
||||
}
|
||||
|
||||
function filterUsers(){
|
||||
const q=document.getElementById('user-search').value.toLowerCase();
|
||||
const role=document.getElementById('user-role-filter').value;
|
||||
const status=document.getElementById('user-status-filter').value;
|
||||
const filtered=allUsers.filter(u=>
|
||||
(!q || u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)) &&
|
||||
(!role || u.role===role) &&
|
||||
(!status || u.status===status)
|
||||
);
|
||||
document.getElementById('user-count').textContent=`${filtered.length} von ${allUsers.length}`;
|
||||
renderUsersRows(filtered, document.getElementById('users-tbody'));
|
||||
}
|
||||
|
||||
function renderUsers(){
|
||||
filterUsers();
|
||||
}
|
||||
|
||||
function renderUsersRows(users, tbody){
|
||||
if(!users.length){ tbody.innerHTML=`<tr><td colspan="7" class="empty">Keine Benutzer gefunden</td></tr>`; return; }
|
||||
tbody.innerHTML=users.map(u=>`
|
||||
<tr>
|
||||
<td style="color:var(--slate-400);font-size:12px">${u.id}</td>
|
||||
<td><strong>${esc(u.username)}</strong></td>
|
||||
<td style="font-size:12px;color:var(--slate-500)">${esc(u.email)}</td>
|
||||
<td>${roleBadge(u.role)}</td>
|
||||
<td>${statusBadge(u.status)}</td>
|
||||
<td style="font-size:12px;color:var(--slate-400)">${fmtDate(u.created_at)}</td>
|
||||
<td><div class="td-actions">${userActions(u)}</div></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function roleBadge(r){
|
||||
const m={admin:'badge-purple',teacher:'badge-blue',student:'badge-green'};
|
||||
return `<span class="badge ${m[r]||'badge-gray'}">${esc(r)}</span>`;
|
||||
}
|
||||
function statusBadge(s){
|
||||
const m={active:'badge-green',pending:'badge-orange',banned:'badge-red',rejected:'badge-gray'};
|
||||
return `<span class="badge ${m[s]||'badge-gray'}">${esc(s)}</span>`;
|
||||
}
|
||||
|
||||
function userActions(u){
|
||||
const parts=[];
|
||||
if(u.status==='active') parts.push(`<button class="btn btn-red" onclick="banUser(${u.id},'${esc(u.username)}')">Sperren</button>`);
|
||||
if(u.status==='banned') parts.push(`<button class="btn btn-green" onclick="unbanUser(${u.id},'${esc(u.username)}')">Entsperren</button>`);
|
||||
if(u.role!=='admin') parts.push(`<button class="btn btn-gray" onclick="deleteUser(${u.id},'${esc(u.username)}')">Löschen</button>`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function renderPending(){
|
||||
const pending=allUsers.filter(u=>u.status==='pending'&&u.role==='teacher');
|
||||
const tbody=document.getElementById('pending-tbody');
|
||||
if(!pending.length){ tbody.innerHTML=`<tr><td colspan="5" class="empty">Keine ausstehenden Anfragen</td></tr>`; return; }
|
||||
tbody.innerHTML=pending.map(u=>`
|
||||
<tr>
|
||||
<td style="color:var(--slate-400);font-size:12px">${u.id}</td>
|
||||
<td><strong>${esc(u.username)}</strong></td>
|
||||
<td style="font-size:12px;color:var(--slate-500)">${esc(u.email)}</td>
|
||||
<td style="font-size:12px;color:var(--slate-400)">${fmtDate(u.created_at)}</td>
|
||||
<td><div class="td-actions">
|
||||
<button class="btn btn-green" onclick="approveTeacher(${u.id},'${esc(u.username)}')">Genehmigen</button>
|
||||
<button class="btn btn-red" onclick="rejectTeacher(${u.id},'${esc(u.username)}')">Ablehnen</button>
|
||||
</div></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
async function banUser(id, name){
|
||||
confirm2(`Benutzer "${name}" sperren?`, `Der Benutzer kann sich bis zur Entsperrung nicht anmelden.`, async()=>{
|
||||
await api('PATCH',`admin/users/${id}`,{status:'banned'});
|
||||
toast(`${name} gesperrt`,'success'); loadUsers();
|
||||
});
|
||||
}
|
||||
async function unbanUser(id, name){
|
||||
confirm2(`Benutzer "${name}" entsperren?`, `Der Benutzer kann sich wieder anmelden.`, async()=>{
|
||||
await api('PATCH',`admin/users/${id}`,{status:'active'});
|
||||
toast(`${name} entsperrt`,'success'); loadUsers();
|
||||
});
|
||||
}
|
||||
async function deleteUser(id, name){
|
||||
confirm2(`Benutzer "${name}" löschen?`, `Alle Daten dieses Benutzers werden unwiderruflich gelöscht.`, async()=>{
|
||||
await api('DELETE',`admin/users/${id}`);
|
||||
toast(`${name} gelöscht`,'success'); loadUsers();
|
||||
});
|
||||
}
|
||||
async function approveTeacher(id, name){
|
||||
await api('POST',`admin/teachers/${id}/approve`);
|
||||
toast(`${name} genehmigt`,'success'); loadUsers();
|
||||
}
|
||||
async function rejectTeacher(id, name){
|
||||
confirm2(`Lehrer "${name}" ablehnen?`, `Die Registrierung wird abgelehnt.`, async()=>{
|
||||
await api('POST',`admin/teachers/${id}/reject`);
|
||||
toast(`${name} abgelehnt`,'success'); loadUsers();
|
||||
});
|
||||
}
|
||||
|
||||
// ── TICKETS ────────────────────────────────────────────────────
|
||||
async function loadTickets(){
|
||||
allTickets = await api('GET','admin/tickets');
|
||||
filterTickets();
|
||||
const open=allTickets.filter(t=>t.status==='open').length;
|
||||
const tb=document.getElementById('tab-badge-tickets');
|
||||
tb.textContent=open; tb.style.display=open?'':'none';
|
||||
}
|
||||
|
||||
function filterTickets(){
|
||||
const status=document.getElementById('ticket-status-filter').value;
|
||||
const filtered=status?allTickets.filter(t=>t.status===status):allTickets;
|
||||
document.getElementById('ticket-count').textContent=`${filtered.length} Ticket${filtered.length!==1?'s':''}`;
|
||||
const tbody=document.getElementById('tickets-tbody');
|
||||
if(!filtered.length){ tbody.innerHTML=`<tr><td colspan="6" class="empty">Keine Tickets</td></tr>`; return; }
|
||||
tbody.innerHTML=filtered.map(t=>`
|
||||
<tr>
|
||||
<td style="color:var(--slate-400);font-size:12px">#${t.id}</td>
|
||||
<td><strong>${esc(t.username)}</strong><br><span style="font-size:11px;color:var(--slate-400)">${esc(t.email)}</span></td>
|
||||
<td>
|
||||
<div style="font-weight:600">${esc(t.subject)}</div>
|
||||
<div class="ticket-msg">${esc(t.message)}</div>
|
||||
</td>
|
||||
<td>${ticketStatusBadge(t.status)}</td>
|
||||
<td style="font-size:12px;color:var(--slate-400)">${fmtDate(t.created_at)}</td>
|
||||
<td><div class="td-actions">
|
||||
<button class="btn btn-blue" onclick="viewTicket(${t.id})">Anzeigen</button>
|
||||
${t.status!=='closed'?`<button class="btn btn-gray" onclick="closeTicketAction(${t.id})">Schließen</button>`:''}
|
||||
${t.status==='open'?`<button class="btn btn-orange" onclick="inProgressTicket(${t.id})">In Bearbeitung</button>`:''}
|
||||
</div></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function ticketStatusBadge(s){
|
||||
const m={open:'badge-red',in_progress:'badge-orange',closed:'badge-green'};
|
||||
const l={open:'Offen',in_progress:'In Bearbeitung',closed:'Geschlossen'};
|
||||
return `<span class="badge ${m[s]||'badge-gray'}">${l[s]||esc(s)}</span>`;
|
||||
}
|
||||
|
||||
let activeAdminTicket = null;
|
||||
|
||||
async function viewTicket(id){
|
||||
const t=allTickets.find(x=>x.id===id); if(!t)return;
|
||||
activeAdminTicket=t;
|
||||
document.getElementById('ticket-modal-title').textContent=t.subject;
|
||||
document.getElementById('ticket-modal-meta').textContent=`Von ${esc(t.username)} (${esc(t.email)}) · ${fmtDateTime(t.created_at)}`;
|
||||
document.getElementById('ticket-modal-reply').value='';
|
||||
document.getElementById('ticket-modal-reply-area').style.display=t.status==='closed'?'none':'flex';
|
||||
document.getElementById('ticket-modal-thread').innerHTML='<div style="text-align:center;color:var(--slate-400);font-size:13px;padding:8px">Laden…</div>';
|
||||
document.getElementById('ticket-overlay').style.display='flex';
|
||||
try{
|
||||
const msgs=await api('GET',`tickets/${id}/messages`);
|
||||
renderAdminThread(t,msgs);
|
||||
}catch(e){
|
||||
document.getElementById('ticket-modal-thread').innerHTML=`<div style="color:var(--red);font-size:12px">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAdminThread(ticket, messages){
|
||||
const wrap=document.getElementById('ticket-modal-thread');
|
||||
const rows=[];
|
||||
rows.push(`<div style="display:flex;flex-direction:column;align-items:flex-start">
|
||||
<div style="background:var(--slate-100);border-radius:10px 10px 10px 4px;padding:8px 12px;font-size:13px;max-width:85%;white-space:pre-wrap;word-break:break-word">${esc(ticket.message)}</div>
|
||||
<div style="font-size:10px;color:var(--slate-400);margin-top:3px">${esc(ticket.username)} · ${fmtDateTime(ticket.created_at)}</div>
|
||||
</div>`);
|
||||
messages.forEach(m=>{
|
||||
const isAdmin=m.role==='admin';
|
||||
rows.push(`<div style="display:flex;flex-direction:column;align-items:${isAdmin?'flex-end':'flex-start'}">
|
||||
<div style="background:${isAdmin?'#2563eb':'var(--slate-100)'};color:${isAdmin?'#fff':'var(--slate-900)'};border-radius:${isAdmin?'10px 10px 4px 10px':'10px 10px 10px 4px'};padding:8px 12px;font-size:13px;max-width:85%;white-space:pre-wrap;word-break:break-word">${esc(m.message)}</div>
|
||||
<div style="font-size:10px;color:var(--slate-400);margin-top:3px;text-align:${isAdmin?'right':'left'}">${isAdmin?'🛡️ Admin':''}${esc(m.username)} · ${fmtDateTime(m.created_at)}</div>
|
||||
</div>`);
|
||||
});
|
||||
wrap.innerHTML=rows.join('');
|
||||
wrap.scrollTop=wrap.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendAdminReply(){
|
||||
if(!activeAdminTicket)return;
|
||||
const inp=document.getElementById('ticket-modal-reply');
|
||||
const message=inp.value.trim();
|
||||
if(!message){ toast('Nachricht darf nicht leer sein','error'); return; }
|
||||
try{
|
||||
await api('POST',`tickets/${activeAdminTicket.id}/messages`,{message});
|
||||
inp.value='';
|
||||
await loadTickets();
|
||||
const updated=allTickets.find(x=>x.id===activeAdminTicket.id);
|
||||
if(updated) activeAdminTicket=updated;
|
||||
const msgs=await api('GET',`tickets/${activeAdminTicket.id}/messages`);
|
||||
renderAdminThread(activeAdminTicket,msgs);
|
||||
toast('Antwort gesendet');
|
||||
}catch(e){ toast(e.message,'error'); }
|
||||
}
|
||||
|
||||
function closeTicket(){ document.getElementById('ticket-overlay').style.display='none'; activeAdminTicket=null; }
|
||||
|
||||
async function closeTicketAction(id){
|
||||
await api('PATCH',`admin/tickets/${id}`,{status:'closed'});
|
||||
toast('Ticket geschlossen'); loadTickets();
|
||||
}
|
||||
async function inProgressTicket(id){
|
||||
await api('PATCH',`admin/tickets/${id}`,{status:'in_progress'});
|
||||
toast('Status aktualisiert'); loadTickets();
|
||||
}
|
||||
|
||||
// ── USAGE ──────────────────────────────────────────────────────
|
||||
async function loadUsage(){
|
||||
allUsage = await api('GET','admin/usage');
|
||||
const tbody=document.getElementById('usage-tbody');
|
||||
if(!allUsage.length){ tbody.innerHTML=`<tr><td colspan="10" class="empty">Keine Daten</td></tr>`; return; }
|
||||
const tables=['timetable','homework','grades','absences','todos','countdowns','quicklinks','support_tickets'];
|
||||
tbody.innerHTML=allUsage.map(u=>`
|
||||
<tr>
|
||||
<td><strong>${esc(u.username)}</strong></td>
|
||||
<td>${roleBadge(u.role)}</td>
|
||||
${tables.map(t=>`<td style="text-align:center;font-size:13px;color:${u.counts[t]>0?'var(--slate-900)':'var(--slate-300)'}">${u.counts[t]}</td>`).join('')}
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ── LOGS ───────────────────────────────────────────────────────
|
||||
async function loadLogs(){
|
||||
allLogs = await api('GET','admin/logs');
|
||||
const tbody=document.getElementById('logs-tbody');
|
||||
if(!allLogs.length){ tbody.innerHTML=`<tr><td colspan="5" class="empty">Keine Aktionen protokolliert</td></tr>`; return; }
|
||||
tbody.innerHTML=allLogs.map(l=>`
|
||||
<tr>
|
||||
<td style="font-size:12px;color:var(--slate-400);white-space:nowrap">${fmtDateTime(l.created_at)}</td>
|
||||
<td><strong>${esc(l.admin_username)}</strong></td>
|
||||
<td><span class="log-action">${esc(l.action)}</span></td>
|
||||
<td style="text-align:center;font-size:12px;color:var(--slate-500)">${l.target_id??'–'}</td>
|
||||
<td><span class="log-details" title="${esc(l.details??'')}">${esc(l.details??'–')}</span></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ── CONFIRM MODAL ──────────────────────────────────────────────
|
||||
let confirmCallback=null;
|
||||
function confirm2(title, msg, cb){
|
||||
document.getElementById('confirm-title').textContent=title;
|
||||
document.getElementById('confirm-msg').textContent=msg;
|
||||
confirmCallback=cb;
|
||||
document.getElementById('confirm-overlay').style.display='flex';
|
||||
}
|
||||
function closeConfirm(){ document.getElementById('confirm-overlay').style.display='none'; confirmCallback=null; }
|
||||
document.getElementById('confirm-ok').addEventListener('click',async()=>{
|
||||
if(!confirmCallback)return;
|
||||
const cb=confirmCallback; closeConfirm();
|
||||
try{ await cb(); } catch(e){ toast(e.message,'error'); }
|
||||
});
|
||||
|
||||
// ── UTILS ──────────────────────────────────────────────────────
|
||||
function fmtDate(s){ return s?new Date(s).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}):'–'; }
|
||||
function fmtDateTime(s){ if(!s)return'–'; const d=new Date(s); return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})+' '+d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}); }
|
||||
|
||||
// ── INIT ───────────────────────────────────────────────────────
|
||||
(async()=>{
|
||||
lucide.createIcons();
|
||||
const ok = await authGuard();
|
||||
if(!ok)return;
|
||||
await Promise.all([loadUsers(), loadTickets()]);
|
||||
// Lazy-load usage + logs on tab switch
|
||||
document.querySelectorAll('.tab').forEach((t,i)=>{
|
||||
const tabs=['users','pending','tickets','usage','logs'];
|
||||
t.addEventListener('click',()=>{
|
||||
if(tabs[i]==='usage') loadUsage();
|
||||
if(tabs[i]==='logs') loadLogs();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Datenschutzerklärung · INFO1 Dashboard</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>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; background: #f8fafc; color: #0f172a; min-height: 100vh; }
|
||||
header { background: #fff; border-bottom: 1px solid #e2e8f0; padding: 0 32px; height: 56px; display: flex; align-items: center; gap: 16px; }
|
||||
.brand { font-size: 16px; font-weight: 800; color: #2563eb; letter-spacing: -.5px; }
|
||||
.back { font-size: 13px; color: #64748b; text-decoration: none; margin-left: auto; }
|
||||
.back:hover { color: #2563eb; }
|
||||
main { max-width: 740px; margin: 48px auto; padding: 0 24px 80px; }
|
||||
h1 { font-size: 28px; font-weight: 800; letter-spacing: -.5px; margin-bottom: 6px; }
|
||||
.subtitle { font-size: 14px; color: #64748b; margin-bottom: 40px; }
|
||||
section { margin-bottom: 36px; }
|
||||
h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; }
|
||||
p { font-size: 14px; line-height: 1.75; color: #334155; margin-bottom: 10px; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
ul { padding-left: 20px; margin-top: 6px; }
|
||||
li { font-size: 14px; line-height: 1.75; color: #334155; margin-bottom: 4px; }
|
||||
.tag { display: inline-block; background: #eff6ff; color: #1d4ed8; font-size: 12px; font-weight: 600; padding: 2px 9px; border-radius: 6px; margin-right: 4px; }
|
||||
.box { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px 24px; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
|
||||
th { text-align: left; padding: 8px 12px; background: #f8fafc; font-weight: 600; color: #374151; border-bottom: 1px solid #e2e8f0; }
|
||||
td { padding: 8px 12px; color: #334155; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="brand">INFO1 Dashboard</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>
|
||||
|
||||
<section>
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<div class="box">
|
||||
<p>Dieses Dashboard wird privat betrieben von einem Schüler der IFB-Berufsfachschule Rosenheim ausschließlich für interne Klassenzwecke der Klasse INFO1. Es handelt sich um kein kommerzielles Angebot.</p>
|
||||
<p style="margin-top:10px"><strong>Kontakt:</strong> <a href="mailto:kontakt@simon0x.xyz">kontakt@simon0x.xyz</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Welche Daten werden gespeichert?</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datenkategorie</th><th>Inhalt</th><th>Zweck</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><span class="tag">Account</span></td><td>Benutzername (Klartext), Passwort als bcrypt-Hash (nicht lesbar, nicht umkehrbar)</td><td>Authentifizierung</td></tr>
|
||||
<tr><td><span class="tag">Stundenplan</span></td><td>Fach, Raum, Lehrkraft, Uhrzeit, Wochentag</td><td>Persönliche Stundenplanverwaltung</td></tr>
|
||||
<tr><td><span class="tag">Hausaufgaben</span></td><td>Titel, Fach, Fälligkeitsdatum, Erledigungsstatus</td><td>Aufgabenverwaltung</td></tr>
|
||||
<tr><td><span class="tag">Noten</span></td><td>Fach, Note, Notentyp, optionale Anmerkung</td><td>Notenübersicht und Durchschnittsberechnung</td></tr>
|
||||
<tr><td><span class="tag">Fehlzeiten</span></td><td>Datum, Fach, Grund</td><td>Fehlzeitentracking</td></tr>
|
||||
<tr><td><span class="tag">To-Do</span></td><td>Aufgabentitel, Erledigungsstatus</td><td>Persönliche Aufgabenliste</td></tr>
|
||||
<tr><td><span class="tag">Countdowns</span></td><td>Bezeichnung, Zieldatum</td><td>Ereignis-Countdowns</td></tr>
|
||||
<tr><td><span class="tag">Links</span></td><td>Bezeichnung, URL</td><td>Persönliche Schnellzugriffe</td></tr>
|
||||
<tr><td><span class="tag">Klassenkalender</span></td><td>Prüfungen und Ferieneinträge (klassenöffentlich)</td><td>Gemeinsame Terminübersicht der Klasse</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top:12px">Es werden <strong>keine</strong> Echtname, E-Mail-Adressen, IP-Adressen (dauerhaft), Gerätedaten oder Verhaltensprofile gespeichert. Es gibt kein Tracking, keine Analytics und keine Werbung.</p>
|
||||
</section>
|
||||
|
||||
<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>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>
|
||||
<h2>4. Rechtsgrundlage</h2>
|
||||
<p>Die Verarbeitung erfolgt auf Grundlage von <strong>Art. 6 Abs. 1 lit. a DSGVO</strong> (Einwilligung). Du gibst deine Einwilligung durch die freiwillige Registrierung. Du kannst sie jederzeit widerrufen, indem du deinen Account löschst (siehe Abschnitt 7).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Zugriff auf deine Daten</h2>
|
||||
<p>Alle persönlichen Einträge (Stundenplan, Noten, Hausaufgaben usw.) sind ausschließlich für den jeweiligen Account sichtbar. <strong>Kein anderer Nutzer</strong> kann deine persönlichen Daten sehen.</p>
|
||||
<p>Klassenkalender-Einträge (Prüfungen, Ferien) sind für alle Besucher der Seite sichtbar, da sie Klasseninformationen darstellen und keine persönlichen Daten enthalten.</p>
|
||||
<p>Der Serverbetreiber hat technischen Zugriff auf die Datenbankdatei. Passwörter sind jedoch als bcrypt-Hash gespeichert und nicht lesbar.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Speicherdauer</h2>
|
||||
<p>Daten werden gespeichert bis:</p>
|
||||
<ul>
|
||||
<li>du deinen Account löschst (alle deine Daten werden sofort und vollständig gelöscht), oder</li>
|
||||
<li>du eine Löschung per E-Mail anforderst, oder</li>
|
||||
<li>das Dashboard eingestellt wird (Nutzer werden vorab informiert soweit möglich)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Deine Rechte (DSGVO Art. 15–21)</h2>
|
||||
<ul>
|
||||
<li><strong>Auskunft (Art. 15):</strong> Du kannst jederzeit Auskunft über deine gespeicherten Daten anfordern.</li>
|
||||
<li><strong>Berichtigung (Art. 16):</strong> Du kannst alle Einträge direkt im Dashboard bearbeiten oder löschen.</li>
|
||||
<li><strong>Löschung (Art. 17):</strong> Du kannst deinen Account in den Einstellungen vollständig löschen. Alle deine Daten werden dabei sofort gelöscht.</li>
|
||||
<li><strong>Einschränkung (Art. 18):</strong> Auf Anfrage per E-Mail möglich.</li>
|
||||
<li><strong>Widerspruch (Art. 21):</strong> Da die Verarbeitung auf Einwilligung basiert, entfällt sie mit Widerruf der Einwilligung (= Account-Löschung).</li>
|
||||
<li><strong>Beschwerde:</strong> Du hast das Recht, bei der zuständigen Datenschutzaufsichtsbehörde Beschwerde einzulegen (Bayern: Bayerisches Landesamt für Datenschutzaufsicht, <a href="https://www.lda.bayern.de" target="_blank" rel="noopener">lda.bayern.de</a>).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Drittanbieter-Dienste</h2>
|
||||
<table>
|
||||
<thead><tr><th>Dienst</th><th>Zweck</th><th>Datenübertragung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Cloudflare</strong></td>
|
||||
<td>CDN, DDoS-Schutz, HTTPS-Zertifikat</td>
|
||||
<td>IP-Adressen werden von Cloudflare temporär verarbeitet (EU-Server möglich). Cloudflare speichert keine Inhalte dauerhaft. <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener">Datenschutzerklärung</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Open-Meteo API</strong></td>
|
||||
<td>Wetterdaten für Rosenheim</td>
|
||||
<td>Anfragen gehen direkt vom Browser an api.open-meteo.com. Es werden nur Koordinaten übertragen, keine personenbezogenen Daten. <a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener">Nutzungsbedingungen</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Google Fonts</strong></td>
|
||||
<td>Schriftart Inter</td>
|
||||
<td>Beim ersten Laden wird die Schriftart von Google-Servern geladen. Dabei wird deine IP-Adresse an Google übertragen. <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">Datenschutzerklärung</a>. Falls unerwünscht: Seite funktioniert auch mit System-Schriftart.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Sicherheit</h2>
|
||||
<ul>
|
||||
<li>Passwörter werden ausschließlich als bcrypt-Hash (Cost-Factor 12) gespeichert – niemals im Klartext</li>
|
||||
<li>Alle Verbindungen laufen über HTTPS (TLS via Cloudflare)</li>
|
||||
<li>Anmeldeversuch-Begrenzung: max. 10 Versuche pro 15 Minuten pro IP-Adresse</li>
|
||||
<li>Session-Cookies sind als HttpOnly gesetzt (kein JavaScript-Zugriff)</li>
|
||||
<li>Jeder Nutzer sieht nur seine eigenen Daten (serverseitige Filterung)</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2563eb"/>
|
||||
<polygon points="16,7 29,13 16,19 3,13" fill="white"/>
|
||||
<path d="M9.5,15.5 L9.5,22 Q16,26 22.5,22 L22.5,15.5" fill="white"/>
|
||||
<line x1="29" y1="13" x2="29" y2="20.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="29" cy="22" r="1.8" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 399 B |
+1967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IFB · INFO1 – 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">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f4f6f9;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.06), 0 1px 4px rgba(0,0,0,.04);
|
||||
padding: 36px 32px 32px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Brand */
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 36px; height: 36px;
|
||||
background: #2563eb;
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; font-weight: 800; color: #fff;
|
||||
letter-spacing: -.5px; flex-shrink: 0;
|
||||
}
|
||||
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.brand-sub { font-size: 10px; color: #9ca3af; font-weight: 500; letter-spacing: .2px; }
|
||||
.brand-name { font-size: 16px; font-weight: 700; color: #111827; letter-spacing: -.3px; }
|
||||
|
||||
.back-link {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
transition: color .12s;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.back-link:hover { color: #2563eb; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
margin-bottom: 24px;
|
||||
gap: 2px;
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 7px;
|
||||
text-align: center;
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: #6b7280;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background .12s, color .12s;
|
||||
user-select: none;
|
||||
}
|
||||
.tab.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form { display: none; flex-direction: column; gap: 14px; }
|
||||
.form.active { display: flex; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.field label { font-size: 12px; font-weight: 600; color: #374151; letter-spacing: .1px; }
|
||||
.field input {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 7px;
|
||||
font-size: 14px; font-family: inherit;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.field input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
|
||||
/* Role toggle */
|
||||
.role-toggle {
|
||||
display: flex;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.role-opt {
|
||||
flex: 1; padding: 7px 10px;
|
||||
text-align: center;
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: #6b7280;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background .12s, color .12s;
|
||||
user-select: none;
|
||||
}
|
||||
.role-opt.active { background: #fff; color: #111827; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.role-opt.active.teacher { background: #fefce8; color: #854d0e; }
|
||||
|
||||
/* Notices */
|
||||
.notice {
|
||||
font-size: 12px;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
.notice.show { display: block; }
|
||||
.notice-amber { color: #92400e; background: #fffbeb; border: 1px solid #fde68a; }
|
||||
.notice-red { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; }
|
||||
.notice-green { color: #15803d; background: #f0fdf4; border: 1px solid #bbf7d0; }
|
||||
|
||||
/* Password strength */
|
||||
.strength-wrap { margin-top: 4px; }
|
||||
.strength-bar { height: 3px; border-radius: 99px; background: #e5e7eb; overflow: hidden; }
|
||||
.strength-fill { height: 100%; border-radius: 99px; transition: width .25s, background .25s; width: 0; }
|
||||
.strength-label { font-size: 11px; color: #9ca3af; margin-top: 3px; }
|
||||
|
||||
/* Submit button */
|
||||
.btn-submit {
|
||||
padding: 10px;
|
||||
background: #2563eb; color: #fff;
|
||||
border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background .12s, transform .08s;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.btn-submit:hover { background: #1d4ed8; }
|
||||
.btn-submit:active { transform: scale(.99); }
|
||||
.btn-submit:disabled { background: #93c5fd; cursor: default; }
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 18px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
footer a { color: #6b7280; text-decoration: none; transition: color .12s; }
|
||||
footer a:hover { color: #2563eb; }
|
||||
|
||||
/* Icons */
|
||||
.lucide { display: inline-block; vertical-align: -0.125em; flex-shrink: 0; width: 1em; height: 1em; stroke-width: 2; }
|
||||
.notice .lucide { width: 14px; height: 14px; margin-right: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/">
|
||||
<div class="brand-mark">i1</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-sub">IFB-Berufsfachschule Rosenheim</span>
|
||||
<span class="brand-name">INFO1</span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="back-link" href="/">← Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" id="tab-login" onclick="switchTab('login')">Anmelden</div>
|
||||
<div class="tab" id="tab-reg" onclick="switchTab('register')">Registrieren</div>
|
||||
</div>
|
||||
|
||||
<form class="form active" id="form-login" onsubmit="doLogin(event)">
|
||||
<div class="field">
|
||||
<label for="l-user">Benutzername</label>
|
||||
<input type="text" id="l-user" autocomplete="username" placeholder="dein.name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="l-pass">Passwort</label>
|
||||
<input type="password" id="l-pass" autocomplete="current-password" placeholder="••••••" required>
|
||||
</div>
|
||||
<div class="notice notice-red" id="login-err"></div>
|
||||
<button class="btn-submit" type="submit">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<form class="form" id="form-reg" onsubmit="doRegister(event)">
|
||||
<div class="field">
|
||||
<label>Ich bin</label>
|
||||
<div class="role-toggle">
|
||||
<div class="role-opt active" id="role-student" onclick="selectRole('student')">Schüler/in</div>
|
||||
<div class="role-opt" id="role-teacher" onclick="selectRole('teacher')">Lehrer/in</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice notice-amber" id="teacher-notice">
|
||||
<i data-lucide="info" aria-hidden="true"></i> Lehrerkonten werden nach der Registrierung von einem Administrator geprüft und freigeschaltet.
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="r-email">Schul E-Mail</label>
|
||||
<input type="email" id="r-email" autocomplete="email" placeholder="vorname.nachname@ifb-schulen.com" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="r-pass">Passwort</label>
|
||||
<input type="password" id="r-pass" autocomplete="new-password" placeholder="Mindestens 6 Zeichen" required oninput="checkStrength(this.value)">
|
||||
<div class="strength-wrap">
|
||||
<div class="strength-bar"><div class="strength-fill" id="strength-fill"></div></div>
|
||||
<div class="strength-label" id="strength-label">Passwort eingeben</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice notice-red" id="reg-err"></div>
|
||||
<div class="notice notice-green" id="reg-ok">Registrierung erfolgreich! Dein Lehrerkonto wird von einem Administrator geprüft und freigeschaltet.</div>
|
||||
<button class="btn-submit" type="submit" id="reg-btn">Account erstellen</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="/datenschutz">Datenschutzerklärung</a>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
let selectedRole = 'student';
|
||||
|
||||
function selectRole(r) {
|
||||
selectedRole = r;
|
||||
document.getElementById('role-student').classList.toggle('active', r === 'student');
|
||||
document.getElementById('role-teacher').classList.toggle('active', r === 'teacher');
|
||||
document.getElementById('role-teacher').classList.toggle('teacher', r === 'teacher');
|
||||
document.getElementById('teacher-notice').classList.toggle('show', r === 'teacher');
|
||||
document.getElementById('reg-btn').textContent = r === 'teacher' ? 'Als Lehrer/in registrieren' : 'Account erstellen';
|
||||
}
|
||||
|
||||
function switchTab(t) {
|
||||
document.getElementById('tab-login').classList.toggle('active', t==='login');
|
||||
document.getElementById('tab-reg').classList.toggle('active', t==='register');
|
||||
document.getElementById('form-login').classList.toggle('active', t==='login');
|
||||
document.getElementById('form-reg').classList.toggle('active', t==='register');
|
||||
}
|
||||
|
||||
function showErr(id, msg) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = msg; el.classList.add('show');
|
||||
}
|
||||
function clearErr(id) { document.getElementById(id).classList.remove('show'); }
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault(); clearErr('login-err');
|
||||
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value }) });
|
||||
const d = await r.json();
|
||||
if (!r.ok) { showErr('login-err', d.error); return; }
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
async function doRegister(e) {
|
||||
e.preventDefault(); clearErr('reg-err');
|
||||
const r = await fetch('/api/register', { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
email: document.getElementById('r-email').value,
|
||||
password: document.getElementById('r-pass').value,
|
||||
role: selectedRole
|
||||
}) });
|
||||
const d = await r.json();
|
||||
if (!r.ok) { showErr('reg-err', d.error); return; }
|
||||
if (d.pending) {
|
||||
document.getElementById('reg-ok').classList.add('show');
|
||||
document.getElementById('reg-btn').disabled = true;
|
||||
document.getElementById('reg-btn').textContent = 'Registriert';
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
function checkStrength(pw) {
|
||||
let score = 0;
|
||||
if (pw.length >= 6) score++;
|
||||
if (pw.length >= 10) score++;
|
||||
if (/[A-Z]/.test(pw)) score++;
|
||||
if (/[0-9]/.test(pw)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
||||
const levels = [
|
||||
{ w: '0%', bg: '#e5e7eb', label: 'Passwort eingeben' },
|
||||
{ w: '25%', bg: '#ef4444', label: 'Sehr schwach' },
|
||||
{ w: '50%', bg: '#f97316', label: 'Schwach' },
|
||||
{ w: '75%', bg: '#eab308', label: 'Mittel' },
|
||||
{ w: '90%', bg: '#22c55e', label: 'Stark' },
|
||||
{ w: '100%', bg: '#16a34a', label: 'Sehr stark' },
|
||||
];
|
||||
const l = levels[Math.min(score, 5)];
|
||||
const fill = document.getElementById('strength-fill');
|
||||
fill.style.width = l.w; fill.style.background = l.bg;
|
||||
document.getElementById('strength-label').textContent = l.label;
|
||||
}
|
||||
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (p.get('tab') === 'register') switchTab('register');
|
||||
|
||||
fetch('/api/me').then(r => { if (r.ok) window.location.href = '/'; });
|
||||
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const SECRET = process.env.JWT_SECRET || 'info1-ifb-secret-change-in-prod';
|
||||
|
||||
function signToken(user) {
|
||||
return jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '30d' });
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, SECRET);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const token = req.cookies?.token;
|
||||
if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||
const user = verifyToken(token);
|
||||
if (!user) return res.status(401).json({ error: 'Ungültige Sitzung' });
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { signToken, verifyToken, requireAuth };
|
||||
@@ -0,0 +1,146 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const db = new Database(path.join(__dirname, '../data.db'));
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS timetable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
time_start TEXT,
|
||||
time_end TEXT,
|
||||
subject TEXT,
|
||||
room TEXT,
|
||||
teacher TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS homework (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subject TEXT,
|
||||
title TEXT NOT NULL,
|
||||
due_date TEXT,
|
||||
done INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
grade REAL,
|
||||
type TEXT DEFAULT 'sonstiges',
|
||||
note TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS absences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT,
|
||||
subject TEXT,
|
||||
reason TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
done INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS countdowns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
target_date TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quicklinks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS class_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT,
|
||||
date_end TEXT,
|
||||
description TEXT,
|
||||
created_by INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
sender_id INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (ticket_id) REFERENCES support_tickets(id),
|
||||
FOREIGN KEY (sender_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_id INTEGER,
|
||||
details TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
class_id TEXT NOT NULL DEFAULT 'info1',
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_name TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Safe migrations
|
||||
try { db.exec(`ALTER TABLE grades ADD COLUMN type TEXT DEFAULT 'sonstiges'`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'student'`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'`); } catch {}
|
||||
|
||||
module.exports = db;
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const db = require('./db');
|
||||
const { requireAuth } = require('./auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const STORAGE_DIR = path.resolve(__dirname, '../storage');
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
|
||||
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_USER_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
const ALLOWED_EXT = new Set([
|
||||
'pdf','doc','docx','xls','xlsx','ppt','pptx','odt','ods','odp','rtf','txt','md',
|
||||
'jpg','jpeg','png','gif','webp','bmp','tiff','svg',
|
||||
'zip','rar','7z','tar','gz',
|
||||
'csv','json','xml',
|
||||
]);
|
||||
|
||||
const EXT_MIME = {
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
odt: 'application/vnd.oasis.opendocument.text',
|
||||
ods: 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
odp: 'application/vnd.oasis.opendocument.presentation',
|
||||
rtf: 'application/rtf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/plain',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
bmp: 'image/bmp',
|
||||
tiff: 'image/tiff',
|
||||
svg: 'image/svg+xml',
|
||||
zip: 'application/zip',
|
||||
rar: 'application/vnd.rar',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
tar: 'application/x-tar',
|
||||
gz: 'application/gzip',
|
||||
csv: 'text/csv',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml',
|
||||
};
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.oasis.opendocument.text',
|
||||
'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'application/vnd.oasis.opendocument.presentation',
|
||||
'application/rtf', 'text/rtf',
|
||||
'text/plain', 'text/markdown',
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'image/bmp', 'image/tiff', 'image/svg+xml',
|
||||
'application/zip', 'application/x-zip-compressed',
|
||||
'application/vnd.rar', 'application/x-rar-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-tar', 'application/gzip', 'application/x-gzip',
|
||||
'text/csv',
|
||||
'application/json', 'text/json',
|
||||
'application/xml', 'text/xml',
|
||||
'application/octet-stream',
|
||||
]);
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, STORAGE_DIR),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase().slice(1);
|
||||
cb(null, crypto.randomUUID() + (ext ? '.' + ext : ''));
|
||||
},
|
||||
});
|
||||
|
||||
function fileFilter(_req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase().slice(1);
|
||||
if (!ext || !ALLOWED_EXT.has(ext)) {
|
||||
return cb(Object.assign(new Error('INVALID_EXT'), { code: 'INVALID_EXT' }));
|
||||
}
|
||||
// Empty MIME happens on some Linux/OS configs — extension check is the primary gate
|
||||
if (file.mimetype && !ALLOWED_MIME.has(file.mimetype)) {
|
||||
return cb(Object.assign(new Error('INVALID_MIME'), { code: 'INVALID_MIME' }));
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
|
||||
const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES }, fileFilter });
|
||||
|
||||
function getUserStorageUsed(userId) {
|
||||
return db.prepare('SELECT COALESCE(SUM(size), 0) AS used FROM user_files WHERE user_id = ?').get(userId).used;
|
||||
}
|
||||
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
const files = db.prepare(
|
||||
'SELECT id, original_name, mime_type, size, created_at FROM user_files WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(req.user.id);
|
||||
const used = getUserStorageUsed(req.user.id);
|
||||
res.json({ files, used, quota: MAX_USER_BYTES });
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, (req, res) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'Datei zu groß (max. 50 MB)' });
|
||||
if (err.code === 'INVALID_EXT') return res.status(400).json({ error: 'Dateityp nicht erlaubt' });
|
||||
if (err.code === 'INVALID_MIME') return res.status(400).json({ error: 'MIME-Typ nicht erlaubt' });
|
||||
return res.status(400).json({ error: 'Upload fehlgeschlagen' });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: 'Keine Datei angegeben' });
|
||||
|
||||
const used = getUserStorageUsed(req.user.id);
|
||||
if (used + req.file.size > MAX_USER_BYTES) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
return res.status(413).json({ error: 'Speicherplatz voll (max. 2 GB pro Nutzer)' });
|
||||
}
|
||||
|
||||
const ext = path.extname(req.file.originalname).toLowerCase().slice(1);
|
||||
const mime = EXT_MIME[ext] || 'application/octet-stream';
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO user_files (user_id, original_name, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(req.user.id, req.file.originalname, req.file.filename, mime, req.file.size);
|
||||
|
||||
res.json({
|
||||
id: result.lastInsertRowid,
|
||||
original_name: req.file.originalname,
|
||||
mime_type: mime,
|
||||
size: req.file.size,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:id/download', requireAuth, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
||||
const file = db.prepare('SELECT * FROM user_files WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
const filePath = path.join(STORAGE_DIR, file.stored_name);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
res.setHeader('Content-Type', file.mime_type);
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.original_name)}`);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'private, no-cache');
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAuth, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
|
||||
const file = db.prepare('SELECT * FROM user_files WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
fs.unlink(path.join(STORAGE_DIR, file.stored_name), () => {});
|
||||
db.prepare('DELETE FROM user_files WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
function deleteUserFiles(userId) {
|
||||
const files = db.prepare('SELECT stored_name FROM user_files WHERE user_id = ?').all(userId);
|
||||
files.forEach(f => fs.unlink(path.join(STORAGE_DIR, f.stored_name), () => {}));
|
||||
db.prepare('DELETE FROM user_files WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
module.exports = { router, deleteUserFiles };
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const db = require('./db');
|
||||
const { signToken, requireAuth } = require('./auth');
|
||||
const { deleteUserFiles } = require('./files');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
message: { error: 'Zu viele Anmeldeversuche. Bitte 15 Minuten warten.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// --- AUTH ---
|
||||
const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i;
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
const { email, password, role } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Alle Felder erforderlich' });
|
||||
if (!IFB_EMAIL_RE.test(email)) return res.status(403).json({ error: 'Ungültige E-Mail-Adresse' });
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Passwort zu kurz (min. 6 Zeichen)' });
|
||||
const username = email.split('@')[0].toLowerCase();
|
||||
const safeRole = (role === 'teacher') ? 'teacher' : 'student';
|
||||
const initialStatus = safeRole === 'teacher' ? 'pending' : 'active';
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
try {
|
||||
const result = db.prepare('INSERT INTO users (username, email, password_hash, role, status) VALUES (?, ?, ?, ?, ?)').run(username, email.toLowerCase(), hash, safeRole, initialStatus);
|
||||
if (safeRole === 'teacher') {
|
||||
return res.json({ ok: true, pending: true });
|
||||
}
|
||||
const user = { id: result.lastInsertRowid, username, role: safeRole };
|
||||
const token = signToken(user);
|
||||
res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' });
|
||||
res.json({ ok: true, pending: false });
|
||||
} catch (e) {
|
||||
if (e.message.includes('UNIQUE')) {
|
||||
if (e.message.includes('email')) return res.status(409).json({ error: 'Diese E-Mail-Adresse ist bereits registriert' });
|
||||
return res.status(409).json({ error: 'Benutzername bereits vergeben' });
|
||||
}
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', loginLimiter, (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' });
|
||||
}
|
||||
if (user.status === 'pending') {
|
||||
return res.status(403).json({ error: 'Dein Konto wartet noch auf Freischaltung durch einen Administrator.' });
|
||||
}
|
||||
if (user.status === 'banned') {
|
||||
return res.status(403).json({ error: 'Dein Konto wurde gesperrt. Bitte wende dich an einen Administrator.' });
|
||||
}
|
||||
if (user.status === 'rejected') {
|
||||
return res.status(403).json({ error: 'Deine Registrierung als Lehrer/in wurde abgelehnt.' });
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
return res.status(403).json({ error: 'Dein Konto ist nicht aktiv.' });
|
||||
}
|
||||
const token = signToken(user);
|
||||
res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' });
|
||||
res.json({ ok: true, username: user.username, role: user.role });
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
res.clearCookie('token');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = db.prepare('SELECT id, username, email, role, status FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' });
|
||||
res.json({ username: user.username, id: user.id, role: user.role, email: user.email });
|
||||
});
|
||||
|
||||
router.put('/me/password', requireAuth, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Felder erforderlich' });
|
||||
if (newPassword.length < 6) return res.status(400).json({ error: 'Neues Passwort zu kurz (min. 6 Zeichen)' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Aktuelles Passwort falsch' });
|
||||
}
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/me', requireAuth, (req, res) => {
|
||||
const { password } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Passwort falsch' });
|
||||
}
|
||||
const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','chat_messages'];
|
||||
tables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(req.user.id));
|
||||
deleteUserFiles(req.user.id);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.user.id);
|
||||
res.clearCookie('token');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- ADMIN ---
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Keine Administratorrechte' });
|
||||
next();
|
||||
}
|
||||
|
||||
function logAdmin(adminId, action, targetId = null, details = null) {
|
||||
db.prepare('INSERT INTO admin_logs (admin_id, action, target_id, details) VALUES (?, ?, ?, ?)')
|
||||
.run(adminId, action, targetId, details ? JSON.stringify(details) : null);
|
||||
}
|
||||
|
||||
router.get('/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||
const { role, status } = req.query;
|
||||
let sql = 'SELECT id, username, email, role, status, created_at FROM users';
|
||||
const params = [];
|
||||
const conditions = [];
|
||||
if (role) { conditions.push('role = ?'); params.push(role); }
|
||||
if (status) { conditions.push('status = ?'); params.push(status); }
|
||||
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
|
||||
sql += ' ORDER BY created_at DESC';
|
||||
res.json(db.prepare(sql).all(...params));
|
||||
});
|
||||
|
||||
router.post('/admin/teachers/:id/approve', requireAuth, requireAdmin, (req, res) => {
|
||||
const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.role !== 'teacher') return res.status(400).json({ error: 'Kein Lehrerkonto' });
|
||||
db.prepare('UPDATE users SET status = ? WHERE id = ?').run('active', req.params.id);
|
||||
logAdmin(req.user.id, 'teacher_approve', Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/admin/teachers/:id/reject', requireAuth, requireAdmin, (req, res) => {
|
||||
const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.role !== 'teacher') return res.status(400).json({ error: 'Kein Lehrerkonto' });
|
||||
db.prepare('UPDATE users SET status = ? WHERE id = ?').run('rejected', req.params.id);
|
||||
logAdmin(req.user.id, 'teacher_reject', Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.patch('/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
const { role, status } = req.body;
|
||||
const allowed_roles = ['student', 'teacher', 'admin'];
|
||||
const allowed_status = ['active', 'pending', 'banned', 'rejected'];
|
||||
if (role && !allowed_roles.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
if (status && !allowed_status.includes(status)) return res.status(400).json({ error: 'Ungültiger Status' });
|
||||
if (!role && !status) return res.status(400).json({ error: 'Keine Änderung angegeben' });
|
||||
|
||||
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
|
||||
if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
|
||||
if (status) db.prepare('UPDATE users SET status = ? WHERE id = ?').run(status, req.params.id);
|
||||
logAdmin(req.user.id, 'user_update', Number(req.params.id), { role, status });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (Number(req.params.id) === req.user.id) return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' });
|
||||
const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','chat_messages'];
|
||||
tables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(req.params.id));
|
||||
deleteUserFiles(Number(req.params.id));
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||
logAdmin(req.user.id, 'user_delete', Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/admin/tickets', requireAuth, requireAdmin, (req, res) => {
|
||||
const tickets = db.prepare(`
|
||||
SELECT t.id, t.user_id, t.subject, t.message, t.status, t.created_at,
|
||||
u.username, u.email
|
||||
FROM support_tickets t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
ORDER BY t.created_at DESC
|
||||
`).all();
|
||||
res.json(tickets);
|
||||
});
|
||||
|
||||
router.patch('/admin/tickets/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
const allowed = ['open', 'in_progress', 'closed'];
|
||||
const { status } = req.body;
|
||||
if (!status || !allowed.includes(status)) return res.status(400).json({ error: 'Ungültiger Status' });
|
||||
const ticket = db.prepare('SELECT id FROM support_tickets WHERE id = ?').get(req.params.id);
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' });
|
||||
db.prepare('UPDATE support_tickets SET status = ? WHERE id = ?').run(status, req.params.id);
|
||||
logAdmin(req.user.id, 'ticket_update', Number(req.params.id), { status });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/admin/logs', requireAuth, requireAdmin, (req, res) => {
|
||||
const logs = db.prepare(`
|
||||
SELECT l.id, l.action, l.target_id, l.details, l.created_at,
|
||||
u.username AS admin_username
|
||||
FROM admin_logs l
|
||||
JOIN users u ON u.id = l.admin_id
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 500
|
||||
`).all();
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
router.get('/admin/usage', requireAuth, requireAdmin, (req, res) => {
|
||||
const users = db.prepare('SELECT id, username, email, role, status FROM users ORDER BY username').all();
|
||||
const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','support_tickets'];
|
||||
const usage = users.map(u => {
|
||||
const counts = {};
|
||||
tables.forEach(t => {
|
||||
const col = t === 'support_tickets' ? 'user_id' : 'user_id';
|
||||
counts[t] = db.prepare(`SELECT COUNT(*) AS c FROM ${t} WHERE user_id = ?`).get(u.id).c;
|
||||
});
|
||||
return { ...u, counts };
|
||||
});
|
||||
res.json(usage);
|
||||
});
|
||||
|
||||
// --- SUPPORT TICKETS (user-facing) ---
|
||||
router.get('/tickets', requireAuth, (req, res) => {
|
||||
res.json(db.prepare('SELECT id, subject, message, status, created_at FROM support_tickets WHERE user_id = ? ORDER BY created_at DESC').all(req.user.id));
|
||||
});
|
||||
|
||||
router.post('/tickets', requireAuth, (req, res) => {
|
||||
const { subject, message } = req.body;
|
||||
if (!subject || !message) return res.status(400).json({ error: 'Betreff und Nachricht erforderlich' });
|
||||
if (typeof subject !== 'string' || typeof message !== 'string') return res.status(400).json({ error: 'Ungültige Eingabe' });
|
||||
const subjectT = subject.trim(), messageT = message.trim();
|
||||
if (!subjectT || !messageT) return res.status(400).json({ error: 'Felder dürfen nicht leer sein' });
|
||||
if (subjectT.length > 200) return res.status(400).json({ error: 'Betreff zu lang (max. 200 Zeichen)' });
|
||||
if (messageT.length > 5000) return res.status(400).json({ error: 'Nachricht zu lang (max. 5000 Zeichen)' });
|
||||
const result = db.prepare('INSERT INTO support_tickets (user_id, subject, message) VALUES (?, ?, ?)').run(req.user.id, subjectT, messageT);
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
const ticketMsgLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) => String(req.user.id),
|
||||
message: { error: 'Zu viele Nachrichten. Bitte warten.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
validate: { keyGeneratorIpFallback: false },
|
||||
});
|
||||
|
||||
router.get('/tickets/:id/messages', requireAuth, (req, res) => {
|
||||
const ticket = db.prepare('SELECT id, user_id FROM support_tickets WHERE id = ?').get(req.params.id);
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' });
|
||||
if (req.user.role !== 'admin' && ticket.user_id !== req.user.id) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const messages = db.prepare(`
|
||||
SELECT m.id, m.message, m.created_at, u.username, u.role
|
||||
FROM ticket_messages m
|
||||
JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.ticket_id = ?
|
||||
ORDER BY m.created_at ASC
|
||||
`).all(req.params.id);
|
||||
res.json(messages);
|
||||
});
|
||||
|
||||
router.post('/tickets/:id/messages', requireAuth, ticketMsgLimiter, (req, res) => {
|
||||
const { message } = req.body;
|
||||
if (!message || typeof message !== 'string') return res.status(400).json({ error: 'Nachricht erforderlich' });
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return res.status(400).json({ error: 'Nachricht darf nicht leer sein' });
|
||||
if (trimmed.length > 5000) return res.status(400).json({ error: 'Nachricht zu lang (max. 5000 Zeichen)' });
|
||||
const ticket = db.prepare('SELECT id, user_id, status FROM support_tickets WHERE id = ?').get(req.params.id);
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' });
|
||||
if (req.user.role !== 'admin' && ticket.user_id !== req.user.id) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
if (ticket.status === 'closed') return res.status(400).json({ error: 'Ticket ist geschlossen' });
|
||||
db.prepare('INSERT INTO ticket_messages (ticket_id, sender_id, message) VALUES (?, ?, ?)').run(ticket.id, req.user.id, trimmed);
|
||||
if (req.user.role === 'admin' && ticket.status === 'open') {
|
||||
db.prepare("UPDATE support_tickets SET status = 'in_progress' WHERE id = ?").run(ticket.id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- CLASS EVENTS (shared, no user filter for GET) ---
|
||||
router.get('/class-events', (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM class_events ORDER BY date ASC').all());
|
||||
});
|
||||
|
||||
router.post('/class-events', requireAuth, (req, res) => {
|
||||
const { type, title, date, date_end, description } = req.body;
|
||||
if (!type || !title) return res.status(400).json({ error: 'Typ und Titel erforderlich' });
|
||||
const result = db.prepare('INSERT INTO class_events (type, title, date, date_end, description, created_by) VALUES (?,?,?,?,?,?)')
|
||||
.run(type, title, date||null, date_end||null, description||null, req.user.id);
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
router.delete('/class-events/:id', requireAuth, (req, res) => {
|
||||
db.prepare('DELETE FROM class_events WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- CHAT ---
|
||||
const chatLimiter = rateLimit({
|
||||
windowMs: 30 * 1000,
|
||||
max: 5,
|
||||
keyGenerator: (req) => String(req.user.id),
|
||||
message: { error: 'Zu viele Nachrichten. Bitte 30 Sekunden warten.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
validate: { keyGeneratorIpFallback: false },
|
||||
});
|
||||
|
||||
const CLASS_ID = 'info1';
|
||||
const CHAT_MAX_LEN = 500;
|
||||
|
||||
router.get('/chat', requireAuth, (req, res) => {
|
||||
const after = parseInt(req.query.after, 10) || 0;
|
||||
const msgs = after
|
||||
? db.prepare(`
|
||||
SELECT m.id, m.content, m.created_at, u.username
|
||||
FROM chat_messages m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.class_id = ? AND m.id > ?
|
||||
ORDER BY m.id ASC
|
||||
`).all(CLASS_ID, after)
|
||||
: db.prepare(`
|
||||
SELECT m.id, m.content, m.created_at, u.username
|
||||
FROM chat_messages m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.class_id = ?
|
||||
ORDER BY m.id DESC
|
||||
LIMIT 50
|
||||
`).all(CLASS_ID).reverse();
|
||||
res.json(msgs);
|
||||
});
|
||||
|
||||
router.post('/chat', requireAuth, chatLimiter, (req, res) => {
|
||||
const { content } = req.body;
|
||||
if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Nachricht erforderlich' });
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return res.status(400).json({ error: 'Nachricht darf nicht leer sein' });
|
||||
if (trimmed.length > CHAT_MAX_LEN) return res.status(400).json({ error: `Nachricht zu lang (max. ${CHAT_MAX_LEN} Zeichen)` });
|
||||
const result = db.prepare('INSERT INTO chat_messages (user_id, class_id, content) VALUES (?, ?, ?)').run(req.user.id, CLASS_ID, trimmed);
|
||||
const msg = db.prepare(`
|
||||
SELECT m.id, m.content, m.created_at, u.username
|
||||
FROM chat_messages m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
res.json(msg);
|
||||
});
|
||||
|
||||
router.delete('/chat/:id', requireAuth, (req, res) => {
|
||||
const msg = db.prepare('SELECT id, user_id FROM chat_messages WHERE id = ?').get(req.params.id);
|
||||
if (!msg) return res.status(404).json({ error: 'Nachricht nicht gefunden' });
|
||||
if (msg.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
db.prepare('DELETE FROM chat_messages WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- PERSONAL CRUD ---
|
||||
function crudRoutes(path, table, fields) {
|
||||
router.get(`/${path}`, requireAuth, (req, res) => {
|
||||
res.json(db.prepare(`SELECT * FROM ${table} WHERE user_id = ?`).all(req.user.id));
|
||||
});
|
||||
router.post(`/${path}`, requireAuth, (req, res) => {
|
||||
const vals = fields.map(f => req.body[f] ?? null);
|
||||
const cols = ['user_id', ...fields].join(', ');
|
||||
const placeholders = ['?', ...fields.map(() => '?')].join(', ');
|
||||
const result = db.prepare(`INSERT INTO ${table} (${cols}) VALUES (${placeholders})`).run(req.user.id, ...vals);
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
router.put(`/${path}/:id`, requireAuth, (req, res) => {
|
||||
const sets = fields.map(f => `${f} = ?`).join(', ');
|
||||
const vals = fields.map(f => req.body[f] ?? null);
|
||||
db.prepare(`UPDATE ${table} SET ${sets} WHERE id = ? AND user_id = ?`).run(...vals, req.params.id, req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
router.delete(`/${path}/:id`, requireAuth, (req, res) => {
|
||||
db.prepare(`DELETE FROM ${table} WHERE id = ? AND user_id = ?`).run(req.params.id, req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
crudRoutes('timetable', 'timetable', ['day', 'time_start', 'time_end', 'subject', 'room', 'teacher']);
|
||||
crudRoutes('homework', 'homework', ['subject', 'title', 'due_date', 'done']);
|
||||
crudRoutes('grades', 'grades', ['subject', 'grade', 'type', 'note']);
|
||||
crudRoutes('absences', 'absences', ['date', 'subject', 'reason']);
|
||||
crudRoutes('todos', 'todos', ['title', 'done']);
|
||||
crudRoutes('countdowns', 'countdowns', ['title', 'target_date']);
|
||||
crudRoutes('quicklinks', 'quicklinks', ['label', 'url']);
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user