Baustein 1: Grundgeruest, Datenbank, Anmeldung, zwei Rollen

Legt das Fundament der Lern-Bar an:

- Next.js 15 (App Router), React 19, TypeScript, ESLint, Vitest
- SQLite ueber Drizzle hinter einer Repository-Schicht
- Eigener Migrationslauf mit Pruefsummen, beim Start angewendet
- Argon2id, serverseitige Sitzungen, HttpOnly-Cookie, SameSite=Lax
- Rollen Ausbilder und Azubi, Rechteregeln als reine Funktionen
- Verwaltung: Azubikonto und Ausbildung anlegen, Passwort, Sperren
- Gestaltung Petrol/Bernstein, helle und dunkle Darstellung
- Dockerfile, docker-compose, Lebenszeichen, update.sh mit Rueckfall
- Sicherung und Wiederherstellung als Shell-Skripte
- 29 Tests fuer Rechte, Zeitrechnung und Migrationslauf

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 00:52:44 +02:00
co-authored by Claude Opus 5
commit e856349cd4
51 changed files with 11883 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile';
import { abmelden } from '../anmelden/aktionen';
export const dynamic = 'force-dynamic';
// Die Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele,
// die es auch wirklich gibt.
const fuerAzubi: Navigationseintrag[] = [{ pfad: '/', titel: 'Woche' }];
const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
export default async function AngemeldetLayout({ children }: { children: React.ReactNode }) {
const benutzer = await erfordereBenutzer();
const eintraege = benutzer.rolle === 'ausbilder' ? fuerAusbilder : fuerAzubi;
return (
<>
<a href="#inhalt" className="sprung">
Zum Inhalt springen
</a>
<Kopfzeile
eintraege={eintraege}
name={benutzer.name}
rolle={benutzer.rolle === 'ausbilder' ? 'Ausbilder' : 'Azubi'}
abmeldeAktion={abmelden}
/>
<main id="inhalt" className="huelle">
{children}
</main>
</>
);
}
+70
View File
@@ -0,0 +1,70 @@
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { alsDatumText, kalenderwocheText, heute } from '@/lib/zeit';
export const dynamic = 'force-dynamic';
/**
* Vorläufige Startseite. Ab Baustein 3 steht hier der Wochenplan.
*/
export default async function StartSeite() {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle === 'ausbilder') {
const meine = ausbildungenVonAusbilder(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
<p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())}
</p>
</div>
<section className="karte stapel">
<h2 style={{ marginBottom: 0 }}>Ausbildungen</h2>
{meine.length === 0 ? (
<p style={{ marginBottom: 0 }}>
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{meine.map(({ ausbildung, azubi }) => (
<li key={ausbildung.id}>
<strong>{azubi.name}</strong> · seit {alsDatumText(ausbildung.beginn)}
</li>
))}
</ul>
)}
</section>
</div>
);
}
const ausbildung = ausbildungNachAzubi(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
<p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())}
</p>
</div>
<section className="karte">
{ausbildung ? (
<p style={{ marginBottom: 0 }}>
Deine Ausbildung läuft seit {alsDatumText(ausbildung.beginn)}
{ausbildung.probezeitBis ? ` (Probezeit bis ${alsDatumText(ausbildung.probezeitBis)})` : ''}.
</p>
) : (
<p style={{ marginBottom: 0 }}>
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid.
</p>
)}
</section>
</div>
);
}
@@ -0,0 +1,111 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import {
ausbildungBearbeiten,
azubiAnlegen,
passwortZuruecksetzen,
type Rueckmeldung,
} from './aktionen';
export function AzubiAnlegenFormular({ vorschlagBeginn }: { vorschlagBeginn: string }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(azubiAnlegen, {});
return (
<form action={aktion} className="stapel">
<Meldung zustand={zustand} />
<div className="feld">
<label htmlFor="neu-name">Name</label>
<input id="neu-name" name="name" type="text" required autoComplete="off" placeholder="Vorname Nachname" />
</div>
<div className="feld">
<label htmlFor="neu-email">E-Mail-Adresse</label>
<input id="neu-email" name="email" type="email" required autoComplete="off" />
</div>
<div className="feld">
<label htmlFor="neu-passwort">Erstes Passwort</label>
<input id="neu-passwort" name="passwort" type="text" required minLength={10} autoComplete="off" />
<p className="leise" style={{ margin: '0.3rem 0 0' }}>
Mindestens 10 Zeichen. Gib es dem Azubi persönlich weiter – er kann es später selbst ändern.
</p>
</div>
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div className="feld" style={{ flex: '1 1 160px', marginTop: 0 }}>
<label htmlFor="neu-beginn">Ausbildungsbeginn</label>
<input id="neu-beginn" name="beginn" type="date" required defaultValue={vorschlagBeginn} />
</div>
<div className="feld" style={{ flex: '1 1 160px', marginTop: 0 }}>
<label htmlFor="neu-probezeit">Probezeit bis</label>
<input id="neu-probezeit" name="probezeitBis" type="date" />
</div>
<div className="feld" style={{ flex: '1 1 160px', marginTop: 0 }}>
<label htmlFor="neu-ende">Geplantes Ende</label>
<input id="neu-ende" name="ende" type="date" />
</div>
</div>
<div>
<Absenden>Konto anlegen</Absenden>
</div>
</form>
);
}
export function AusbildungFormular({
ausbildungId,
beginn,
probezeitBis,
ende,
}: {
ausbildungId: string;
beginn: string;
probezeitBis: string | null;
ende: string | null;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(ausbildungBearbeiten, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="ausbildungId" value={ausbildungId} />
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 150px' }}>
<label htmlFor={`beginn-${ausbildungId}`}>Beginn</label>
<input id={`beginn-${ausbildungId}`} name="beginn" type="date" defaultValue={beginn} required />
</div>
<div style={{ flex: '1 1 150px' }}>
<label htmlFor={`probe-${ausbildungId}`}>Probezeit bis</label>
<input id={`probe-${ausbildungId}`} name="probezeitBis" type="date" defaultValue={probezeitBis ?? ''} />
</div>
<div style={{ flex: '1 1 150px' }}>
<label htmlFor={`ende-${ausbildungId}`}>Ende</label>
<input id={`ende-${ausbildungId}`} name="ende" type="date" defaultValue={ende ?? ''} />
</div>
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
</div>
</form>
);
}
export function PasswortFormular({ benutzerId, name }: { benutzerId: string; name: string }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(passwortZuruecksetzen, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="benutzerId" value={benutzerId} />
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 220px' }}>
<label htmlFor={`pw-${benutzerId}`}>Neues Passwort für {name}</label>
<input id={`pw-${benutzerId}`} name="passwort" type="text" minLength={10} required autoComplete="off" />
</div>
<Absenden className="knopf knopf-zweit">Passwort setzen</Absenden>
</div>
</form>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereAusbilder } from '@/lib/auth/rechte';
import { passwortMangel } from '@/lib/auth/passwort';
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereAusbilder();
const name = String(formular.get('name') ?? '').trim();
const email = String(formular.get('email') ?? '')
.trim()
.toLowerCase();
const passwort = String(formular.get('passwort') ?? '');
const beginn = String(formular.get('beginn') ?? '').trim();
const probezeitBis = String(formular.get('probezeitBis') ?? '').trim();
const ende = String(formular.get('ende') ?? '').trim();
if (!name) return { fehler: 'Bitte einen Namen eintragen.' };
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { fehler: 'Die E-Mail-Adresse sieht nicht gültig aus.' };
const mangel = passwortMangel(passwort);
if (mangel) return { fehler: mangel };
if (!datumMuster.test(beginn)) return { fehler: 'Bitte ein Beginndatum wählen.' };
if (benutzerNachEmail(email)) return { fehler: 'Diese E-Mail-Adresse ist schon vergeben.' };
const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' });
ausbildungAnlegen({
azubiId: azubi.id,
ausbilderId: ausbilder.id,
beginn,
probezeitBis: datumMuster.test(probezeitBis) ? probezeitBis : null,
ende: datumMuster.test(ende) ? ende : null,
});
revalidatePath('/verwaltung');
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${beginn} eingerichtet.` };
}
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereAusbilder();
const id = String(formular.get('ausbildungId') ?? '');
const bestand = ausbildungNachId(id);
if (!bestand || bestand.ausbilderId !== ausbilder.id) {
return { fehler: 'Diese Ausbildung gehört nicht zu dir.' };
}
const beginn = String(formular.get('beginn') ?? '').trim();
const ende = String(formular.get('ende') ?? '').trim();
const probezeitBis = String(formular.get('probezeitBis') ?? '').trim();
if (!datumMuster.test(beginn)) return { fehler: 'Bitte ein gültiges Beginndatum wählen.' };
ausbildungAendern(id, {
beginn,
ende: datumMuster.test(ende) ? ende : null,
probezeitBis: datumMuster.test(probezeitBis) ? probezeitBis : null,
});
revalidatePath('/verwaltung');
return { erfolg: 'Ausbildung aktualisiert.' };
}
export async function passwortZuruecksetzen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereAusbilder();
const benutzerId = String(formular.get('benutzerId') ?? '');
const neu = String(formular.get('passwort') ?? '');
const ziel = benutzerNachId(benutzerId);
if (!ziel) return { fehler: 'Konto nicht gefunden.' };
const bezug = ausbildungNachAzubi(benutzerId);
const eigenesKonto = ziel.id === ausbilder.id;
const eigenerAzubi = ziel.rolle === 'azubi' && bezug?.ausbilderId === ausbilder.id;
if (!eigenesKonto && !eigenerAzubi) return { fehler: 'Dieses Konto darfst du nicht ändern.' };
const mangel = passwortMangel(neu);
if (mangel) return { fehler: mangel };
await passwortSetzen(benutzerId, neu);
revalidatePath('/verwaltung');
return { erfolg: `Neues Passwort für ${ziel.name} gesetzt. Alle offenen Sitzungen wurden beendet.` };
}
export async function kontoAktivSetzen(formular: FormData): Promise<void> {
const ausbilder = await erfordereAusbilder();
const benutzerId = String(formular.get('benutzerId') ?? '');
const aktiv = String(formular.get('aktiv') ?? '') === '1';
const ziel = benutzerNachId(benutzerId);
if (!ziel || ziel.id === ausbilder.id) return;
const bezug = ausbildungNachAzubi(benutzerId);
if (ziel.rolle !== 'azubi' || bezug?.ausbilderId !== ausbilder.id) return;
aktivSetzen(benutzerId, aktiv);
revalidatePath('/verwaltung');
}
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from 'next';
import { erfordereAusbilder } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
import { AusbildungFormular, AzubiAnlegenFormular, PasswortFormular } from './Formulare';
import { kontoAktivSetzen } from './aktionen';
export const metadata: Metadata = { title: 'Verwaltung' };
export const dynamic = 'force-dynamic';
export default async function VerwaltungsSeite() {
const ausbilder = await erfordereAusbilder();
const meine = ausbildungenVonAusbilder(ausbilder.id);
return (
<div className="stapel">
<div>
<h1>Verwaltung</h1>
<p className="leise">
Hier legst du Azubikonten an und pflegst die Eckdaten der Ausbildung. Mehr Verwaltung gibt es bewusst nicht.
</p>
</div>
<section className="karte stapel" aria-labelledby="ueberschrift-azubis">
<h2 id="ueberschrift-azubis" style={{ marginBottom: 0 }}>
Deine Auszubildenden
</h2>
{meine.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Noch kein Azubikonto angelegt. Nimm dafür das Formular weiter unten.
</p>
) : (
<div className="stapel">
{meine.map(({ ausbildung, azubi }) => (
<article key={ausbildung.id} className="karte-grund stapel">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<div>
<h3 style={{ marginBottom: '0.15rem' }}>{azubi.name}</h3>
<p className="leise" style={{ margin: 0 }}>
{azubi.email} · {ausbildung.beruf}
</p>
<p className="leise" style={{ margin: 0 }}>
Beginn {alsDatumText(ausbildung.beginn)}
{ausbildung.probezeitBis ? ` · Probezeit bis ${alsDatumText(ausbildung.probezeitBis)}` : ''}
{ausbildung.ende ? ` · Ende ${alsDatumText(ausbildung.ende)}` : ''}
</p>
<p className="leise" style={{ margin: 0 }}>
Zuletzt angemeldet: {alsZeitpunktText(azubi.letzteAnmeldungAm)}
</p>
</div>
<div className="reihe">
<span className="marke-status" data-ton={azubi.aktiv === 1 ? 'gut' : 'warnung'}>
{azubi.aktiv === 1 ? '● aktiv' : '○ gesperrt'}
</span>
<form action={kontoAktivSetzen}>
<input type="hidden" name="benutzerId" value={azubi.id} />
<input type="hidden" name="aktiv" value={azubi.aktiv === 1 ? '0' : '1'} />
<button type="submit" className="knopf knopf-still">
{azubi.aktiv === 1 ? 'Sperren' : 'Entsperren'}
</button>
</form>
</div>
</div>
<AusbildungFormular
ausbildungId={ausbildung.id}
beginn={ausbildung.beginn}
probezeitBis={ausbildung.probezeitBis}
ende={ausbildung.ende}
/>
<PasswortFormular benutzerId={azubi.id} name={azubi.name} />
</article>
))}
</div>
)}
</section>
<section className="karte stapel" aria-labelledby="ueberschrift-neu">
<div>
<h2 id="ueberschrift-neu" style={{ marginBottom: '0.15rem' }}>
Neues Azubikonto
</h2>
<p className="leise" style={{ margin: 0 }}>
Konto und Ausbildungsverhältnis werden in einem Schritt angelegt.
</p>
</div>
<AzubiAnlegenFormular vorschlagBeginn={heute()} />
</section>
<section className="karte stapel" aria-labelledby="ueberschrift-eigenes">
<h2 id="ueberschrift-eigenes" style={{ marginBottom: 0 }}>
Eigenes Passwort
</h2>
<PasswortFormular benutzerId={ausbilder.id} name={ausbilder.name} />
</section>
</div>
);
}