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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user