Files
breakpilot-lehrer/studio-v2/app/schulkalender/_components/ParentManager.tsx
T
Benjamin Admin 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
Phase 9c: Parent accounts, magic-link login + parent timetable view
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>
2026-05-22 11:50:35 +02:00

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>
)
}