diff --git a/public/app.html b/public/app.html index b1b566d..adc0f7f 100644 --- a/public/app.html +++ b/public/app.html @@ -1064,7 +1064,7 @@ footer { - @@ -1124,11 +1124,24 @@ footer {
Fehlzeiten
-
-
+
+ + +
+
-
-
Materialien
+
+
Fächer
+
Materialien
Ankündigungen
Prüfungen
Noten
+
Stundenplan
+
Fehlzeiten
-
+
+

Wähle die Fächer aus, die du unterrichtest. Diese erscheinen dann als Auswahlmöglichkeit in allen anderen Bereichen.

+
+
+ + +
+
+ + +
+
+ +
- + +
Keine Materialien hochgeladen
@@ -1207,7 +1239,10 @@ footer {
- +
+ + +
@@ -1216,7 +1251,10 @@ footer {
- +
+ + +
@@ -1228,6 +1266,7 @@ footer {
+ @@ -1244,9 +1283,40 @@ footer {
Keine Noten vergeben
+ +
+
+ + + + + +
+
Keine Einträge
+
+ +
+
+ + + + + +
+
Keine Fehlzeiten
+
+ +
@@ -1276,7 +1346,6 @@ footer {
Stundenplan
-
@@ -1422,6 +1491,23 @@ footer {
+ +
Zwei-Faktor-Authentifizierung (2FA)
Wird geladen…
@@ -1445,6 +1531,29 @@ footer {
+ + + + @@ -1491,14 +1604,14 @@ footer { const DAYS = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag']; const DAY_S = ['Mo','Di','Mi','Do','Fr']; const TIME_SLOTS = [ - { key:'07:45', label:'1.', time:'07:45' }, - { key:'08:30', label:'2.', time:'08:30' }, - { key:'09:30', label:'3.', time:'09:30' }, - { key:'10:15', label:'4.', time:'10:15' }, - { key:'11:15', label:'5.', time:'11:15' }, - { key:'12:00', label:'6.', time:'12:00' }, - { key:'13:30', label:'7.', time:'13:30' }, - { key:'14:15', label:'8.', time:'14:15' }, + { key:'08:00', label:'1.', time:'08:00' }, + { key:'08:45', label:'2.', time:'08:45' }, + { key:'09:45', label:'3.', time:'09:45' }, + { key:'10:30', label:'4.', time:'10:30' }, + { key:'11:30', label:'5.', time:'11:30' }, + { key:'12:15', label:'6.', time:'12:15' }, + { key:'13:45', label:'7.', time:'13:45' }, + { key:'14:30', label:'8.', time:'14:30' }, ]; 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' }; @@ -1563,8 +1676,14 @@ loadWeather(); // ── API ──────────────────────────────────────────────────────── async function api(method,path,body){ - const r=await fetch('/api/'+path,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}); - return r.json(); + try{ + 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 ──────────────────────────────────────────────────── @@ -1580,13 +1699,17 @@ async function init(){ 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){ + currentRole = role; document.getElementById('banner').style.display='none'; document.getElementById('card-tt-public').style.display='none'; document.getElementById('card-tt').style.display=''; document.getElementById('pair-hw-todo').style.display='grid'; 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=''); ['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=''; ['sb-nav-materials','sb-nav-announcements'].forEach(id=>document.getElementById(id).style.display='flex'); - // Teacher-only panel + // Teacher features if(role==='teacher'){ document.getElementById('sb-label-lehrer').style.display=''; document.getElementById('sb-nav-teacher').style.display='flex'; + document.getElementById('ab-teacher-form').style.display=''; + loadStudentListForAbsencesCard(); } const adminLink = role === 'admin' ? `
🛡️ Admin` : ''; @@ -1618,6 +1743,7 @@ function loginUI(username,id,role,subject){
`; + loadSubjectsDatalist(); loadAll(); initChat(username, id); } @@ -1635,12 +1761,16 @@ async function doLogout(){ // ── LOAD ALL ────────────────────────────────────────────────── async function loadAll(){ - const [tt,hw,gr,ab,td,cd,ql]=await Promise.all([ - api('GET','timetable'),api('GET','homework'),api('GET','grades'), - api('GET','absences'),api('GET','todos'),api('GET','countdowns'),api('GET','quicklinks') + const [tt,hw,gr,td,cd,ql]=await Promise.all([ + api('GET','teacher/class-timetable?class_id=info1'), + api('GET','homework'), + api('GET','grades'), + api('GET','todos'), + api('GET','countdowns'), + api('GET','quicklinks') ]); - renderTT(tt); renderHW(hw); renderGrades(gr); - renderAbsences(ab); renderTodos(td); renderCountdowns(cd); renderQL(ql); + renderTT(Array.isArray(tt)?tt:[]); renderHW(hw); renderGrades(gr); + renderTodos(td); renderCountdowns(cd); renderQL(ql); loadFiles(); loadTickets(); } @@ -1687,7 +1817,7 @@ function renderTT(data){ const lel=document.createElement('div'); lel.className='tt-lesson'; lel.style.cssText=`background:${col.bg};border-left:2.5px solid ${col.border};color:${col.text}`; - lel.innerHTML=`
${esc(lesson.subject||'')}
${esc(lesson.room||'')}${lesson.teacher?' · '+esc(lesson.teacher):''}
`; + lel.innerHTML=`
${esc(lesson.subject||'')}
${esc(lesson.room||'')}${lesson.teacher_name?' · '+esc(lesson.teacher_name):''}
`; cell.appendChild(lel); } grid.appendChild(cell); @@ -1788,15 +1918,68 @@ function renderGrades(data){ } // ── ABSENCES ────────────────────────────────────────────────── +async function loadAbsencesCard(){ + const el=document.getElementById('list-ab'); + el.innerHTML='
'; + 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){ const el=document.getElementById('list-ab'); - if(!data.length){el.innerHTML='
Keine Fehlzeiten
';return;} - el.innerHTML=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(a=> - `
+ if(!data.length){el.innerHTML='
Keine Fehlzeiten eingetragen
';return;} + const sorted=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||'')); + if(currentRole==='teacher'){ + el.innerHTML=sorted.map(a=>`
+
${a.date?fmtDate(a.date):'–'}
+
+
${esc(a.student_name||'')}${a.subject?' · '+esc(a.subject):''}
+
${esc(a.reason||'')}
+
+ +
`).join('')+`
${data.length} Fehlzeit${data.length!==1?'en':''} gesamt
`; + } else { + el.innerHTML=sorted.map(a=>`
${a.date?fmtDate(a.date):'–'}
${esc(a.subject||'')}
${esc(a.reason||'')}
-
`).join('')+`
${data.length} Fehlzeit${data.length!==1?'en':''} gesamt
`; + } +} + +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=''+students.map(s=>``).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 ───────────────────────────────────────────────────── @@ -1848,11 +2031,6 @@ async function toggle(type,id,current){ // ── MODAL CONFIG ────────────────────────────────────────────── 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:[ {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:'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'}]}, countdowns:{title:'Countdown hinzufügen',fields:[ {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('user-dropdown')?.classList.remove('open'); 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=>`'+subjects.map(s=>``).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=>`${esc(s)}`).join(''):'Noch keine Fächer hinzugefügt'; +} + +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';} async function load2FAStatus(){ const statusRow=document.getElementById('2fa-status-row'); - document.getElementById('2fa-idle-area').style.display='none'; - document.getElementById('2fa-enabled-area').style.display='none'; - document.getElementById('2fa-setup-area').style.display='none'; - document.getElementById('2fa-disable-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(id).style.display='none'; + }); try { const r=await api('GET','2fa/status'); statusRow.textContent=''; 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=''; } else { document.getElementById('2fa-idle-area').style.display=''; @@ -1941,10 +2164,12 @@ async function load2FAStatus(){ } 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-status-row').textContent='QR-Code wird generiert…'; try { - const r=await api('POST','2fa/setup'); + const r=await api('POST','2fa/setup',{password:pw}); document.getElementById('2fa-status-row').textContent=''; if(r.error){toast(r.error,'error');document.getElementById('2fa-idle-area').style.display='';return;} document.getElementById('2fa-qr').src=r.qr; @@ -1971,7 +2196,60 @@ async function confirm2FA(){ if(r.ok){ toast('2FA aktiviert ✓','success'); 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=>`
${esc(c)}
`).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 { toast(r.error,'error'); } @@ -2376,18 +2654,160 @@ document.addEventListener('keydown',e=>{ // ── TEACHER PANEL ───────────────────────────────────────────── 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-pane').forEach(p=>p.classList.remove('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(){ - loadTeacherMaterials(); - loadTeacherAnnouncements(); - loadTeacherExams(); - loadTeacherGrades(); + loadTFaecher(); +} + +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=>``).join(''):''; + sel.innerHTML=''+opts; + } + // render current subjects as removable badges + const el=document.getElementById('t-my-subjects-list'); + if(el){ + el.innerHTML=mySet.size?[...mySet].map(s=>`${esc(s)}`).join(''):'Noch keine Fächer hinzugefügt'; + } + // 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=''+mine.map(s=>``).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='
Keine Einträge
';return;} + el.innerHTML=data.map(e=>`
+
+
${esc(e.day)} · ${esc(e.time_start||'')}${e.time_end?'–'+esc(e.time_end):''}
+
${esc(e.subject||'')}${e.room?' · '+esc(e.room):''}
+
+ +
`).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='
Keine Fehlzeiten
';return;} + el.innerHTML=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(a=>`
+
${a.date?fmtDate(a.date):'–'}
+
+
${esc(a.student_name)} · ${esc(a.subject||'')}
+
${esc(a.reason||'')}
+
+ +
`).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=''+students.map(s=>``).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(){ @@ -2409,10 +2829,13 @@ async function uploadTeacherMaterial(e){ const files=e.target.files; if(!files.length)return; 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(!subject){toast('Bitte ein Fach auswählen','error');e.target.value='';return;} const fd=new FormData(); fd.append('file',files[0]); fd.append('title',title); + fd.append('subject',subject); fd.append('class_id','info1'); e.target.value=''; const r=await fetch('/api/teacher/materials',{method:'POST',body:fd}); @@ -2446,8 +2869,10 @@ async function loadTeacherAnnouncements(){ async function createAnnouncement(){ const title=document.getElementById('ann-title').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;} - 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;} document.getElementById('ann-title').value=''; document.getElementById('ann-content').value=''; @@ -2482,8 +2907,10 @@ async function createExam(){ const title=document.getElementById('exam-title').value.trim(); const date=document.getElementById('exam-date').value; const description=document.getElementById('exam-desc').value.trim(); + const subject=document.getElementById('exam-subject').value; 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;} document.getElementById('exam-title').value=''; document.getElementById('exam-date').value=''; @@ -2526,9 +2953,11 @@ async function assignGrade(){ const grade=parseFloat(document.getElementById('grade-val').value); const type=document.getElementById('grade-type').value; 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(!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;} document.getElementById('grade-val').value=''; document.getElementById('grade-note').value=''; diff --git a/public/login.html b/public/login.html index 5e7a386..1217367 100644 --- a/public/login.html +++ b/public/login.html @@ -223,8 +223,8 @@ footer a:hover { color: #2563eb; } diff --git a/src/auth.js b/src/auth.js index 18653ea..6dc3e22 100644 --- a/src/auth.js +++ b/src/auth.js @@ -11,7 +11,13 @@ const COOKIE_OPTIONS = { }; 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) { @@ -27,8 +33,11 @@ function requireAuth(req, res, next) { if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' }); const payload = verifyToken(token); 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 ((payload.tv | 0) !== (user.token_version | 0)) { + return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' }); + } req.user = user; next(); } diff --git a/src/db.js b/src/db.js index eaa021b..4a6dcf4 100644 --- a/src/db.js +++ b/src/db.js @@ -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(` CREATE TABLE IF NOT EXISTS user_keys ( 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 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_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 verify_token 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(` CREATE TABLE IF NOT EXISTS password_resets ( diff --git a/src/routes.js b/src/routes.js index 0dae669..2015d66 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,5 +1,7 @@ const express = require('express'); const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); const bcrypt = require('bcryptjs'); const rateLimit = require('express-rate-limit'); const { generateSecret, generateURI, verifySync } = require('otplib'); @@ -9,9 +11,24 @@ const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth'); const { deleteUserFiles } = require('./files'); const { sendVerifyMail, sendPasswordResetMail } = require('./mailer'); +const STORAGE_DIR = path.resolve(__dirname, '../storage'); + const VERIFY_TTL_MS = 24 * 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 loginLimiter = rateLimit({ @@ -64,9 +81,26 @@ const resetPasswordLimiter = rateLimit({ 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 --- 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) => { const { email, password, role, subject } = req.body; @@ -79,10 +113,15 @@ router.post('/register', registerLimiter, async (req, res) => { let safeSubject = null; 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' }); } - 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); @@ -96,6 +135,9 @@ router.post('/register', registerLimiter, async (req, res) => { VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) `).run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject, verifyToken, verifyExpires); 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) { if (e.message.includes('UNIQUE')) { 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) => { 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); - 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' }); } if (!user.email_verified) { @@ -139,7 +186,15 @@ router.post('/login', loginLimiter, (req, res) => { if (user.totp_enabled) { if (!totp_token) return res.json({ requireTotp: true }); 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); res.cookie('token', token, COOKIE_OPTIONS); @@ -151,7 +206,7 @@ router.post('/logout', (req, res) => { res.json({ ok: true }); }); -router.get('/verify', (req, res) => { +router.get('/verify', verifyLimiter, (req, res) => { const { token } = req.query; const fail = (msg) => res.status(400).send(` Verifizierung @@ -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.' }); const hash = bcrypt.hashSync(newPassword, 12); 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 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' }); } 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 }); }); +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) => { - 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); if (!bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ error: 'Passwort falsch' }); } - const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','chat_messages']; - tables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(req.user.id)); - deleteUserFiles(req.user.id); - db.prepare('DELETE FROM users WHERE id = ?').run(req.user.id); + if (user.role === 'admin' && countActiveAdmins() <= 1) { + return res.status(400).json({ error: 'Letztes Admin-Konto kann nicht gelöscht werden' }); + } + 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.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 (!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' }); + 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 (status) db.prepare('UPDATE users SET status = ? WHERE id = ?').run(status, req.params.id); if (hasEmailVerified) { @@ -329,14 +425,17 @@ router.patch('/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 (Number(req.params.id) === req.user.id) return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' }); - const tables = ['timetable','homework','grades','absences','todos','countdowns','quicklinks','chat_messages']; - tables.forEach(t => db.prepare(`DELETE FROM ${t} WHERE user_id = ?`).run(req.params.id)); - deleteUserFiles(Number(req.params.id)); - db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); - logAdmin(req.user.id, 'user_delete', Number(req.params.id)); + if (targetId === req.user.id) return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' }); + try { + db.transaction(() => purgeUser(targetId))(); + } catch (e) { + console.error('DELETE /admin/users/:id failed:', e); + return res.status(500).json({ error: 'Löschen fehlgeschlagen' }); + } + logAdmin(req.user.id, 'user_delete', targetId); res.json({ ok: true }); }); @@ -380,7 +479,6 @@ router.get('/admin/usage', requireAuth, requireAdmin, (req, res) => { const usage = users.map(u => { const counts = {}; tables.forEach(t => { - const col = t === 'support_tickets' ? 'user_id' : 'user_id'; counts[t] = db.prepare(`SELECT COUNT(*) AS c FROM ${t} WHERE user_id = ?`).get(u.id).c; }); return { ...u, counts }; @@ -451,11 +549,23 @@ router.get('/class-events', (req, res) => { 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) => { const { type, title, date, date_end, description } = req.body; 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 (?,?,?,?,?,?)') - .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 }); }); @@ -604,8 +714,12 @@ router.post('/e2ee/group-keys', requireAuth, (req, res) => { return res.status(400).json({ error: 'Ungültige Anfrage' }); } 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(` - 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) VALUES (?, ?, ?, ?, ?) `); @@ -622,9 +736,17 @@ router.post('/e2ee/group-keys', requireAuth, (req, res) => { }); // --- 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 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); const otpauth = generateURI({ secret, label: user.email, issuer: 'INFO1', type: 'totp' }); 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' }); const result = verifySync({ token: String(token), secret: user.totp_secret }); 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); - res.json({ ok: true }); + const { plain, hashes } = generateRecoveryCodes(); + 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) => { @@ -656,13 +780,28 @@ router.post('/2fa/disable', requireAuth, loginLimiter, (req, res) => { } const disableResult = verifySync({ token: String(token), secret: user.totp_secret }); 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 }); }); router.get('/2fa/status', requireAuth, (req, res) => { - const user = db.prepare('SELECT totp_enabled FROM users WHERE id = ?').get(req.user.id); - res.json({ enabled: !!user.totp_enabled }); + const user = db.prepare('SELECT totp_enabled, totp_recovery_codes FROM users WHERE id = ?').get(req.user.id); + 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 --- @@ -699,9 +838,18 @@ function crudRoutes(path, table, fields) { crudRoutes('timetable', 'timetable', ['day', 'time_start', 'time_end', 'subject', 'room', 'teacher']); crudRoutes('homework', 'homework', ['subject', 'title', 'due_date', 'done']); crudRoutes('grades', 'grades', ['subject', 'grade', 'type', 'note']); -crudRoutes('absences', 'absences', ['date', 'subject', 'reason']); crudRoutes('todos', 'todos', ['title', 'done']); crudRoutes('countdowns', 'countdowns', ['title', 'target_date']); crudRoutes('quicklinks', 'quicklinks', ['label', 'url']); +// 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; diff --git a/src/teacher.js b/src/teacher.js index 5fd4267..8c90f78 100644 --- a/src/teacher.js +++ b/src/teacher.js @@ -8,19 +8,34 @@ const { requireAuth } = require('./auth'); 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 CLASS_IDS = ['info1']; function requireTeacher(req, res, next) { 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.subject) return res.status(403).json({ error: 'Kein Lehrfach zugewiesen' }); - req.teacher = user; + const subjects = db.prepare('SELECT subject_name FROM user_subjects WHERE user_id = ?').all(req.user.id).map(r => r.subject_name); + req.teacher = { subjects }; 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'); 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 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( '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 }); }); @@ -158,7 +175,7 @@ router.get('/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 (!content || typeof content !== 'string') return res.status(400).json({ error: 'Inhalt erforderlich' }); 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 (!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 safeSubj = getTeacherSubject(req, res, typeof subject === 'string' ? subject.trim() : null); + if (!safeSubj) return; const result = db.prepare( '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 }); }); @@ -199,16 +218,18 @@ router.get('/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' }); const safeTitle = title.trim(); 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 safeDesc = description && typeof description === 'string' ? description.trim().slice(0, 1000) : null; 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( '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 }); }); @@ -241,7 +262,7 @@ router.get('/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' }); const gradeNum = parseFloat(grade); 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 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 safeSubj = getTeacherSubject(req, res, typeof subject === 'string' ? subject.trim() : null); + if (!safeSubj) return; const result = db.prepare( '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 }); }); @@ -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()); }); +// ── 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) => { if (req.user.role !== 'student') return res.status(403).json({ error: 'Nur für Schüler' }); res.json(db.prepare(`