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:
+485
-56
@@ -1064,7 +1064,7 @@ footer {
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
|
||||
@@ -1124,11 +1124,24 @@ footer {
|
||||
<div class="card-head">
|
||||
<div class="card-title"><i data-lucide="user-x" aria-hidden="true"></i> Fehlzeiten</div>
|
||||
<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>
|
||||
</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 class="card ov-card" id="card-chat" style="display:none">
|
||||
@@ -1190,16 +1203,35 @@ footer {
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="t-tabs">
|
||||
<div class="t-tab active" onclick="teacherTab('materials')">Materialien</div>
|
||||
<div class="t-tabs" style="flex-wrap:wrap">
|
||||
<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('exams')">Prüfungen</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 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">
|
||||
<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>
|
||||
</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 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>
|
||||
<button class="add-btn" style="align-self:flex-start" onclick="createAnnouncement()">+ Ankündigung</button>
|
||||
</div>
|
||||
@@ -1216,7 +1251,10 @@ footer {
|
||||
|
||||
<div id="t-pane-exams" class="t-pane">
|
||||
<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">
|
||||
<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">
|
||||
@@ -1228,6 +1266,7 @@ footer {
|
||||
|
||||
<div id="t-pane-grades" class="t-pane">
|
||||
<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">
|
||||
<option value="">Schüler auswählen…</option>
|
||||
</select>
|
||||
@@ -1244,9 +1283,40 @@ footer {
|
||||
</div>
|
||||
<div id="list-teacher-grades"><div class="empty">Keine Noten vergeben</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>
|
||||
|
||||
<datalist id="subjects-datalist"></datalist>
|
||||
|
||||
<main>
|
||||
<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-actions">
|
||||
<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 class="card-body">
|
||||
@@ -1422,6 +1491,23 @@ footer {
|
||||
</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 & 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-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>
|
||||
@@ -1445,6 +1531,29 @@ footer {
|
||||
</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 -->
|
||||
<div id="2fa-disable-area" style="display:none">
|
||||
<div class="settings-fields">
|
||||
@@ -1464,7 +1573,11 @@ footer {
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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' ? `<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>`;
|
||||
|
||||
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=`<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);
|
||||
}
|
||||
grid.appendChild(cell);
|
||||
@@ -1788,15 +1918,68 @@ function renderGrades(data){
|
||||
}
|
||||
|
||||
// ── 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){
|
||||
const el=document.getElementById('list-ab');
|
||||
if(!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">
|
||||
if(!data.length){el.innerHTML='<div class="empty">Keine Fehlzeiten eingetragen</div>';return;}
|
||||
const sorted=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||''));
|
||||
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-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>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────────
|
||||
@@ -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=>`<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';}
|
||||
|
||||
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=>`<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 {
|
||||
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=>`<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(){
|
||||
@@ -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='';
|
||||
|
||||
+2
-2
@@ -223,8 +223,8 @@ footer a:hover { color: #2563eb; }
|
||||
<div id="login-step-2" style="display:none">
|
||||
<div class="field">
|
||||
<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">
|
||||
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Code aus deiner Authenticator-App eingeben</span>
|
||||
<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 der Authenticator-App oder einen Wiederherstellungscode (xxxxx-xxxxx) eingeben</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user