d9858084dd
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 26s
Backend (school-service):
- parent_account, parent_child, parent_magic_link, parent_session
tables. Tokens are sha256-hashed in DB; raw goes back exactly
once to the inviting teacher.
- InviteParent upserts the parent account, links a child to a tt_
class, mints a 7-day magic link. Returns the link path so the
teacher can paste it into Matrix/Email.
- RedeemMagicLink validates + marks used + mints a 30-day session,
sets HttpOnly bp_parent_session cookie.
- ParentSessionMiddleware reads the cookie and resolves the parent.
Lives in its own router group /api/v1/parent — totally separate
from the teacher JWT path.
- ParentMe returns the account + list of children (with class name).
- ParentTimetable returns the latest completed tt_solution's lessons
for the requested child's class, with full authorization check
(parent must own a child in that class).
Frontend (studio-v2):
- lib/calendar/subject-i18n.ts maps 22 German subject names to 8
parent locales (de/en/tr/ar/uk/ru/pl/fr). Falls back to German
for custom subjects.
- ParentManager component on the Schulkalender page lets the teacher
invite parents via email + child name + class + language. Newly
minted magic-link is shown with a copy-to-clipboard button.
- app/api/parent/[...path]/route.ts proxies parent-side endpoints
via the cookie so HttpOnly survives the Next.js round-trip.
- /eltern/login?token=… redeems and redirects to /eltern.
- /eltern shows a Wochengrid with German days + translated subject
names in the parent's preferred language. Headings and weekday
labels also localised (de/en/tr/ar/uk/ru/pl/fr).
Tests:
- 3 new Go unit tests (random token, hash stability, invite-request
validator). 83 subtests gesamt.
- studio-v2: e2e/eltern.spec.ts mit 7 tests across ParentManager,
/eltern/login, /eltern overview, subject-i18n end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
174 lines
7.9 KiB
TypeScript
174 lines
7.9 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
import { calendarApi } from '@/lib/schulkalender/api'
|
|
import { classesApi } from '@/lib/stundenplan/api'
|
|
import type { ParentInviteListItem, InviteParentResponse } from '@/app/schulkalender/types'
|
|
import type { TimetableClass } from '@/app/stundenplan/types'
|
|
|
|
const LANGS: { code: string; name: string }[] = [
|
|
{ code: 'de', name: 'Deutsch' },
|
|
{ code: 'en', name: 'English' },
|
|
{ code: 'tr', name: 'Tuerkce' },
|
|
{ code: 'ar', name: 'العربية' },
|
|
{ code: 'uk', name: 'Українська' },
|
|
{ code: 'ru', name: 'Русский' },
|
|
{ code: 'pl', name: 'Polski' },
|
|
{ code: 'fr', name: 'Francais' },
|
|
]
|
|
|
|
export function ParentManager() {
|
|
const { isDark } = useTheme()
|
|
const [items, setItems] = useState<ParentInviteListItem[]>([])
|
|
const [classes, setClasses] = useState<TimetableClass[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [lastInvite, setLastInvite] = useState<InviteParentResponse | null>(null)
|
|
const [form, setForm] = useState({
|
|
email: '',
|
|
preferred_language: 'de',
|
|
child_first_name: '',
|
|
child_last_name: '',
|
|
tt_class_id: '',
|
|
})
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [list, cls] = await Promise.all([calendarApi.listParents(), classesApi.list()])
|
|
setItems(list || [])
|
|
setClasses(cls || [])
|
|
setError(null)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setSubmitting(true)
|
|
setError(null)
|
|
try {
|
|
const res = await calendarApi.inviteParent(form)
|
|
setLastInvite(res)
|
|
setForm({ ...form, child_first_name: '', child_last_name: '' })
|
|
await load()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Einladen fehlgeschlagen')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (childId: string) => {
|
|
if (!confirm('Eltern-Zuordnung wirklich loeschen?')) return
|
|
try { await calendarApi.deleteParentChild(childId); await load() }
|
|
catch (e) { setError(e instanceof Error ? e.message : 'Loeschen fehlgeschlagen') }
|
|
}
|
|
|
|
const fullLink = (path: string) =>
|
|
typeof window === 'undefined' ? path : `${window.location.origin}${path}`
|
|
|
|
const copyLink = (path: string) => {
|
|
navigator.clipboard?.writeText(fullLink(path))
|
|
}
|
|
|
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
|
|
|
return (
|
|
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="parent-manager">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-lg font-semibold">Eltern verwalten ({items.length})</h3>
|
|
<button
|
|
onClick={() => setShowForm(s => !s)}
|
|
disabled={classes.length === 0}
|
|
data-testid="parent-invite-toggle"
|
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
|
>
|
|
{showForm ? 'Abbrechen' : '+ Eltern einladen'}
|
|
</button>
|
|
</div>
|
|
|
|
{classes.length === 0 && (
|
|
<p className={`text-sm mb-2 ${isDark ? 'text-amber-200' : 'text-amber-900'}`}>
|
|
Zuerst Klassen im Stundenplan-Modul anlegen.
|
|
</p>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
|
|
)}
|
|
|
|
{showForm && (
|
|
<form onSubmit={handleSubmit} className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-2">
|
|
<input required type="email" placeholder="Eltern-E-Mail" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} data-testid="parent-email" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
|
<input required placeholder="Vorname Kind" value={form.child_first_name} onChange={e => setForm({ ...form, child_first_name: e.target.value })} data-testid="parent-child-first" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
|
<input required placeholder="Nachname Kind" value={form.child_last_name} onChange={e => setForm({ ...form, child_last_name: e.target.value })} data-testid="parent-child-last" className={`px-3 py-2 rounded-lg border ${inputClass}`} />
|
|
<select required value={form.tt_class_id} onChange={e => setForm({ ...form, tt_class_id: e.target.value })} data-testid="parent-class" className={`px-3 py-2 rounded-lg border ${inputClass}`}>
|
|
<option value="">— Klasse waehlen —</option>
|
|
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
</select>
|
|
<select value={form.preferred_language} onChange={e => setForm({ ...form, preferred_language: e.target.value })} className={`px-3 py-2 rounded-lg border ${inputClass}`}>
|
|
{LANGS.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
|
|
</select>
|
|
<button type="submit" disabled={submitting} data-testid="parent-invite-submit" className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
|
{submitting ? 'Erstellt…' : 'Einladen'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{lastInvite && (
|
|
<div className={`mb-3 p-3 rounded-lg ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`} data-testid="parent-invite-link">
|
|
<div className="text-sm font-medium mb-1">Einladungs-Link fuer {lastInvite.parent.email}</div>
|
|
<div className="flex gap-2 items-center">
|
|
<code className={`flex-1 text-xs px-2 py-1 rounded overflow-x-auto ${isDark ? 'bg-white/10' : 'bg-white'}`}>
|
|
{fullLink(lastInvite.magic_url)}
|
|
</code>
|
|
<button onClick={() => copyLink(lastInvite.magic_url)} className="text-xs px-2 py-1 rounded bg-indigo-500 hover:bg-indigo-600 text-white">Kopieren</button>
|
|
</div>
|
|
<p className="text-xs opacity-70 mt-1">Gueltig bis {new Date(lastInvite.expires_at).toLocaleString('de-DE')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="opacity-60 py-4 text-center text-sm">Laedt…</div>
|
|
) : items.length === 0 ? (
|
|
<div className="opacity-60 py-4 text-center text-sm">Keine eingeladenen Eltern.</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className={isDark ? 'opacity-70' : 'opacity-70'}>
|
|
<tr>
|
|
<th className="text-left py-2">E-Mail</th>
|
|
<th className="text-left py-2">Kind</th>
|
|
<th className="text-left py-2">Klasse</th>
|
|
<th className="text-left py-2">Sprache</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map(it => (
|
|
<tr key={it.child_id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
|
<td className="py-2">{it.email}</td>
|
|
<td className="py-2">{it.child_first_name} {it.child_last_name}</td>
|
|
<td className="py-2">{it.class_name}</td>
|
|
<td className="py-2">{it.preferred_language}</td>
|
|
<td className="py-2 text-right">
|
|
<button onClick={() => handleDelete(it.child_id)} className="text-xs text-red-400 hover:text-red-300">Loeschen</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|