fix: harden auth, admin, teacher, and e2ee endpoints

- Invalidate JWTs on password change/reset via token_version
- Constant-time login compare against dummy hash to prevent user enum
- Register validates subject against subjects table + user_subjects link
- Last-admin guard on account delete and admin role/status PATCH
- purgeUser unlinks teacher_materials storage files
- 2FA setup/regenerate require password, setup blocks while enabled
- Group sender keys: existing-distributor check + INSERT OR IGNORE
- class_events: type whitelist, ISO date regex, end >= start check
- Teacher absences DELETE: ownership check (teacher_id)
- class_timetable POST: HHMM validation, overlap detection
- class_timetable PUT: subject restricted to teacher list, HHMM + overlap
- Register VALID_SUBJECTS removed; dynamic subjects from DB
- /api/subjects made public (needed by register form)
This commit is contained in:
Simon
2026-04-21 13:18:17 +02:00
parent 38d69c65a5
commit 0dd915eeb2
6 changed files with 988 additions and 103 deletions
+485 -56
View File
@@ -1064,7 +1064,7 @@ footer {
<button class="sb-nav-item" id="sb-nav-files" style="display:none" onclick="openOverlay('card-files');closeSidebar()"> <button class="sb-nav-item" id="sb-nav-files" style="display:none" onclick="openOverlay('card-files');closeSidebar()">
<i data-lucide="folder"></i><span>Dateispeicher</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i> <i data-lucide="folder"></i><span>Dateispeicher</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button> </button>
<button class="sb-nav-item" id="sb-nav-ab" style="display:none" onclick="openOverlay('card-ab');closeSidebar()"> <button class="sb-nav-item" id="sb-nav-ab" style="display:none" onclick="openOverlay('card-ab');loadAbsencesCard();closeSidebar()">
<i data-lucide="user-x"></i><span>Fehlzeiten</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i> <i data-lucide="user-x"></i><span>Fehlzeiten</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button> </button>
@@ -1124,11 +1124,24 @@ footer {
<div class="card-head"> <div class="card-head">
<div class="card-title"><i data-lucide="user-x" aria-hidden="true"></i> Fehlzeiten</div> <div class="card-title"><i data-lucide="user-x" aria-hidden="true"></i> Fehlzeiten</div>
<div class="card-actions"> <div class="card-actions">
<button class="add-btn" onclick="openModal('absences')">+ Eintragen</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button> <button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div> </div>
</div> </div>
<div class="card-body" id="list-ab"></div> <div class="card-body">
<!-- Teacher add form (shown for teachers only) -->
<div id="ab-teacher-form" style="display:none;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<select id="ab-student-card" class="t-input" style="flex:2;min-width:140px">
<option value="">Schüler auswählen…</option>
</select>
<input type="date" id="ab-date-card" class="t-input" style="flex:1;min-width:140px">
<input type="text" id="ab-subject-card" class="t-input" placeholder="Fach / Stunde" maxlength="200" style="flex:1;min-width:120px" list="subjects-datalist">
<input type="text" id="ab-reason-card" class="t-input" placeholder="Grund (optional)" maxlength="500" style="flex:2;min-width:140px">
<button class="add-btn" onclick="addAbsenceFromCard()">+ Fehlzeit</button>
</div>
</div>
<div id="list-ab"></div>
</div>
</div> </div>
<div class="card ov-card" id="card-chat" style="display:none"> <div class="card ov-card" id="card-chat" style="display:none">
@@ -1190,16 +1203,35 @@ footer {
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="t-tabs"> <div class="t-tabs" style="flex-wrap:wrap">
<div class="t-tab active" onclick="teacherTab('materials')">Materialien</div> <div class="t-tab active" onclick="teacherTab('faecher')">Fächer</div>
<div class="t-tab" onclick="teacherTab('materials')">Materialien</div>
<div class="t-tab" onclick="teacherTab('announcements')">Ankündigungen</div> <div class="t-tab" onclick="teacherTab('announcements')">Ankündigungen</div>
<div class="t-tab" onclick="teacherTab('exams')">Prüfungen</div> <div class="t-tab" onclick="teacherTab('exams')">Prüfungen</div>
<div class="t-tab" onclick="teacherTab('grades')">Noten</div> <div class="t-tab" onclick="teacherTab('grades')">Noten</div>
<div class="t-tab" onclick="teacherTab('timetable')">Stundenplan</div>
<div class="t-tab" onclick="teacherTab('absences')">Fehlzeiten</div>
</div> </div>
<div id="t-pane-materials" class="t-pane active"> <div id="t-pane-faecher" class="t-pane active">
<p style="font-size:13px;color:var(--text-muted);margin:0 0 12px">Wähle die Fächer aus, die du unterrichtest. Diese erscheinen dann als Auswahlmöglichkeit in allen anderen Bereichen.</p>
<div id="t-my-subjects-list" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;min-height:28px"></div>
<div style="display:flex;gap:8px;margin-bottom:8px">
<select id="t-subject-add" style="flex:1">
<option value="">Vorhandenes Fach hinzufügen…</option>
</select>
<button class="add-btn" onclick="tAddMySubject()">Hinzufügen</button>
</div>
<div style="display:flex;gap:8px">
<input type="text" id="t-subject-new" class="t-input" placeholder="Neues Fach erstellen und hinzufügen…" maxlength="60" style="flex:1">
<button class="add-btn" onclick="tCreateSubject()">Erstellen</button>
</div>
</div>
<div id="t-pane-materials" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap"> <div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input type="text" id="mat-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:1;min-width:160px"> <select id="mat-subject" class="t-input" style="flex:1;min-width:130px"><option value="">Fach auswählen…</option></select>
<input type="text" id="mat-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:2;min-width:160px">
<button class="add-btn" onclick="document.getElementById('teacher-file-input').click()">↑ Datei hochladen</button> <button class="add-btn" onclick="document.getElementById('teacher-file-input').click()">↑ Datei hochladen</button>
</div> </div>
<div id="list-teacher-materials"><div class="empty">Keine Materialien hochgeladen</div></div> <div id="list-teacher-materials"><div class="empty">Keine Materialien hochgeladen</div></div>
@@ -1207,7 +1239,10 @@ footer {
<div id="t-pane-announcements" class="t-pane"> <div id="t-pane-announcements" class="t-pane">
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px"> <div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">
<input type="text" id="ann-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200"> <div style="display:flex;gap:8px;flex-wrap:wrap">
<select id="ann-subject" class="t-input" style="flex:1;min-width:130px"><option value="">Fach auswählen…</option></select>
<input type="text" id="ann-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:2;min-width:160px">
</div>
<textarea id="ann-content" class="t-input" placeholder="Inhalt (max. 5000 Zeichen)" maxlength="5000" rows="3" style="resize:vertical"></textarea> <textarea id="ann-content" class="t-input" placeholder="Inhalt (max. 5000 Zeichen)" maxlength="5000" rows="3" style="resize:vertical"></textarea>
<button class="add-btn" style="align-self:flex-start" onclick="createAnnouncement()">+ Ankündigung</button> <button class="add-btn" style="align-self:flex-start" onclick="createAnnouncement()">+ Ankündigung</button>
</div> </div>
@@ -1216,7 +1251,10 @@ footer {
<div id="t-pane-exams" class="t-pane"> <div id="t-pane-exams" class="t-pane">
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px"> <div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">
<input type="text" id="exam-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200"> <div style="display:flex;gap:8px;flex-wrap:wrap">
<select id="exam-subject" class="t-input" style="flex:1;min-width:130px"><option value="">Fach auswählen…</option></select>
<input type="text" id="exam-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:2;min-width:160px">
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap"> <div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="date" id="exam-date" class="t-input" style="flex:1;min-width:140px"> <input type="date" id="exam-date" class="t-input" style="flex:1;min-width:140px">
<input type="text" id="exam-desc" class="t-input" placeholder="Beschreibung (optional)" maxlength="1000" style="flex:2;min-width:160px"> <input type="text" id="exam-desc" class="t-input" placeholder="Beschreibung (optional)" maxlength="1000" style="flex:2;min-width:160px">
@@ -1228,6 +1266,7 @@ footer {
<div id="t-pane-grades" class="t-pane"> <div id="t-pane-grades" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end"> <div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end">
<select id="grade-subject" class="t-input" style="flex:1;min-width:130px"><option value="">Fach…</option></select>
<select id="grade-student" class="t-input" style="flex:2;min-width:140px"> <select id="grade-student" class="t-input" style="flex:2;min-width:140px">
<option value="">Schüler auswählen…</option> <option value="">Schüler auswählen…</option>
</select> </select>
@@ -1244,9 +1283,40 @@ footer {
</div> </div>
<div id="list-teacher-grades"><div class="empty">Keine Noten vergeben</div></div> <div id="list-teacher-grades"><div class="empty">Keine Noten vergeben</div></div>
</div> </div>
<div id="t-pane-timetable" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end">
<select id="tt-day" class="t-input" style="flex:1;min-width:120px">
<option value="">Tag…</option>
<option>Montag</option><option>Dienstag</option><option>Mittwoch</option><option>Donnerstag</option><option>Freitag</option>
</select>
<select id="tt-time" class="t-input" style="flex:1;min-width:140px">
<option value="">Stunde…</option>
</select>
<input type="text" id="tt-subject" class="t-input" placeholder="Fach (leer = eigenes)" maxlength="60" style="flex:1;min-width:120px" list="subjects-datalist">
<input type="text" id="tt-room" class="t-input" placeholder="Raum (optional)" maxlength="60" style="flex:1;min-width:100px">
<button class="add-btn" onclick="addTimetableEntry()">+ Eintragen</button>
</div>
<div id="list-teacher-timetable"><div class="empty">Keine Einträge</div></div>
</div>
<div id="t-pane-absences" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end">
<select id="ab-student" class="t-input" style="flex:2;min-width:140px">
<option value="">Schüler auswählen…</option>
</select>
<input type="date" id="ab-date" class="t-input" style="flex:1;min-width:140px">
<input type="text" id="ab-subject" class="t-input" placeholder="Fach / Stunde" maxlength="200" style="flex:1;min-width:120px" list="subjects-datalist">
<input type="text" id="ab-reason" class="t-input" placeholder="Grund (optional)" maxlength="500" style="flex:2;min-width:140px">
<button class="add-btn" onclick="addStudentAbsence()">+ Fehlzeit</button>
</div>
<div id="list-teacher-absences"><div class="empty">Keine Fehlzeiten</div></div>
</div>
</div> </div>
</div> </div>
<datalist id="subjects-datalist"></datalist>
<main> <main>
<div class="main-grid"> <div class="main-grid">
@@ -1276,7 +1346,6 @@ footer {
<div class="card-title"><i data-lucide="calendar" aria-hidden="true"></i> Stundenplan</div> <div class="card-title"><i data-lucide="calendar" aria-hidden="true"></i> Stundenplan</div>
<div class="card-actions"> <div class="card-actions">
<button class="print-btn" onclick="window.print()"><i data-lucide="printer" aria-hidden="true"></i> Drucken</button> <button class="print-btn" onclick="window.print()"><i data-lucide="printer" aria-hidden="true"></i> Drucken</button>
<button class="add-btn" onclick="openModal('timetable')">+ Stunde</button>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -1422,6 +1491,23 @@ footer {
</div> </div>
</div> </div>
<div class="settings-section" id="teacher-subject-section" style="display:none">
<div class="settings-label">Meine Lehrfächer</div>
<div id="my-subjects-list" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px"></div>
<div class="settings-fields">
<div style="display:flex;gap:8px">
<select id="subject-add-select" style="flex:1">
<option value="">Vorhandenes Fach hinzufügen…</option>
</select>
<button class="btn-save" style="white-space:nowrap" onclick="addMySubject()">Hinzufügen</button>
</div>
<div style="display:flex;gap:8px">
<input type="text" id="subject-new" placeholder="Neues Fach erstellen &amp; hinzufügen…" maxlength="60" style="flex:1">
<button class="btn-save" style="white-space:nowrap" onclick="createNewSubject()">Erstellen</button>
</div>
</div>
</div>
<div class="settings-section" id="2fa-section"> <div class="settings-section" id="2fa-section">
<div class="settings-label">Zwei-Faktor-Authentifizierung (2FA)</div> <div class="settings-label">Zwei-Faktor-Authentifizierung (2FA)</div>
<div id="2fa-status-row" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geladen…</div> <div id="2fa-status-row" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geladen…</div>
@@ -1445,6 +1531,29 @@ footer {
</div> </div>
</div> </div>
</div> </div>
<!-- Recovery codes display (after setup or regen) -->
<div id="2fa-codes-area" style="display:none">
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#92400e">
Speichere diese Codes sicher. Jeder Code kann nur <strong>einmal</strong> verwendet werden. Ohne Codes und Authenticator-App verlierst du den Zugang.
</div>
<div id="2fa-codes-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px;font-family:monospace;font-size:13px;background:var(--n-50);border:1px solid var(--border);border-radius:8px;padding:12px"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-save" style="align-self:flex-start;font-size:12px;padding:5px 12px" onclick="copyRecoveryCodes()">Kopieren</button>
<button class="btn-save" style="align-self:flex-start;font-size:12px;padding:5px 12px" onclick="downloadRecoveryCodes()">Als Datei speichern</button>
<button class="btn-cancel" style="align-self:flex-start;font-size:12px;padding:5px 12px" onclick="dismissRecoveryCodes()">Fertig</button>
</div>
</div>
<!-- Regenerate recovery codes -->
<div id="2fa-regen-area" style="display:none">
<div style="font-size:13px;color:var(--text-2);margin-bottom:8px">Bestätige mit deinem aktuellen 2FA-Code um neue Wiederherstellungscodes zu erstellen. Alte Codes werden ungültig.</div>
<div class="settings-fields">
<input type="text" id="2fa-regen-code" placeholder="6-stelliger 2FA-Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
<div style="display:flex;gap:8px">
<button class="btn-save" style="align-self:flex-start" onclick="doRegenCodes()">Neu generieren</button>
<button class="btn-cancel" style="align-self:flex-start" onclick="cancelRegenCodes()">Abbrechen</button>
</div>
</div>
</div>
<!-- Disable flow --> <!-- Disable flow -->
<div id="2fa-disable-area" style="display:none"> <div id="2fa-disable-area" style="display:none">
<div class="settings-fields"> <div class="settings-fields">
@@ -1464,7 +1573,11 @@ footer {
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:13px;color:var(--green);font-weight:600">✓ 2FA ist aktiv</span> <span style="font-size:13px;color:var(--green);font-weight:600">✓ 2FA ist aktiv</span>
</div> </div>
<button class="btn-danger" style="font-size:12px;padding:5px 12px" onclick="showDisable2FA()">2FA deaktivieren</button> <div id="2fa-rc-count" style="font-size:12px;color:var(--text-muted);margin-bottom:8px"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-cancel" style="font-size:12px;padding:5px 12px" onclick="showRegenCodes()">Wiederherstellungscodes neu generieren</button>
<button class="btn-danger" style="font-size:12px;padding:5px 12px" onclick="showDisable2FA()">2FA deaktivieren</button>
</div>
</div> </div>
</div> </div>
@@ -1491,14 +1604,14 @@ footer {
const DAYS = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag']; const DAYS = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag'];
const DAY_S = ['Mo','Di','Mi','Do','Fr']; const DAY_S = ['Mo','Di','Mi','Do','Fr'];
const TIME_SLOTS = [ const TIME_SLOTS = [
{ key:'07:45', label:'1.', time:'07:45' }, { key:'08:00', label:'1.', time:'08:00' },
{ key:'08:30', label:'2.', time:'08:30' }, { key:'08:45', label:'2.', time:'08:45' },
{ key:'09:30', label:'3.', time:'09:30' }, { key:'09:45', label:'3.', time:'09:45' },
{ key:'10:15', label:'4.', time:'10:15' }, { key:'10:30', label:'4.', time:'10:30' },
{ key:'11:15', label:'5.', time:'11:15' }, { key:'11:30', label:'5.', time:'11:30' },
{ key:'12:00', label:'6.', time:'12:00' }, { key:'12:15', label:'6.', time:'12:15' },
{ key:'13:30', label:'7.', time:'13:30' }, { key:'13:45', label:'7.', time:'13:45' },
{ key:'14:15', label:'8.', time:'14:15' }, { key:'14:30', label:'8.', time:'14:30' },
]; ];
const GRADE_WEIGHTS = { schulaufgabe:2, kurzarbeit:1.5, stegreifaufgabe:1, muendlich:1, sonstiges:1 }; const GRADE_WEIGHTS = { schulaufgabe:2, kurzarbeit:1.5, stegreifaufgabe:1, muendlich:1, sonstiges:1 };
const GRADE_TYPES = { schulaufgabe:'Schulaufgabe', kurzarbeit:'Kurzarbeit', stegreifaufgabe:'Stegreifaufgabe', muendlich:'Mündlich', sonstiges:'Sonstiges' }; const GRADE_TYPES = { schulaufgabe:'Schulaufgabe', kurzarbeit:'Kurzarbeit', stegreifaufgabe:'Stegreifaufgabe', muendlich:'Mündlich', sonstiges:'Sonstiges' };
@@ -1563,8 +1676,14 @@ loadWeather();
// ── API ──────────────────────────────────────────────────────── // ── API ────────────────────────────────────────────────────────
async function api(method,path,body){ async function api(method,path,body){
const r=await fetch('/api/'+path,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}); try{
return r.json(); const r=await fetch('/api/'+path,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const text=await r.text();
let data;
try{data=text?JSON.parse(text):{};}catch{data={error:'Ungültige Antwort vom Server'};console.warn('api non-JSON',path,text.slice(0,200));}
if(!r.ok){console.warn('api',method,path,r.status,data);if(!data.error)data.error='HTTP '+r.status;}
return data;
}catch(e){console.error('api fetch',method,path,e);return{error:'Netzwerkfehler: '+e.message};}
} }
// ── SESSION ──────────────────────────────────────────────────── // ── SESSION ────────────────────────────────────────────────────
@@ -1580,13 +1699,17 @@ async function init(){
if(r.ok){ const d=await r.json(); loginUI(d.username,d.id,d.role,d.subject); } if(r.ok){ const d=await r.json(); loginUI(d.username,d.id,d.role,d.subject); }
} }
let currentRole = '';
function loginUI(username,id,role,subject){ function loginUI(username,id,role,subject){
currentRole = role;
document.getElementById('banner').style.display='none'; document.getElementById('banner').style.display='none';
document.getElementById('card-tt-public').style.display='none'; document.getElementById('card-tt-public').style.display='none';
document.getElementById('card-tt').style.display=''; document.getElementById('card-tt').style.display='';
document.getElementById('pair-hw-todo').style.display='grid'; document.getElementById('pair-hw-todo').style.display='grid';
document.getElementById('sb-label-komm').style.display=''; document.getElementById('sb-label-komm').style.display='';
['sb-nav-grades','sb-nav-files','sb-nav-ab','sb-nav-chat','sb-nav-tickets'].forEach(id=>document.getElementById(id).style.display='flex'); ['sb-nav-files','sb-nav-ab','sb-nav-chat','sb-nav-tickets'].forEach(id=>document.getElementById(id).style.display='flex');
if(role!=='teacher') document.getElementById('sb-nav-grades').style.display='flex';
['card-hw','card-todo','card-cd'].forEach(id=>document.getElementById(id).style.display=''); ['card-hw','card-todo','card-cd'].forEach(id=>document.getElementById(id).style.display='');
['btn-add-pruefung','btn-add-ferien','btn-add-ql'].forEach(id=>document.getElementById(id).style.display=''); ['btn-add-pruefung','btn-add-ferien','btn-add-ql'].forEach(id=>document.getElementById(id).style.display='');
@@ -1594,10 +1717,12 @@ function loginUI(username,id,role,subject){
document.getElementById('sb-label-klasse').style.display=''; document.getElementById('sb-label-klasse').style.display='';
['sb-nav-materials','sb-nav-announcements'].forEach(id=>document.getElementById(id).style.display='flex'); ['sb-nav-materials','sb-nav-announcements'].forEach(id=>document.getElementById(id).style.display='flex');
// Teacher-only panel // Teacher features
if(role==='teacher'){ if(role==='teacher'){
document.getElementById('sb-label-lehrer').style.display=''; document.getElementById('sb-label-lehrer').style.display='';
document.getElementById('sb-nav-teacher').style.display='flex'; document.getElementById('sb-nav-teacher').style.display='flex';
document.getElementById('ab-teacher-form').style.display='';
loadStudentListForAbsencesCard();
} }
const adminLink = role === 'admin' ? `<div class="dd-sep"></div><a class="dd-item" href="/admin">🛡️ Admin</a>` : ''; const adminLink = role === 'admin' ? `<div class="dd-sep"></div><a class="dd-item" href="/admin">🛡️ Admin</a>` : '';
@@ -1618,6 +1743,7 @@ function loginUI(username,id,role,subject){
</div> </div>
</div>`; </div>`;
loadSubjectsDatalist();
loadAll(); loadAll();
initChat(username, id); initChat(username, id);
} }
@@ -1635,12 +1761,16 @@ async function doLogout(){
// ── LOAD ALL ────────────────────────────────────────────────── // ── LOAD ALL ──────────────────────────────────────────────────
async function loadAll(){ async function loadAll(){
const [tt,hw,gr,ab,td,cd,ql]=await Promise.all([ const [tt,hw,gr,td,cd,ql]=await Promise.all([
api('GET','timetable'),api('GET','homework'),api('GET','grades'), api('GET','teacher/class-timetable?class_id=info1'),
api('GET','absences'),api('GET','todos'),api('GET','countdowns'),api('GET','quicklinks') api('GET','homework'),
api('GET','grades'),
api('GET','todos'),
api('GET','countdowns'),
api('GET','quicklinks')
]); ]);
renderTT(tt); renderHW(hw); renderGrades(gr); renderTT(Array.isArray(tt)?tt:[]); renderHW(hw); renderGrades(gr);
renderAbsences(ab); renderTodos(td); renderCountdowns(cd); renderQL(ql); renderTodos(td); renderCountdowns(cd); renderQL(ql);
loadFiles(); loadFiles();
loadTickets(); loadTickets();
} }
@@ -1687,7 +1817,7 @@ function renderTT(data){
const lel=document.createElement('div'); const lel=document.createElement('div');
lel.className='tt-lesson'; lel.className='tt-lesson';
lel.style.cssText=`background:${col.bg};border-left:2.5px solid ${col.border};color:${col.text}`; lel.style.cssText=`background:${col.bg};border-left:2.5px solid ${col.border};color:${col.text}`;
lel.innerHTML=`<div class="tt-lesson-subj">${esc(lesson.subject||'')}</div><div class="tt-lesson-meta">${esc(lesson.room||'')}${lesson.teacher?' · '+esc(lesson.teacher):''}</div><button class="tt-del" onclick="delItem('timetable',${lesson.id})">✕</button>`; lel.innerHTML=`<div class="tt-lesson-subj">${esc(lesson.subject||'')}</div><div class="tt-lesson-meta">${esc(lesson.room||'')}${lesson.teacher_name?' · '+esc(lesson.teacher_name):''}</div>`;
cell.appendChild(lel); cell.appendChild(lel);
} }
grid.appendChild(cell); grid.appendChild(cell);
@@ -1788,15 +1918,68 @@ function renderGrades(data){
} }
// ── ABSENCES ────────────────────────────────────────────────── // ── ABSENCES ──────────────────────────────────────────────────
async function loadAbsencesCard(){
const el=document.getElementById('list-ab');
el.innerHTML='<div class="sk-line sk"></div>';
if(currentRole==='teacher') await loadStudentListForAbsencesCard();
const data=currentRole==='teacher'
? await api('GET','teacher/absences')
: await api('GET','absences');
renderAbsences(Array.isArray(data)?data:[]);
}
function renderAbsences(data){ function renderAbsences(data){
const el=document.getElementById('list-ab'); const el=document.getElementById('list-ab');
if(!data.length){el.innerHTML='<div class="empty">Keine Fehlzeiten</div>';return;} if(!data.length){el.innerHTML='<div class="empty">Keine Fehlzeiten eingetragen</div>';return;}
el.innerHTML=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(a=> const sorted=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||''));
`<div class="ab-item"> if(currentRole==='teacher'){
el.innerHTML=sorted.map(a=>`<div class="ab-item">
<div class="ab-date">${a.date?fmtDate(a.date):''}</div>
<div class="ab-info">
<div class="ab-subj"><strong>${esc(a.student_name||'')}</strong>${a.subject?' · '+esc(a.subject):''}</div>
<div class="ab-reason">${esc(a.reason||'')}</div>
</div>
<button class="del-btn" onclick="delTeacherAbsenceCard(${a.id})">🗑</button>
</div>`).join('')+`<div class="ab-total">${data.length} Fehlzeit${data.length!==1?'en':''} gesamt</div>`;
} else {
el.innerHTML=sorted.map(a=>`<div class="ab-item">
<div class="ab-date">${a.date?fmtDate(a.date):''}</div> <div class="ab-date">${a.date?fmtDate(a.date):''}</div>
<div class="ab-info"><div class="ab-subj">${esc(a.subject||'')}</div><div class="ab-reason">${esc(a.reason||'')}</div></div> <div class="ab-info"><div class="ab-subj">${esc(a.subject||'')}</div><div class="ab-reason">${esc(a.reason||'')}</div></div>
<button class="del-btn" onclick="delItem('absences',${a.id})">🗑</button>
</div>`).join('')+`<div class="ab-total">${data.length} Fehlzeit${data.length!==1?'en':''} gesamt</div>`; </div>`).join('')+`<div class="ab-total">${data.length} Fehlzeit${data.length!==1?'en':''} gesamt</div>`;
}
}
async function loadStudentListForAbsencesCard(){
const students=await api('GET','teacher/students');
if(!Array.isArray(students))return;
['ab-student-card','ab-student'].forEach(id=>{
const sel=document.getElementById(id);
if(!sel)return;
const cur=sel.value;
sel.innerHTML='<option value="">Schüler auswählen…</option>'+students.map(s=>`<option value="${s.id}">${esc(s.username)}</option>`).join('');
if(cur)sel.value=cur;
});
}
async function addAbsenceFromCard(){
const student_id=parseInt(document.getElementById('ab-student-card').value,10);
const date=document.getElementById('ab-date-card').value;
const subject=document.getElementById('ab-subject-card').value.trim();
const reason=document.getElementById('ab-reason-card').value.trim();
if(!student_id){toast('Schüler auswählen','error');return;}
const r=await api('POST','teacher/absences',{student_id,date:date||null,subject:subject||null,reason:reason||null});
if(r.error){toast(r.error,'error');return;}
document.getElementById('ab-date-card').value='';
document.getElementById('ab-subject-card').value='';
document.getElementById('ab-reason-card').value='';
toast('Fehlzeit eingetragen ✓','success');
loadAbsencesCard();
}
async function delTeacherAbsenceCard(id){
await api('DELETE','teacher/absences/'+id);
toast('Gelöscht');
loadAbsencesCard();
} }
// ── TODOS ───────────────────────────────────────────────────── // ── TODOS ─────────────────────────────────────────────────────
@@ -1848,11 +2031,6 @@ async function toggle(type,id,current){
// ── MODAL CONFIG ────────────────────────────────────────────── // ── MODAL CONFIG ──────────────────────────────────────────────
const MODALS={ const MODALS={
timetable:{title:'Stunde hinzufügen',fields:[
{n:'day',l:'Tag',t:'select',opts:DAYS},
{n:'time_start',l:'Uhrzeit',t:'select',opts:TIME_SLOTS.map(s=>s.key),labels:TIME_SLOTS.map(s=>`${s.label} Std. ${s.time}`)},
{n:'subject',l:'Fach',t:'text'},{n:'room',l:'Raum',t:'text'},{n:'teacher',l:'Lehrkraft',t:'text'}
]},
homework:{title:'Hausaufgabe hinzufügen',fields:[ homework:{title:'Hausaufgabe hinzufügen',fields:[
{n:'title',l:'Titel',t:'text'},{n:'subject',l:'Fach',t:'text'},{n:'due_date',l:'Fällig am',t:'date'} {n:'title',l:'Titel',t:'text'},{n:'subject',l:'Fach',t:'text'},{n:'due_date',l:'Fällig am',t:'date'}
]}, ]},
@@ -1862,9 +2040,6 @@ const MODALS={
{n:'type',l:'Typ',t:'select',opts:Object.keys(GRADE_TYPES),labels:Object.values(GRADE_TYPES)}, {n:'type',l:'Typ',t:'select',opts:Object.keys(GRADE_TYPES),labels:Object.values(GRADE_TYPES)},
{n:'note',l:'Anmerkung (optional)',t:'text'} {n:'note',l:'Anmerkung (optional)',t:'text'}
]}, ]},
absences:{title:'Fehlzeit eintragen',fields:[
{n:'date',l:'Datum',t:'date'},{n:'subject',l:'Fach / Stunde',t:'text'},{n:'reason',l:'Grund',t:'text'}
]},
todos:{title:'Aufgabe hinzufügen',fields:[{n:'title',l:'Aufgabe',t:'text'}]}, todos:{title:'Aufgabe hinzufügen',fields:[{n:'title',l:'Aufgabe',t:'text'}]},
countdowns:{title:'Countdown hinzufügen',fields:[ countdowns:{title:'Countdown hinzufügen',fields:[
{n:'title',l:'Bezeichnung',t:'text'},{n:'target_date',l:'Datum',t:'date'} {n:'title',l:'Bezeichnung',t:'text'},{n:'target_date',l:'Datum',t:'date'}
@@ -1918,19 +2093,67 @@ function openSettings(){
document.getElementById('settings-overlay').style.display='flex'; document.getElementById('settings-overlay').style.display='flex';
document.getElementById('user-dropdown')?.classList.remove('open'); document.getElementById('user-dropdown')?.classList.remove('open');
load2FAStatus(); load2FAStatus();
if(currentRole==='teacher'){
document.getElementById('teacher-subject-section').style.display='';
loadMySubjects();
loadSubjectsDatalist();
}
}
async function loadSubjectsDatalist(){
const subjects=await api('GET','subjects');
if(!Array.isArray(subjects))return;
const dl=document.getElementById('subjects-datalist');
dl.innerHTML=subjects.map(s=>`<option value="${esc(s.name)}">`).join('');
const sel=document.getElementById('subject-add-select');
if(sel) sel.innerHTML='<option value="">Vorhandenes Fach hinzufügen…</option>'+subjects.map(s=>`<option value="${esc(s.name)}">${esc(s.name)}</option>`).join('');
}
async function loadMySubjects(){
const mine=await api('GET','teacher/my-subjects');
if(!Array.isArray(mine))return;
const el=document.getElementById('my-subjects-list');
if(!el)return;
el.innerHTML=mine.length?mine.map(s=>`<span style="display:inline-flex;align-items:center;gap:4px;background:var(--blue-100);color:#1d4ed8;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:500">${esc(s)}<button onclick="removeMySubject('${esc(s)}')" style="background:none;border:none;cursor:pointer;color:inherit;font-size:11px;padding:0;line-height:1;margin-left:2px">✕</button></span>`).join(''):'<span style="font-size:13px;color:var(--text-muted)">Noch keine Fächer hinzugefügt</span>';
}
async function addMySubject(){
const name=document.getElementById('subject-add-select').value;
if(!name){toast('Fach auswählen','error');return;}
const r=await api('POST','teacher/my-subjects',{name});
if(r.error){toast(r.error,'error');return;}
toast('Fach hinzugefügt ✓','success');
loadMySubjects();
}
async function removeMySubject(name){
await api('DELETE','teacher/my-subjects/'+encodeURIComponent(name));
loadMySubjects();
}
async function createNewSubject(){
const name=document.getElementById('subject-new').value.trim();
if(!name){toast('Name erforderlich','error');return;}
const r=await api('POST','teacher/subjects',{name});
if(r.error){toast(r.error,'error');return;}
document.getElementById('subject-new').value='';
toast('Fach erstellt & hinzugefügt ✓','success');
loadMySubjects();
loadSubjectsDatalist();
} }
function closeSettings(){document.getElementById('settings-overlay').style.display='none';} function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
async function load2FAStatus(){ async function load2FAStatus(){
const statusRow=document.getElementById('2fa-status-row'); const statusRow=document.getElementById('2fa-status-row');
document.getElementById('2fa-idle-area').style.display='none'; ['2fa-idle-area','2fa-enabled-area','2fa-setup-area','2fa-disable-area','2fa-codes-area','2fa-regen-area'].forEach(id=>{
document.getElementById('2fa-enabled-area').style.display='none'; document.getElementById(id).style.display='none';
document.getElementById('2fa-setup-area').style.display='none'; });
document.getElementById('2fa-disable-area').style.display='none';
try { try {
const r=await api('GET','2fa/status'); const r=await api('GET','2fa/status');
statusRow.textContent=''; statusRow.textContent='';
if(r.enabled){ if(r.enabled){
const n=r.recovery_codes_remaining;
document.getElementById('2fa-rc-count').textContent=n+' Wiederherstellungscode'+(n!==1?'s':'')+' verbleibend';
document.getElementById('2fa-enabled-area').style.display=''; document.getElementById('2fa-enabled-area').style.display='';
} else { } else {
document.getElementById('2fa-idle-area').style.display=''; document.getElementById('2fa-idle-area').style.display='';
@@ -1941,10 +2164,12 @@ async function load2FAStatus(){
} }
async function setup2FA(){ async function setup2FA(){
const pw=prompt('Zur Bestätigung bitte dein Passwort eingeben:');
if(!pw)return;
document.getElementById('2fa-idle-area').style.display='none'; document.getElementById('2fa-idle-area').style.display='none';
document.getElementById('2fa-status-row').textContent='QR-Code wird generiert…'; document.getElementById('2fa-status-row').textContent='QR-Code wird generiert…';
try { try {
const r=await api('POST','2fa/setup'); const r=await api('POST','2fa/setup',{password:pw});
document.getElementById('2fa-status-row').textContent=''; document.getElementById('2fa-status-row').textContent='';
if(r.error){toast(r.error,'error');document.getElementById('2fa-idle-area').style.display='';return;} if(r.error){toast(r.error,'error');document.getElementById('2fa-idle-area').style.display='';return;}
document.getElementById('2fa-qr').src=r.qr; document.getElementById('2fa-qr').src=r.qr;
@@ -1971,7 +2196,60 @@ async function confirm2FA(){
if(r.ok){ if(r.ok){
toast('2FA aktiviert ✓','success'); toast('2FA aktiviert ✓','success');
document.getElementById('2fa-setup-area').style.display='none'; document.getElementById('2fa-setup-area').style.display='none';
document.getElementById('2fa-enabled-area').style.display=''; showRecoveryCodes(r.codes);
} else {
toast(r.error,'error');
}
}
let _recoveryCodes=[];
function showRecoveryCodes(codes){
_recoveryCodes=codes;
const grid=document.getElementById('2fa-codes-grid');
grid.innerHTML=codes.map(c=>`<div style="padding:4px 6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;letter-spacing:.05em">${esc(c)}</div>`).join('');
document.getElementById('2fa-codes-area').style.display='';
}
function copyRecoveryCodes(){
navigator.clipboard.writeText(_recoveryCodes.join('\n')).then(()=>toast('Codes kopiert','success'));
}
function downloadRecoveryCodes(){
const text='INFO1 Wiederherstellungscodes\n'+new Date().toLocaleString('de-DE')+'\n\n'+_recoveryCodes.join('\n')+'\n\nJeder Code kann nur einmal verwendet werden.';
const a=document.createElement('a');
a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(text);
a.download='info1-wiederherstellungscodes.txt';
a.click();
}
async function dismissRecoveryCodes(){
_recoveryCodes=[];
document.getElementById('2fa-codes-area').style.display='none';
await load2FAStatus();
}
function showRegenCodes(){
document.getElementById('2fa-enabled-area').style.display='none';
document.getElementById('2fa-regen-code').value='';
document.getElementById('2fa-regen-area').style.display='';
document.getElementById('2fa-regen-code').focus();
}
function cancelRegenCodes(){
document.getElementById('2fa-regen-area').style.display='none';
document.getElementById('2fa-enabled-area').style.display='';
}
async function doRegenCodes(){
const code=document.getElementById('2fa-regen-code').value.trim();
if(!code){toast('Code eingeben','error');return;}
const pw=prompt('Zur Bestätigung bitte dein Passwort eingeben:');
if(!pw)return;
const r=await api('POST','2fa/regenerate-codes',{token:code,password:pw});
if(r.ok){
document.getElementById('2fa-regen-area').style.display='none';
showRecoveryCodes(r.codes);
} else { } else {
toast(r.error,'error'); toast(r.error,'error');
} }
@@ -2376,18 +2654,160 @@ document.addEventListener('keydown',e=>{
// ── TEACHER PANEL ───────────────────────────────────────────── // ── TEACHER PANEL ─────────────────────────────────────────────
function teacherTab(name){ function teacherTab(name){
const names=['materials','announcements','exams','grades']; const names=['faecher','materials','announcements','exams','grades','timetable','absences'];
document.querySelectorAll('.t-tab').forEach((t,i)=>t.classList.toggle('active',names[i]===name)); document.querySelectorAll('.t-tab').forEach((t,i)=>t.classList.toggle('active',names[i]===name));
document.querySelectorAll('.t-pane').forEach(p=>p.classList.remove('active')); document.querySelectorAll('.t-pane').forEach(p=>p.classList.remove('active'));
document.getElementById('t-pane-'+name).classList.add('active'); document.getElementById('t-pane-'+name).classList.add('active');
if(name==='grades') loadStudentListForGrades(); if(name==='faecher') loadTFaecher();
if(name==='grades'){loadStudentListForGrades();populateTeacherSubjectSelects();}
if(name==='timetable'){loadTeacherTimetable();populateTimetableTimeSelect();}
if(name==='absences'){loadTeacherAbsences();loadStudentListForAbsences();}
} }
async function loadTeacherPanel(){ async function loadTeacherPanel(){
loadTeacherMaterials(); loadTFaecher();
loadTeacherAnnouncements(); }
loadTeacherExams();
loadTeacherGrades(); async function loadTFaecher(){
const [all, mine]=await Promise.all([api('GET','subjects'),api('GET','teacher/my-subjects')]);
const mySet=new Set(Array.isArray(mine)?mine:[]);
// populate "add existing" select
const sel=document.getElementById('t-subject-add');
if(sel){
const opts=Array.isArray(all)?all.filter(s=>!mySet.has(s.name)).map(s=>`<option value="${esc(s.name)}">${esc(s.name)}</option>`).join(''):'';
sel.innerHTML='<option value="">Vorhandenes Fach hinzufügen…</option>'+opts;
}
// render current subjects as removable badges
const el=document.getElementById('t-my-subjects-list');
if(el){
el.innerHTML=mySet.size?[...mySet].map(s=>`<span style="display:inline-flex;align-items:center;gap:4px;background:var(--blue-100);color:#1d4ed8;border-radius:12px;padding:3px 10px;font-size:12px;font-weight:500">${esc(s)}<button onclick="tRemoveSubject('${esc(s)}')" style="background:none;border:none;cursor:pointer;color:inherit;font-size:12px;padding:0;line-height:1;margin-left:2px">✕</button></span>`).join(''):'<span style="font-size:13px;color:var(--text-muted)">Noch keine Fächer hinzugefügt</span>';
}
// also refresh the other pane selects
populateTeacherSubjectSelects(mine);
}
async function populateTeacherSubjectSelects(mine){
if(!mine) mine=await api('GET','teacher/my-subjects');
if(!Array.isArray(mine))return;
const opts='<option value="">Fach auswählen…</option>'+mine.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
['mat-subject','ann-subject','exam-subject','grade-subject'].forEach(id=>{
const el=document.getElementById(id);
if(el){const v=el.value;el.innerHTML=opts;if(v)el.value=v;}
});
}
async function tAddMySubject(){
const name=document.getElementById('t-subject-add').value;
if(!name){toast('Fach auswählen','error');return;}
const r=await api('POST','teacher/my-subjects',{name});
if(r.error){toast(r.error,'error');return;}
toast('Hinzugefügt ✓','success');
loadTFaecher();
}
async function tCreateSubject(){
const name=document.getElementById('t-subject-new').value.trim();
if(!name){toast('Name eingeben','error');return;}
const r=await api('POST','teacher/subjects',{name});
if(r.error){toast(r.error,'error');return;}
document.getElementById('t-subject-new').value='';
toast(name+' erstellt & hinzugefügt ✓','success');
loadTFaecher();
loadSubjectsDatalist();
}
async function tRemoveSubject(name){
await api('DELETE','teacher/my-subjects/'+encodeURIComponent(name));
loadTFaecher();
}
function populateTimetableTimeSelect(){
const sel=document.getElementById('tt-time');
if(sel.options.length>1)return;
TIME_SLOTS.forEach(s=>{
const o=document.createElement('option');
o.value=s.key; o.textContent=`${s.label}. Stunde ${s.time}`;
sel.appendChild(o);
});
}
async function loadTeacherTimetable(){
const data=await api('GET','teacher/class-timetable?class_id=info1');
const el=document.getElementById('list-teacher-timetable');
if(!Array.isArray(data)||!data.length){el.innerHTML='<div class="empty">Keine Einträge</div>';return;}
el.innerHTML=data.map(e=>`<div class="ev-item">
<div class="ev-info">
<div class="ev-title">${esc(e.day)} · ${esc(e.time_start||'')}${e.time_end?''+esc(e.time_end):''}</div>
<div class="ev-date">${esc(e.subject||'')}${e.room?' · '+esc(e.room):''}</div>
</div>
<button class="ev-del" onclick="delTeacherTimetableEntry(${e.id})">🗑</button>
</div>`).join('');
}
async function addTimetableEntry(){
const day=document.getElementById('tt-day').value;
const time_start=document.getElementById('tt-time').value;
const subject=document.getElementById('tt-subject').value.trim();
const room=document.getElementById('tt-room').value.trim();
if(!day||!time_start){toast('Tag und Stunde erforderlich','error');return;}
const r=await api('POST','teacher/class-timetable',{day,time_start,subject:subject||null,room:room||null,class_id:'info1'});
if(r.error){toast(r.error,'error');return;}
document.getElementById('tt-subject').value='';
document.getElementById('tt-room').value='';
toast('Eingetragen ✓','success');
loadTeacherTimetable();
loadAll();
}
async function delTeacherTimetableEntry(id){
await api('DELETE','teacher/class-timetable/'+id);
toast('Gelöscht');
loadTeacherTimetable();
loadAll();
}
async function loadTeacherAbsences(){
const data=await api('GET','teacher/absences');
const el=document.getElementById('list-teacher-absences');
if(!Array.isArray(data)||!data.length){el.innerHTML='<div class="empty">Keine Fehlzeiten</div>';return;}
el.innerHTML=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(a=>`<div class="ab-item">
<div class="ab-date">${a.date?fmtDate(a.date):''}</div>
<div class="ab-info">
<div class="ab-subj"><strong>${esc(a.student_name)}</strong> · ${esc(a.subject||'')}</div>
<div class="ab-reason">${esc(a.reason||'')}</div>
</div>
<button class="del-btn" onclick="delTeacherAbsence(${a.id})">🗑</button>
</div>`).join('');
}
async function loadStudentListForAbsences(){
const students=await api('GET','teacher/students');
if(students.error)return;
const sel=document.getElementById('ab-student');
const cur=sel.value;
sel.innerHTML='<option value="">Schüler auswählen…</option>'+students.map(s=>`<option value="${s.id}">${esc(s.username)}</option>`).join('');
if(cur)sel.value=cur;
}
async function addStudentAbsence(){
const student_id=parseInt(document.getElementById('ab-student').value,10);
const date=document.getElementById('ab-date').value;
const subject=document.getElementById('ab-subject').value.trim();
const reason=document.getElementById('ab-reason').value.trim();
if(!student_id){toast('Schüler auswählen','error');return;}
const r=await api('POST','teacher/absences',{student_id,date:date||null,subject:subject||null,reason:reason||null});
if(r.error){toast(r.error,'error');return;}
document.getElementById('ab-date').value='';
document.getElementById('ab-subject').value='';
document.getElementById('ab-reason').value='';
toast('Fehlzeit eingetragen ✓','success');
loadTeacherAbsences();
}
async function delTeacherAbsence(id){
await api('DELETE','teacher/absences/'+id);
toast('Gelöscht');
loadTeacherAbsences();
} }
async function loadTeacherMaterials(){ async function loadTeacherMaterials(){
@@ -2409,10 +2829,13 @@ async function uploadTeacherMaterial(e){
const files=e.target.files; const files=e.target.files;
if(!files.length)return; if(!files.length)return;
const title=document.getElementById('mat-title').value.trim(); const title=document.getElementById('mat-title').value.trim();
const subject=document.getElementById('mat-subject').value;
if(!title){toast('Bitte einen Titel eingeben','error');e.target.value='';return;} if(!title){toast('Bitte einen Titel eingeben','error');e.target.value='';return;}
if(!subject){toast('Bitte ein Fach auswählen','error');e.target.value='';return;}
const fd=new FormData(); const fd=new FormData();
fd.append('file',files[0]); fd.append('file',files[0]);
fd.append('title',title); fd.append('title',title);
fd.append('subject',subject);
fd.append('class_id','info1'); fd.append('class_id','info1');
e.target.value=''; e.target.value='';
const r=await fetch('/api/teacher/materials',{method:'POST',body:fd}); const r=await fetch('/api/teacher/materials',{method:'POST',body:fd});
@@ -2446,8 +2869,10 @@ async function loadTeacherAnnouncements(){
async function createAnnouncement(){ async function createAnnouncement(){
const title=document.getElementById('ann-title').value.trim(); const title=document.getElementById('ann-title').value.trim();
const content=document.getElementById('ann-content').value.trim(); const content=document.getElementById('ann-content').value.trim();
const subject=document.getElementById('ann-subject').value;
if(!title||!content){toast('Titel und Inhalt erforderlich','error');return;} if(!title||!content){toast('Titel und Inhalt erforderlich','error');return;}
const r=await api('POST','teacher/announcements',{title,content,class_id:'info1'}); if(!subject){toast('Bitte ein Fach auswählen','error');return;}
const r=await api('POST','teacher/announcements',{title,content,subject,class_id:'info1'});
if(r.error){toast(r.error,'error');return;} if(r.error){toast(r.error,'error');return;}
document.getElementById('ann-title').value=''; document.getElementById('ann-title').value='';
document.getElementById('ann-content').value=''; document.getElementById('ann-content').value='';
@@ -2482,8 +2907,10 @@ async function createExam(){
const title=document.getElementById('exam-title').value.trim(); const title=document.getElementById('exam-title').value.trim();
const date=document.getElementById('exam-date').value; const date=document.getElementById('exam-date').value;
const description=document.getElementById('exam-desc').value.trim(); const description=document.getElementById('exam-desc').value.trim();
const subject=document.getElementById('exam-subject').value;
if(!title){toast('Titel erforderlich','error');return;} if(!title){toast('Titel erforderlich','error');return;}
const r=await api('POST','teacher/exams',{title,date:date||null,description:description||null,class_id:'info1'}); if(!subject){toast('Bitte ein Fach auswählen','error');return;}
const r=await api('POST','teacher/exams',{title,date:date||null,description:description||null,subject,class_id:'info1'});
if(r.error){toast(r.error,'error');return;} if(r.error){toast(r.error,'error');return;}
document.getElementById('exam-title').value=''; document.getElementById('exam-title').value='';
document.getElementById('exam-date').value=''; document.getElementById('exam-date').value='';
@@ -2526,9 +2953,11 @@ async function assignGrade(){
const grade=parseFloat(document.getElementById('grade-val').value); const grade=parseFloat(document.getElementById('grade-val').value);
const type=document.getElementById('grade-type').value; const type=document.getElementById('grade-type').value;
const note=document.getElementById('grade-note').value.trim(); const note=document.getElementById('grade-note').value.trim();
const subject=document.getElementById('grade-subject').value;
if(!subject){toast('Fach auswählen','error');return;}
if(!student_id){toast('Schüler auswählen','error');return;} if(!student_id){toast('Schüler auswählen','error');return;}
if(!grade||grade<1||grade>6){toast('Note muss zwischen 1 und 6 liegen','error');return;} if(!grade||grade<1||grade>6){toast('Note muss zwischen 1 und 6 liegen','error');return;}
const r=await api('POST','teacher/grades',{student_id,grade,type,note:note||null}); const r=await api('POST','teacher/grades',{student_id,grade,type,note:note||null,subject});
if(r.error){toast(r.error,'error');return;} if(r.error){toast(r.error,'error');return;}
document.getElementById('grade-val').value=''; document.getElementById('grade-val').value='';
document.getElementById('grade-note').value=''; document.getElementById('grade-note').value='';
+2 -2
View File
@@ -223,8 +223,8 @@ footer a:hover { color: #2563eb; }
<div id="login-step-2" style="display:none"> <div id="login-step-2" style="display:none">
<div class="field"> <div class="field">
<label for="l-totp">2FA-Code</label> <label for="l-totp">2FA-Code</label>
<input type="text" id="l-totp" autocomplete="one-time-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric"> <input type="text" id="l-totp" autocomplete="one-time-code" placeholder="6-stelliger Code oder Wiederherstellungscode" maxlength="11">
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Code aus deiner Authenticator-App eingeben</span> <span style="font-size:11px;color:#9ca3af;margin-top:2px">Code aus der Authenticator-App oder einen Wiederherstellungscode (xxxxx-xxxxx) eingeben</span>
</div> </div>
<button type="button" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:0;margin-top:4px" onclick="backToStep1()">← Zurück</button> <button type="button" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:0;margin-top:4px" onclick="backToStep1()">← Zurück</button>
</div> </div>
+11 -2
View File
@@ -11,7 +11,13 @@ const COOKIE_OPTIONS = {
}; };
function signToken(user) { function signToken(user) {
return jwt.sign({ id: user.id, username: user.username, role: user.role, subject: user.subject || null }, SECRET, { expiresIn: '30d' }); return jwt.sign({
id: user.id,
username: user.username,
role: user.role,
subject: user.subject || null,
tv: user.token_version | 0,
}, SECRET, { expiresIn: '30d' });
} }
function verifyToken(token) { function verifyToken(token) {
@@ -27,8 +33,11 @@ function requireAuth(req, res, next) {
if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' }); if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' });
const payload = verifyToken(token); const payload = verifyToken(token);
if (!payload) return res.status(401).json({ error: 'Ungültige Sitzung' }); if (!payload) return res.status(401).json({ error: 'Ungültige Sitzung' });
const user = db.prepare('SELECT id, username, role, status, subject FROM users WHERE id = ?').get(payload.id); const user = db.prepare('SELECT id, username, role, status, subject, token_version FROM users WHERE id = ?').get(payload.id);
if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' }); if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' });
if ((payload.tv | 0) !== (user.token_version | 0)) {
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' });
}
req.user = user; req.user = user;
next(); next();
} }
+47
View File
@@ -190,6 +190,50 @@ db.exec(`
); );
`); `);
db.exec(`
CREATE TABLE IF NOT EXISTS subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_by INTEGER,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS class_timetable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id TEXT NOT NULL DEFAULT 'info1',
teacher_id INTEGER NOT NULL,
day TEXT NOT NULL,
time_start TEXT,
time_end TEXT,
subject TEXT,
room TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (teacher_id) REFERENCES users(id)
);
`);
// Seed default subjects (safe: INSERT OR IGNORE)
const DEFAULT_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges'];
const insertSubj = db.prepare('INSERT OR IGNORE INTO subjects (name) VALUES (?)');
for (const s of DEFAULT_SUBJECTS) insertSubj.run(s);
db.exec(`
CREATE TABLE IF NOT EXISTS user_subjects (
user_id INTEGER NOT NULL,
subject_name TEXT NOT NULL,
PRIMARY KEY (user_id, subject_name),
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
// Migrate existing users.subject single value into user_subjects
try {
const withSubj = db.prepare("SELECT id, subject FROM users WHERE subject IS NOT NULL AND subject != ''").all();
const ins = db.prepare('INSERT OR IGNORE INTO user_subjects (user_id, subject_name) VALUES (?,?)');
for (const u of withSubj) ins.run(u.id, u.subject);
} catch {}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS user_keys ( CREATE TABLE IF NOT EXISTS user_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -220,9 +264,12 @@ try { db.exec(`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pendin
try { db.exec(`ALTER TABLE users ADD COLUMN subject TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN subject TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN totp_secret TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN totp_secret TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN verify_token TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN verify_token TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`); } catch {}
try { db.exec(`ALTER TABLE absences ADD COLUMN teacher_id INTEGER`); } catch {}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS password_resets ( CREATE TABLE IF NOT EXISTS password_resets (
+180 -32
View File
@@ -1,5 +1,7 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { generateSecret, generateURI, verifySync } = require('otplib'); const { generateSecret, generateURI, verifySync } = require('otplib');
@@ -9,9 +11,24 @@ const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
const { deleteUserFiles } = require('./files'); const { deleteUserFiles } = require('./files');
const { sendVerifyMail, sendPasswordResetMail } = require('./mailer'); const { sendVerifyMail, sendPasswordResetMail } = require('./mailer');
const STORAGE_DIR = path.resolve(__dirname, '../storage');
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000; const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
const RESET_TTL_MS = 60 * 60 * 1000; const RESET_TTL_MS = 60 * 60 * 1000;
const DUMMY_PASSWORD_HASH = bcrypt.hashSync('dummy-placeholder-value', 12);
function generateRecoveryCodes() {
const plain = Array.from({ length: 8 }, () => {
const h = crypto.randomBytes(5).toString('hex');
return h.slice(0, 5) + '-' + h.slice(5);
});
const hashes = plain.map(c =>
crypto.createHash('sha256').update(c.replace('-', '')).digest('hex')
);
return { plain, hashes };
}
const router = express.Router(); const router = express.Router();
const loginLimiter = rateLimit({ const loginLimiter = rateLimit({
@@ -64,9 +81,26 @@ const resetPasswordLimiter = rateLimit({
legacyHeaders: false, legacyHeaders: false,
}); });
const twoFaSetupLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: (req) => String(req.user?.id ?? req.ip),
message: { error: 'Zu viele 2FA-Setup-Versuche. Bitte 15 Minuten warten.' },
standardHeaders: true,
legacyHeaders: false,
validate: { keyGeneratorIpFallback: false },
});
const verifyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
message: 'Zu viele Versuche. Bitte später erneut versuchen.',
standardHeaders: true,
legacyHeaders: false,
});
// --- AUTH --- // --- AUTH ---
const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i; const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i;
const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges'];
router.post('/register', registerLimiter, async (req, res) => { router.post('/register', registerLimiter, async (req, res) => {
const { email, password, role, subject } = req.body; const { email, password, role, subject } = req.body;
@@ -79,10 +113,15 @@ router.post('/register', registerLimiter, async (req, res) => {
let safeSubject = null; let safeSubject = null;
if (safeRole === 'teacher') { if (safeRole === 'teacher') {
if (!subject || !VALID_SUBJECTS.includes(subject)) { if (!subject || typeof subject !== 'string') {
return res.status(400).json({ error: 'Bitte ein gültiges Lehrfach auswählen' }); return res.status(400).json({ error: 'Bitte ein gültiges Lehrfach auswählen' });
} }
safeSubject = subject; const trimmed = subject.trim();
const subjRow = db.prepare('SELECT name FROM subjects WHERE name = ?').get(trimmed);
if (!subjRow) {
return res.status(400).json({ error: 'Bitte ein gültiges Lehrfach auswählen' });
}
safeSubject = subjRow.name;
} }
const hash = bcrypt.hashSync(password, 12); const hash = bcrypt.hashSync(password, 12);
@@ -96,6 +135,9 @@ router.post('/register', registerLimiter, async (req, res) => {
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
`).run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject, verifyToken, verifyExpires); `).run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject, verifyToken, verifyExpires);
userId = result.lastInsertRowid; userId = result.lastInsertRowid;
if (safeRole === 'teacher' && safeSubject) {
db.prepare('INSERT OR IGNORE INTO user_subjects (user_id, subject_name) VALUES (?, ?)').run(userId, safeSubject);
}
} catch (e) { } catch (e) {
if (e.message.includes('UNIQUE')) { if (e.message.includes('UNIQUE')) {
if (e.message.includes('email')) return res.status(409).json({ error: 'Diese E-Mail-Adresse ist bereits registriert' }); if (e.message.includes('email')) return res.status(409).json({ error: 'Diese E-Mail-Adresse ist bereits registriert' });
@@ -117,8 +159,13 @@ router.post('/register', registerLimiter, async (req, res) => {
router.post('/login', loginLimiter, (req, res) => { router.post('/login', loginLimiter, (req, res) => {
const { username, password, totp_token } = req.body; const { username, password, totp_token } = req.body;
if (!username || typeof username !== 'string' || !password || typeof password !== 'string') {
bcrypt.compareSync('x', DUMMY_PASSWORD_HASH);
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
}
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) { const passwordOk = bcrypt.compareSync(password, user ? user.password_hash : DUMMY_PASSWORD_HASH);
if (!user || !passwordOk) {
return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' }); return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' });
} }
if (!user.email_verified) { if (!user.email_verified) {
@@ -139,7 +186,15 @@ router.post('/login', loginLimiter, (req, res) => {
if (user.totp_enabled) { if (user.totp_enabled) {
if (!totp_token) return res.json({ requireTotp: true }); if (!totp_token) return res.json({ requireTotp: true });
const totpResult = verifySync({ token: String(totp_token), secret: user.totp_secret }); const totpResult = verifySync({ token: String(totp_token), secret: user.totp_secret });
if (!totpResult || !totpResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' }); if (!totpResult || !totpResult.valid) {
const codes = JSON.parse(user.totp_recovery_codes || '[]');
const normalized = String(totp_token).toLowerCase().replace(/[^a-f0-9]/g, '');
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
const idx = codes.indexOf(hash);
if (idx === -1) return res.status(401).json({ error: 'Ungültiger 2FA-Code' });
codes.splice(idx, 1);
db.prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?').run(JSON.stringify(codes), user.id);
}
} }
const token = signToken(user); const token = signToken(user);
res.cookie('token', token, COOKIE_OPTIONS); res.cookie('token', token, COOKIE_OPTIONS);
@@ -151,7 +206,7 @@ router.post('/logout', (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
router.get('/verify', (req, res) => { router.get('/verify', verifyLimiter, (req, res) => {
const { token } = req.query; const { token } = req.query;
const fail = (msg) => res.status(400).send(` const fail = (msg) => res.status(400).send(`
<!doctype html><meta charset="utf-8"><title>Verifizierung</title> <!doctype html><meta charset="utf-8"><title>Verifizierung</title>
@@ -202,7 +257,7 @@ router.post('/reset-password', resetPasswordLimiter, (req, res) => {
if (new Date(row.expires_at) < new Date()) return res.status(400).json({ error: 'Link abgelaufen. Bitte neu anfordern.' }); if (new Date(row.expires_at) < new Date()) return res.status(400).json({ error: 'Link abgelaufen. Bitte neu anfordern.' });
const hash = bcrypt.hashSync(newPassword, 12); const hash = bcrypt.hashSync(newPassword, 12);
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, row.user_id); db.prepare('UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?').run(hash, row.user_id);
db.prepare('UPDATE password_resets SET used_at = datetime("now") WHERE id = ?').run(row.id); db.prepare('UPDATE password_resets SET used_at = datetime("now") WHERE id = ?').run(row.id);
db.prepare('UPDATE password_resets SET used_at = datetime("now") WHERE user_id = ? AND used_at IS NULL AND id != ?').run(row.user_id, row.id); db.prepare('UPDATE password_resets SET used_at = datetime("now") WHERE user_id = ? AND used_at IS NULL AND id != ?').run(row.user_id, row.id);
}); });
@@ -243,20 +298,52 @@ router.put('/me/password', requireAuth, passwordChangeLimiter, (req, res) => {
return res.status(401).json({ error: 'Aktuelles Passwort falsch' }); return res.status(401).json({ error: 'Aktuelles Passwort falsch' });
} }
const hash = bcrypt.hashSync(newPassword, 12); const hash = bcrypt.hashSync(newPassword, 12);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id); db.prepare('UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?').run(hash, req.user.id);
const refreshed = db.prepare('SELECT id, username, role, subject, token_version FROM users WHERE id = ?').get(req.user.id);
res.cookie('token', signToken(refreshed), COOKIE_OPTIONS);
res.json({ ok: true }); res.json({ ok: true });
}); });
function purgeUser(userId) {
const teacherMats = db.prepare('SELECT stored_name FROM teacher_materials WHERE teacher_id = ?').all(userId);
const userTables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','chat_messages','user_subjects','user_keys','password_resets'];
userTables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(userId));
db.prepare('DELETE FROM teacher_materials WHERE teacher_id = ?').run(userId);
db.prepare('DELETE FROM teacher_announcements WHERE teacher_id = ?').run(userId);
db.prepare('DELETE FROM teacher_exams WHERE teacher_id = ?').run(userId);
db.prepare('DELETE FROM teacher_assigned_grades WHERE teacher_id = ? OR student_id = ?').run(userId, userId);
db.prepare('DELETE FROM class_timetable WHERE teacher_id = ?').run(userId);
db.prepare('DELETE FROM ticket_messages WHERE sender_id = ?').run(userId);
db.prepare('DELETE FROM ticket_messages WHERE ticket_id IN (SELECT id FROM support_tickets WHERE user_id = ?)').run(userId);
db.prepare('DELETE FROM support_tickets WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM group_sender_keys WHERE recipient_user_id = ? OR distributor_user_id = ?').run(userId, userId);
db.prepare('UPDATE subjects SET created_by = NULL WHERE created_by = ?').run(userId);
db.prepare('UPDATE class_events SET created_by = NULL WHERE created_by = ?').run(userId);
deleteUserFiles(userId);
teacherMats.forEach(m => fs.unlink(path.join(STORAGE_DIR, m.stored_name), () => {}));
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
}
function countActiveAdmins() {
return db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND status = 'active'").get().c;
}
router.delete('/me', requireAuth, (req, res) => { router.delete('/me', requireAuth, (req, res) => {
const { password } = req.body; const { password } = req.body || {};
if (!password || typeof password !== 'string') return res.status(400).json({ error: 'Passwort erforderlich' });
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(password, user.password_hash)) { if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Passwort falsch' }); return res.status(401).json({ error: 'Passwort falsch' });
} }
const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','chat_messages']; if (user.role === 'admin' && countActiveAdmins() <= 1) {
tables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(req.user.id)); return res.status(400).json({ error: 'Letztes Admin-Konto kann nicht gelöscht werden' });
deleteUserFiles(req.user.id); }
db.prepare('DELETE FROM users WHERE id = ?').run(req.user.id); try {
db.transaction(() => purgeUser(req.user.id))();
} catch (e) {
console.error('DELETE /me failed:', e);
return res.status(500).json({ error: 'Kontolöschung fehlgeschlagen' });
}
res.clearCookie('token'); res.clearCookie('token');
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -312,9 +399,18 @@ router.patch('/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
if (email_verified !== undefined && !hasEmailVerified) return res.status(400).json({ error: 'Ungültiger email_verified-Wert' }); if (email_verified !== undefined && !hasEmailVerified) return res.status(400).json({ error: 'Ungültiger email_verified-Wert' });
if (!role && !status && !hasEmailVerified) return res.status(400).json({ error: 'Keine Änderung angegeben' }); if (!role && !status && !hasEmailVerified) return res.status(400).json({ error: 'Keine Änderung angegeben' });
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); const target = db.prepare('SELECT id, role, status FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
const wasActiveAdmin = target.role === 'admin' && target.status === 'active';
const willBecomeNonActiveAdmin = wasActiveAdmin && (
(role && role !== 'admin') ||
(status && status !== 'active')
);
if (willBecomeNonActiveAdmin && countActiveAdmins() <= 1) {
return res.status(400).json({ error: 'Letzter aktiver Admin kann nicht demoted/gesperrt werden' });
}
if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id); 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); if (status) db.prepare('UPDATE users SET status = ? WHERE id = ?').run(status, req.params.id);
if (hasEmailVerified) { if (hasEmailVerified) {
@@ -329,14 +425,17 @@ router.patch('/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
}); });
router.delete('/admin/users/:id', requireAuth, requireAdmin, (req, res) => { router.delete('/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); const targetId = Number(req.params.id);
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(targetId);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); 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' }); if (targetId === 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']; try {
tables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(req.params.id)); db.transaction(() => purgeUser(targetId))();
deleteUserFiles(Number(req.params.id)); } catch (e) {
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); console.error('DELETE /admin/users/:id failed:', e);
logAdmin(req.user.id, 'user_delete', Number(req.params.id)); return res.status(500).json({ error: 'Löschen fehlgeschlagen' });
}
logAdmin(req.user.id, 'user_delete', targetId);
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -380,7 +479,6 @@ router.get('/admin/usage', requireAuth, requireAdmin, (req, res) => {
const usage = users.map(u => { const usage = users.map(u => {
const counts = {}; const counts = {};
tables.forEach(t => { 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; counts[t] = db.prepare(`SELECT COUNT(*) AS c FROM ${t} WHERE user_id = ?`).get(u.id).c;
}); });
return { ...u, counts }; return { ...u, counts };
@@ -451,11 +549,23 @@ router.get('/class-events', (req, res) => {
res.json(db.prepare('SELECT * FROM class_events ORDER BY date ASC').all()); res.json(db.prepare('SELECT * FROM class_events ORDER BY date ASC').all());
}); });
const VALID_EVENT_TYPES = ['pruefung','ferien','termin','sonstiges'];
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
router.post('/class-events', requireAuth, (req, res) => { router.post('/class-events', requireAuth, (req, res) => {
const { type, title, date, date_end, description } = req.body; const { type, title, date, date_end, description } = req.body;
if (!type || !title) return res.status(400).json({ error: 'Typ und Titel erforderlich' }); if (!type || !title) return res.status(400).json({ error: 'Typ und Titel erforderlich' });
if (!VALID_EVENT_TYPES.includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' });
const safeTitle = typeof title === 'string' ? title.trim().slice(0, 200) : '';
if (!safeTitle) return res.status(400).json({ error: 'Titel darf nicht leer sein' });
const safeDate = (typeof date === 'string' && ISO_DATE_RE.test(date)) ? date : null;
const safeDateEnd = (typeof date_end === 'string' && ISO_DATE_RE.test(date_end)) ? date_end : null;
if (safeDate && safeDateEnd && safeDateEnd < safeDate) {
return res.status(400).json({ error: 'Enddatum liegt vor Startdatum' });
}
const safeDesc = typeof description === 'string' ? description.trim().slice(0, 2000) : null;
const result = db.prepare('INSERT INTO class_events (type, title, date, date_end, description, created_by) VALUES (?,?,?,?,?,?)') 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); .run(type, safeTitle, safeDate, safeDateEnd, safeDesc, req.user.id);
res.json({ id: result.lastInsertRowid }); res.json({ id: result.lastInsertRowid });
}); });
@@ -604,8 +714,12 @@ router.post('/e2ee/group-keys', requireAuth, (req, res) => {
return res.status(400).json({ error: 'Ungültige Anfrage' }); return res.status(400).json({ error: 'Ungültige Anfrage' });
} }
if (keys.length > 500) return res.status(400).json({ error: 'Zu viele Einträge' }); if (keys.length > 500) return res.status(400).json({ error: 'Zu viele Einträge' });
const existing = db.prepare('SELECT distributor_user_id FROM group_sender_keys WHERE group_id = ? AND kid = ? LIMIT 1').get(group_id, kid);
if (existing && existing.distributor_user_id !== req.user.id) {
return res.status(403).json({ error: 'Schlüssel bereits von anderem Nutzer verteilt' });
}
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT OR REPLACE INTO group_sender_keys INSERT OR IGNORE INTO group_sender_keys
(group_id, kid, recipient_user_id, distributor_user_id, encrypted_key) (group_id, kid, recipient_user_id, distributor_user_id, encrypted_key)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`); `);
@@ -622,9 +736,17 @@ router.post('/e2ee/group-keys', requireAuth, (req, res) => {
}); });
// --- 2FA --- // --- 2FA ---
router.post('/2fa/setup', requireAuth, async (req, res) => { router.post('/2fa/setup', requireAuth, twoFaSetupLimiter, async (req, res) => {
const { password } = req.body || {};
if (!password || typeof password !== 'string') return res.status(400).json({ error: 'Passwort erforderlich' });
const user = db.prepare('SELECT username, email, password_hash, totp_enabled FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Passwort falsch' });
}
if (user.totp_enabled) {
return res.status(409).json({ error: '2FA ist bereits aktiv. Bitte zuerst deaktivieren.' });
}
const secret = generateSecret(); const secret = generateSecret();
const user = db.prepare('SELECT username, email FROM users WHERE id = ?').get(req.user.id);
db.prepare('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?').run(secret, req.user.id); db.prepare('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?').run(secret, req.user.id);
const otpauth = generateURI({ secret, label: user.email, issuer: 'INFO1', type: 'totp' }); const otpauth = generateURI({ secret, label: user.email, issuer: 'INFO1', type: 'totp' });
try { try {
@@ -641,8 +763,10 @@ router.post('/2fa/confirm', requireAuth, loginLimiter, (req, res) => {
if (!user.totp_secret) return res.status(400).json({ error: '2FA-Setup nicht gestartet' }); if (!user.totp_secret) return res.status(400).json({ error: '2FA-Setup nicht gestartet' });
const result = verifySync({ token: String(token), secret: user.totp_secret }); const result = verifySync({ token: String(token), secret: user.totp_secret });
if (!result || !result.valid) return res.status(401).json({ error: 'Ungültiger Code' }); if (!result || !result.valid) return res.status(401).json({ error: 'Ungültiger Code' });
db.prepare('UPDATE users SET totp_enabled = 1 WHERE id = ?').run(req.user.id); const { plain, hashes } = generateRecoveryCodes();
res.json({ ok: true }); db.prepare('UPDATE users SET totp_enabled = 1, totp_recovery_codes = ? WHERE id = ?')
.run(JSON.stringify(hashes), req.user.id);
res.json({ ok: true, codes: plain });
}); });
router.post('/2fa/disable', requireAuth, loginLimiter, (req, res) => { router.post('/2fa/disable', requireAuth, loginLimiter, (req, res) => {
@@ -656,13 +780,28 @@ router.post('/2fa/disable', requireAuth, loginLimiter, (req, res) => {
} }
const disableResult = verifySync({ token: String(token), secret: user.totp_secret }); const disableResult = verifySync({ token: String(token), secret: user.totp_secret });
if (!disableResult || !disableResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' }); if (!disableResult || !disableResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' });
db.prepare('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?').run(req.user.id); db.prepare('UPDATE users SET totp_secret = NULL, totp_enabled = 0, totp_recovery_codes = NULL WHERE id = ?').run(req.user.id);
res.json({ ok: true }); res.json({ ok: true });
}); });
router.get('/2fa/status', requireAuth, (req, res) => { router.get('/2fa/status', requireAuth, (req, res) => {
const user = db.prepare('SELECT totp_enabled FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT totp_enabled, totp_recovery_codes FROM users WHERE id = ?').get(req.user.id);
res.json({ enabled: !!user.totp_enabled }); const codes = JSON.parse(user.totp_recovery_codes || '[]');
res.json({ enabled: !!user.totp_enabled, recovery_codes_remaining: codes.length });
});
router.post('/2fa/regenerate-codes', requireAuth, loginLimiter, (req, res) => {
const { token, password } = req.body;
if (!token) return res.status(400).json({ error: 'Code erforderlich' });
if (!password || typeof password !== 'string') return res.status(400).json({ error: 'Passwort erforderlich' });
const user = db.prepare('SELECT totp_secret, totp_enabled, password_hash FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(password, user.password_hash)) return res.status(401).json({ error: 'Passwort falsch' });
if (!user.totp_enabled) return res.status(400).json({ error: '2FA nicht aktiv' });
const result = verifySync({ token: String(token), secret: user.totp_secret });
if (!result || !result.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' });
const { plain, hashes } = generateRecoveryCodes();
db.prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?').run(JSON.stringify(hashes), req.user.id);
res.json({ ok: true, codes: plain });
}); });
// --- PERSONAL CRUD --- // --- PERSONAL CRUD ---
@@ -699,9 +838,18 @@ function crudRoutes(path, table, fields) {
crudRoutes('timetable', 'timetable', ['day', 'time_start', 'time_end', 'subject', 'room', 'teacher']); crudRoutes('timetable', 'timetable', ['day', 'time_start', 'time_end', 'subject', 'room', 'teacher']);
crudRoutes('homework', 'homework', ['subject', 'title', 'due_date', 'done']); crudRoutes('homework', 'homework', ['subject', 'title', 'due_date', 'done']);
crudRoutes('grades', 'grades', ['subject', 'grade', 'type', 'note']); crudRoutes('grades', 'grades', ['subject', 'grade', 'type', 'note']);
crudRoutes('absences', 'absences', ['date', 'subject', 'reason']);
crudRoutes('todos', 'todos', ['title', 'done']); crudRoutes('todos', 'todos', ['title', 'done']);
crudRoutes('countdowns', 'countdowns', ['title', 'target_date']); crudRoutes('countdowns', 'countdowns', ['title', 'target_date']);
crudRoutes('quicklinks', 'quicklinks', ['label', 'url']); crudRoutes('quicklinks', 'quicklinks', ['label', 'url']);
// Student read-only view of their own absences
router.get('/absences', requireAuth, (req, res) => {
res.json(db.prepare('SELECT * FROM absences WHERE user_id = ? ORDER BY date DESC').all(req.user.id));
});
// Subjects list (public — needed by register form)
router.get('/subjects', (req, res) => {
res.json(db.prepare('SELECT id, name FROM subjects ORDER BY name ASC').all());
});
module.exports = router; module.exports = router;
+263 -11
View File
@@ -8,19 +8,34 @@ const { requireAuth } = require('./auth');
const router = express.Router(); const router = express.Router();
const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges'];
const VALID_TYPES = ['schulaufgabe','kurzarbeit','stegreifaufgabe','muendlich','sonstiges']; const VALID_TYPES = ['schulaufgabe','kurzarbeit','stegreifaufgabe','muendlich','sonstiges'];
const CLASS_IDS = ['info1']; const CLASS_IDS = ['info1'];
function requireTeacher(req, res, next) { function requireTeacher(req, res, next) {
if (req.user.role !== 'teacher') return res.status(403).json({ error: 'Nur für Lehrkräfte' }); if (req.user.role !== 'teacher') return res.status(403).json({ error: 'Nur für Lehrkräfte' });
const user = db.prepare('SELECT status, subject FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT status FROM users WHERE id = ?').get(req.user.id);
if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' }); if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' });
if (!user.subject) return res.status(403).json({ error: 'Kein Lehrfach zugewiesen' }); const subjects = db.prepare('SELECT subject_name FROM user_subjects WHERE user_id = ?').all(req.user.id).map(r => r.subject_name);
req.teacher = user; req.teacher = { subjects };
next(); next();
} }
function getTeacherSubject(req, res, subjectFromBody) {
const { subjects } = req.teacher;
if (subjectFromBody && subjects.includes(subjectFromBody)) return subjectFromBody;
if (subjects.length === 1) return subjects[0];
if (subjects.length > 1 && !subjectFromBody) {
res.status(400).json({ error: 'Bitte Fach angeben' });
return null;
}
if (!subjects.length) {
res.status(400).json({ error: 'Kein Lehrfach zugewiesen. Bitte in Einstellungen hinzufügen.' });
return null;
}
res.status(400).json({ error: 'Ungültiges Fach' });
return null;
}
const STORAGE_DIR = path.resolve(__dirname, '../storage'); const STORAGE_DIR = path.resolve(__dirname, '../storage');
fs.mkdirSync(STORAGE_DIR, { recursive: true }); fs.mkdirSync(STORAGE_DIR, { recursive: true });
@@ -108,9 +123,11 @@ router.post('/materials', requireAuth, requireTeacher, (req, res) => {
const ext = path.extname(req.file.originalname).toLowerCase().slice(1); const ext = path.extname(req.file.originalname).toLowerCase().slice(1);
const mime = EXT_MIME[ext] || 'application/octet-stream'; const mime = EXT_MIME[ext] || 'application/octet-stream';
const subject = getTeacherSubject(req, res, typeof req.body.subject === 'string' ? req.body.subject.trim() : null);
if (!subject) { fs.unlink(req.file.path, () => {}); return; }
const result = db.prepare( const result = db.prepare(
'INSERT INTO teacher_materials (teacher_id, subject, class_id, title, original_name, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO teacher_materials (teacher_id, subject, class_id, title, original_name, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(req.user.id, req.teacher.subject, safeClass, title, req.file.originalname, req.file.filename, mime, req.file.size); ).run(req.user.id, subject, safeClass, title, req.file.originalname, req.file.filename, mime, req.file.size);
res.json({ id: result.lastInsertRowid, title, original_name: req.file.originalname, mime_type: mime, size: req.file.size }); res.json({ id: result.lastInsertRowid, title, original_name: req.file.originalname, mime_type: mime, size: req.file.size });
}); });
@@ -158,7 +175,7 @@ router.get('/announcements', requireAuth, requireTeacher, (req, res) => {
}); });
router.post('/announcements', requireAuth, requireTeacher, (req, res) => { router.post('/announcements', requireAuth, requireTeacher, (req, res) => {
const { title, content, class_id } = req.body; const { title, content, class_id, subject } = req.body;
if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' }); if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' });
if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Inhalt erforderlich' }); if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Inhalt erforderlich' });
const safeTitle = title.trim(); const safeTitle = title.trim();
@@ -166,9 +183,11 @@ router.post('/announcements', requireAuth, requireTeacher, (req, res) => {
if (!safeTitle || safeTitle.length > 200) return res.status(400).json({ error: 'Titel ungültig (max. 200 Zeichen)' }); if (!safeTitle || safeTitle.length > 200) return res.status(400).json({ error: 'Titel ungültig (max. 200 Zeichen)' });
if (!safeContent || safeContent.length > 5000) return res.status(400).json({ error: 'Inhalt zu lang (max. 5000 Zeichen)' }); if (!safeContent || safeContent.length > 5000) return res.status(400).json({ error: 'Inhalt zu lang (max. 5000 Zeichen)' });
const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1'; const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1';
const safeSubj = getTeacherSubject(req, res, typeof subject === 'string' ? subject.trim() : null);
if (!safeSubj) return;
const result = db.prepare( const result = db.prepare(
'INSERT INTO teacher_announcements (teacher_id, subject, class_id, title, content) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO teacher_announcements (teacher_id, subject, class_id, title, content) VALUES (?, ?, ?, ?, ?)'
).run(req.user.id, req.teacher.subject, safeClass, safeTitle, safeContent); ).run(req.user.id, safeSubj, safeClass, safeTitle, safeContent);
res.json({ id: result.lastInsertRowid }); res.json({ id: result.lastInsertRowid });
}); });
@@ -199,16 +218,18 @@ router.get('/exams', requireAuth, requireTeacher, (req, res) => {
}); });
router.post('/exams', requireAuth, requireTeacher, (req, res) => { router.post('/exams', requireAuth, requireTeacher, (req, res) => {
const { title, date, description, class_id } = req.body; const { title, date, description, class_id, subject } = req.body;
if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' }); if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' });
const safeTitle = title.trim(); const safeTitle = title.trim();
if (!safeTitle || safeTitle.length > 200) return res.status(400).json({ error: 'Titel ungültig (max. 200 Zeichen)' }); if (!safeTitle || safeTitle.length > 200) return res.status(400).json({ error: 'Titel ungültig (max. 200 Zeichen)' });
const safeDate = date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null; const safeDate = date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
const safeDesc = description && typeof description === 'string' ? description.trim().slice(0, 1000) : null; const safeDesc = description && typeof description === 'string' ? description.trim().slice(0, 1000) : null;
const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1'; const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1';
const safeSubj = getTeacherSubject(req, res, typeof subject === 'string' ? subject.trim() : null);
if (!safeSubj) return;
const result = db.prepare( const result = db.prepare(
'INSERT INTO teacher_exams (teacher_id, subject, class_id, title, date, description) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO teacher_exams (teacher_id, subject, class_id, title, date, description) VALUES (?, ?, ?, ?, ?, ?)'
).run(req.user.id, req.teacher.subject, safeClass, safeTitle, safeDate, safeDesc); ).run(req.user.id, safeSubj, safeClass, safeTitle, safeDate, safeDesc);
res.json({ id: result.lastInsertRowid }); res.json({ id: result.lastInsertRowid });
}); });
@@ -241,7 +262,7 @@ router.get('/grades', requireAuth, requireTeacher, (req, res) => {
}); });
router.post('/grades', requireAuth, requireTeacher, (req, res) => { router.post('/grades', requireAuth, requireTeacher, (req, res) => {
const { student_id, grade, type, note } = req.body; const { student_id, grade, type, note, subject } = req.body;
if (!student_id) return res.status(400).json({ error: 'Schüler erforderlich' }); if (!student_id) return res.status(400).json({ error: 'Schüler erforderlich' });
const gradeNum = parseFloat(grade); const gradeNum = parseFloat(grade);
if (isNaN(gradeNum) || gradeNum < 1 || gradeNum > 6) return res.status(400).json({ error: 'Note muss zwischen 1 und 6 liegen' }); if (isNaN(gradeNum) || gradeNum < 1 || gradeNum > 6) return res.status(400).json({ error: 'Note muss zwischen 1 und 6 liegen' });
@@ -249,9 +270,11 @@ router.post('/grades', requireAuth, requireTeacher, (req, res) => {
const safeNote = note && typeof note === 'string' ? note.trim().slice(0, 500) : null; const safeNote = note && typeof note === 'string' ? note.trim().slice(0, 500) : null;
const student = db.prepare("SELECT id, role FROM users WHERE id = ? AND status = 'active'").get(parseInt(student_id, 10)); const student = db.prepare("SELECT id, role FROM users WHERE id = ? AND status = 'active'").get(parseInt(student_id, 10));
if (!student || student.role !== 'student') return res.status(404).json({ error: 'Schüler nicht gefunden' }); if (!student || student.role !== 'student') return res.status(404).json({ error: 'Schüler nicht gefunden' });
const safeSubj = getTeacherSubject(req, res, typeof subject === 'string' ? subject.trim() : null);
if (!safeSubj) return;
const result = db.prepare( const result = db.prepare(
'INSERT INTO teacher_assigned_grades (teacher_id, student_id, subject, grade, type, note) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO teacher_assigned_grades (teacher_id, student_id, subject, grade, type, note) VALUES (?, ?, ?, ?, ?, ?)'
).run(req.user.id, student.id, req.teacher.subject, gradeNum, safeType, safeNote); ).run(req.user.id, student.id, safeSubj, gradeNum, safeType, safeNote);
res.json({ id: result.lastInsertRowid }); res.json({ id: result.lastInsertRowid });
}); });
@@ -268,6 +291,235 @@ router.get('/students', requireAuth, requireTeacher, (req, res) => {
res.json(db.prepare("SELECT id, username FROM users WHERE role = 'student' AND status = 'active' ORDER BY username").all()); res.json(db.prepare("SELECT id, username FROM users WHERE role = 'student' AND status = 'active' ORDER BY username").all());
}); });
// ── SUBJECTS ───────────────────────────────────────────────────
router.get('/my-subjects', requireAuth, requireTeacher, (req, res) => {
res.json(req.teacher.subjects);
});
// Create new subject globally + add to teacher's list
router.post('/subjects', requireAuth, requireTeacher, (req, res) => {
const { name } = req.body;
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'Name erforderlich' });
const safe = name.trim().slice(0, 60);
if (!safe) return res.status(400).json({ error: 'Name darf nicht leer sein' });
try {
db.prepare('INSERT OR IGNORE INTO subjects (name, created_by) VALUES (?, ?)').run(safe, req.user.id);
db.prepare('INSERT OR IGNORE INTO user_subjects (user_id, subject_name) VALUES (?,?)').run(req.user.id, safe);
res.json({ ok: true, name: safe });
} catch (e) {
res.status(500).json({ error: 'Serverfehler' });
}
});
// Add existing subject to teacher's list
router.post('/my-subjects', requireAuth, requireTeacher, (req, res) => {
const { name } = req.body;
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'Name erforderlich' });
const safe = name.trim().slice(0, 60);
const exists = db.prepare('SELECT id FROM subjects WHERE name = ?').get(safe);
if (!exists) return res.status(404).json({ error: 'Fach nicht gefunden' });
db.prepare('INSERT OR IGNORE INTO user_subjects (user_id, subject_name) VALUES (?,?)').run(req.user.id, safe);
res.json({ ok: true });
});
// Remove subject from teacher's list
router.delete('/my-subjects/:name', requireAuth, requireTeacher, (req, res) => {
const name = decodeURIComponent(req.params.name);
db.prepare('DELETE FROM user_subjects WHERE user_id = ? AND subject_name = ?').run(req.user.id, name);
res.json({ ok: true });
});
// ── CLASS TIMETABLE ────────────────────────────────────────────
router.get('/class-timetable', requireAuth, (req, res) => {
const classId = CLASS_IDS.includes(req.query.class_id) ? req.query.class_id : 'info1';
res.json(db.prepare(`
SELECT t.id, t.day, t.time_start, t.time_end, t.subject, t.room, t.class_id,
u.username AS teacher_name, t.teacher_id
FROM class_timetable t JOIN users u ON u.id = t.teacher_id
WHERE t.class_id = ? ORDER BY t.day, t.time_start
`).all(classId));
});
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
router.post('/class-timetable', requireAuth, requireTeacher, (req, res) => {
const { day, time_start, time_end, subject, room, class_id } = req.body;
if (!day || !time_start) return res.status(400).json({ error: 'Tag und Uhrzeit erforderlich' });
const VALID_DAYS = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag'];
if (!VALID_DAYS.includes(day)) return res.status(400).json({ error: 'Ungültiger Tag' });
if (typeof time_start !== 'string' || !HHMM_RE.test(time_start)) return res.status(400).json({ error: 'Ungültige Startzeit' });
const safeEnd = (time_end && typeof time_end === 'string' && HHMM_RE.test(time_end)) ? time_end : null;
if (safeEnd && safeEnd <= time_start) return res.status(400).json({ error: 'Endzeit muss nach Startzeit liegen' });
const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1';
const safeSubj = getTeacherSubject(req, res, typeof subject === 'string' ? subject.trim() : null);
if (!safeSubj) return;
const safeRoom = (room && typeof room === 'string') ? room.trim().slice(0, 60) : null;
const conflict = db.prepare(`
SELECT id FROM class_timetable
WHERE class_id = ? AND day = ?
AND time_start < COALESCE(?, time(time_start,'+1 minute'))
AND COALESCE(time_end, time(time_start,'+1 minute')) > ?
`).get(safeClass, day, safeEnd, time_start);
if (conflict) return res.status(409).json({ error: 'Zeitslot überschneidet sich mit einem bestehenden Eintrag' });
const r = db.prepare(
'INSERT INTO class_timetable (class_id, teacher_id, day, time_start, time_end, subject, room) VALUES (?,?,?,?,?,?,?)'
).run(safeClass, req.user.id, day, time_start, safeEnd, safeSubj, safeRoom);
res.json({ id: r.lastInsertRowid });
});
router.put('/class-timetable/:id', requireAuth, requireTeacher, (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
const entry = db.prepare('SELECT id, teacher_id, class_id, day, time_start FROM class_timetable WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden' });
if (entry.teacher_id !== req.user.id) return res.status(403).json({ error: 'Keine Berechtigung' });
const { subject, room, time_end } = req.body;
let safeSubj = null;
if (subject !== undefined) {
const trimmed = (typeof subject === 'string') ? subject.trim() : '';
if (!trimmed || !req.teacher.subjects.includes(trimmed)) {
return res.status(400).json({ error: 'Ungültiges Fach' });
}
safeSubj = trimmed;
}
let safeRoom;
if (room !== undefined) {
safeRoom = (typeof room === 'string') ? room.trim().slice(0, 60) : null;
}
let safeEnd;
let endProvided = false;
if (time_end !== undefined) {
endProvided = true;
if (time_end === null || time_end === '') {
safeEnd = null;
} else if (typeof time_end !== 'string' || !HHMM_RE.test(time_end)) {
return res.status(400).json({ error: 'Ungültige Endzeit' });
} else if (time_end <= entry.time_start) {
return res.status(400).json({ error: 'Endzeit muss nach Startzeit liegen' });
} else {
safeEnd = time_end;
}
const conflict = db.prepare(`
SELECT id FROM class_timetable
WHERE class_id = ? AND day = ? AND id != ?
AND time_start < COALESCE(?, time(time_start,'+1 minute'))
AND COALESCE(time_end, time(time_start,'+1 minute')) > ?
`).get(entry.class_id, entry.day, id, safeEnd, entry.time_start);
if (conflict) return res.status(409).json({ error: 'Zeitslot überschneidet sich mit einem bestehenden Eintrag' });
}
if (safeSubj !== null) db.prepare('UPDATE class_timetable SET subject = ? WHERE id = ?').run(safeSubj, id);
if (safeRoom !== undefined) db.prepare('UPDATE class_timetable SET room = ? WHERE id = ?').run(safeRoom, id);
if (endProvided) db.prepare('UPDATE class_timetable SET time_end = ? WHERE id = ?').run(safeEnd, id);
res.json({ ok: true });
});
router.delete('/class-timetable/:id', requireAuth, requireTeacher, (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
const entry = db.prepare('SELECT id, teacher_id FROM class_timetable WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden' });
if (entry.teacher_id !== req.user.id) return res.status(403).json({ error: 'Keine Berechtigung' });
db.prepare('DELETE FROM class_timetable WHERE id = ?').run(id);
res.json({ ok: true });
});
// ── STUDENT ABSENCES (teacher-managed) ────────────────────────
router.get('/absences', requireAuth, requireTeacher, (req, res) => {
res.json(db.prepare(`
SELECT a.id, a.date, a.subject, a.reason, a.user_id, a.teacher_id,
u.username AS student_name
FROM absences a JOIN users u ON u.id = a.user_id
ORDER BY a.date DESC
`).all());
});
router.post('/absences', requireAuth, requireTeacher, (req, res) => {
const { student_id, date, subject, reason } = req.body;
if (!student_id) return res.status(400).json({ error: 'Schüler erforderlich' });
const student = db.prepare("SELECT id, role FROM users WHERE id = ? AND status = 'active'").get(parseInt(student_id, 10));
if (!student || student.role !== 'student') return res.status(404).json({ error: 'Schüler nicht gefunden' });
const safeDate = (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) ? date : null;
const safeSubj = (subject && typeof subject === 'string') ? subject.trim().slice(0, 200) : null;
const safeReason = (reason && typeof reason === 'string') ? reason.trim().slice(0, 500) : null;
const r = db.prepare(
'INSERT INTO absences (user_id, teacher_id, date, subject, reason) VALUES (?,?,?,?,?)'
).run(student.id, req.user.id, safeDate, safeSubj, safeReason);
res.json({ id: r.lastInsertRowid });
});
router.delete('/absences/:id', requireAuth, requireTeacher, (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
const ab = db.prepare('SELECT id, teacher_id FROM absences WHERE id = ?').get(id);
if (!ab) return res.status(404).json({ error: 'Eintrag nicht gefunden' });
if (ab.teacher_id !== req.user.id) return res.status(403).json({ error: 'Keine Berechtigung' });
db.prepare('DELETE FROM absences WHERE id = ?').run(id);
res.json({ ok: true });
});
// ── PUT ENDPOINTS FOR EXISTING RESOURCES ───────────────────────
router.put('/materials/:id', requireAuth, requireTeacher, (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
const mat = db.prepare('SELECT id FROM teacher_materials WHERE id = ? AND teacher_id = ?').get(id, req.user.id);
if (!mat) return res.status(404).json({ error: 'Material nicht gefunden' });
const { title } = req.body;
if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' });
const safe = title.trim().slice(0, 200);
if (!safe) return res.status(400).json({ error: 'Titel darf nicht leer sein' });
db.prepare('UPDATE teacher_materials SET title = ? WHERE id = ?').run(safe, id);
res.json({ ok: true });
});
router.put('/announcements/:id', requireAuth, requireTeacher, (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
const ann = db.prepare('SELECT id FROM teacher_announcements WHERE id = ? AND teacher_id = ?').get(id, req.user.id);
if (!ann) return res.status(404).json({ error: 'Ankündigung nicht gefunden' });
const { title, content } = req.body;
if (title !== undefined) {
const safe = typeof title === 'string' ? title.trim().slice(0, 200) : '';
if (!safe) return res.status(400).json({ error: 'Titel darf nicht leer sein' });
db.prepare('UPDATE teacher_announcements SET title = ? WHERE id = ?').run(safe, id);
}
if (content !== undefined) {
const safe = typeof content === 'string' ? content.trim().slice(0, 5000) : '';
if (!safe) return res.status(400).json({ error: 'Inhalt darf nicht leer sein' });
db.prepare('UPDATE teacher_announcements SET content = ? WHERE id = ?').run(safe, id);
}
res.json({ ok: true });
});
router.put('/exams/:id', requireAuth, requireTeacher, (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Ungültige ID' });
const exam = db.prepare('SELECT id FROM teacher_exams WHERE id = ? AND teacher_id = ?').get(id, req.user.id);
if (!exam) return res.status(404).json({ error: 'Prüfung nicht gefunden' });
const { title, date, description } = req.body;
if (title !== undefined) {
const safe = typeof title === 'string' ? title.trim().slice(0, 200) : '';
if (!safe) return res.status(400).json({ error: 'Titel darf nicht leer sein' });
db.prepare('UPDATE teacher_exams SET title = ? WHERE id = ?').run(safe, id);
}
if (date !== undefined) {
const safe = (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) ? date : null;
db.prepare('UPDATE teacher_exams SET date = ? WHERE id = ?').run(safe, id);
}
if (description !== undefined) {
const safe = typeof description === 'string' ? description.trim().slice(0, 1000) : null;
db.prepare('UPDATE teacher_exams SET description = ? WHERE id = ?').run(safe, id);
}
res.json({ ok: true });
});
router.get('/my-grades', requireAuth, (req, res) => { router.get('/my-grades', requireAuth, (req, res) => {
if (req.user.role !== 'student') return res.status(403).json({ error: 'Nur für Schüler' }); if (req.user.role !== 'student') return res.status(403).json({ error: 'Nur für Schüler' });
res.json(db.prepare(` res.json(db.prepare(`