Add non-destructive dev seed on startup

This commit is contained in:
t.indorf
2026-05-06 22:31:07 +02:00
parent 9f452fccac
commit b686e714ff
77 changed files with 10862 additions and 87 deletions
+135
View File
@@ -0,0 +1,135 @@
"use server";
import { hash } from "bcryptjs";
import { z } from "zod";
import { signIn } from "@/auth";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /invite/[token] · Server Action
// ---------------------------------------------------------------------
// acceptInviteAction:
// 1. Token erneut gegen DB prüfen (kein Trust auf Client-State)
// 2. Passwort hashen + User-Daten setzen (DSGVO-Timestamps)
// 3. Token löschen (kein Replay möglich)
// 4. Direkt einloggen + Redirect auf /dashboard
// =====================================================================
const inviteSchema = z
.object({
token: z.string().min(1),
password: z.string().min(8, "Mindestens 8 Zeichen.").max(72, "Maximal 72 Zeichen."),
confirmPassword: z.string().min(1, "Passwort-Bestätigung fehlt."),
acceptPrivacyPolicy: z.literal("on", {
error: "Bitte die Datenschutzerklärung akzeptieren.",
}),
directoryOptIn: z.enum(["on", "off"]).optional(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwörter stimmen nicht überein.",
path: ["confirmPassword"],
});
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type InviteState = {
errors?: {
password?: string[];
confirmPassword?: string[];
acceptPrivacyPolicy?: string[];
directoryOptIn?: string[];
_form?: string[];
};
};
export async function acceptInviteAction(
_prev: InviteState,
formData: FormData,
): Promise<InviteState> {
const parsed = inviteSchema.safeParse({
token: formData.get("token"),
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy"),
directoryOptIn: formData.get("directoryOptIn") ?? "off",
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const { token, password, directoryOptIn } = parsed.data;
const now = new Date();
// ── 1. Token erneut DB-seitig validieren ──────────────────────────
const verificationToken = await prisma.verificationToken.findUnique({
where: { token },
});
if (!verificationToken) {
return { errors: { _form: ["Einladungslink ist ungültig."] } };
}
if (verificationToken.expires < now) {
return {
errors: {
_form: [
"Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.",
],
},
};
}
const userId = verificationToken.identifier;
// ── 2. User validieren (existiert und hat noch kein Passwort) ──────
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, passwordHash: true },
});
if (!user) {
return { errors: { _form: ["Benutzer nicht gefunden."] } };
}
if (user.passwordHash !== "") {
// Invite wurde bereits eingelöst
return {
errors: {
_form: [
"Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.",
],
},
};
}
// ── 3. Passwort hashen ─────────────────────────────────────────────
const passwordHash = await hash(password, 12);
// ── 4. User updaten + Token löschen (atomar) ──────────────────────
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: {
passwordHash,
privacyPolicyAcceptedAt: now,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: directoryOptIn === "on" ? now : null,
emailVerifiedAt: now,
lastLoginAt: now,
},
}),
prisma.verificationToken.delete({
where: { token },
}),
]);
// ── 5. Direkt einloggen → wirft NEXT_REDIRECT ─────────────────────
await signIn("credentials", {
email: user.email,
password,
redirectTo: "/dashboard",
});
// Unreachable signIn redirected.
return {};
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { acceptInviteAction, type InviteState } from "./actions";
const initialState: InviteState = {};
// =====================================================================
// InviteForm · Client Component
// ---------------------------------------------------------------------
// Empfängt den Token als Prop (aus der Server Component) und übergibt
// ihn als Hidden-Input an die Server Action — der Client "kennt" den
// Token nur zum Weiterleiten, validiert ihn nicht selbst.
// =====================================================================
export function InviteForm({
token,
userName,
}: {
token: string;
userName: string;
}) {
const [state, formAction, pending] = useActionState(
acceptInviteAction,
initialState,
);
return (
<form action={formAction} className="space-y-5">
{/* Token als verstecktes Feld */}
<input type="hidden" name="token" value={token} />
<div className="space-y-1.5">
<p className="text-sm text-muted-foreground">
Du wurdest eingeladen als: <strong>{userName}</strong>
</p>
</div>
{/* Passwort */}
<div className="space-y-1.5">
<Label htmlFor="password">Passwort wählen</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
aria-invalid={!!state.errors?.password}
/>
{state.errors?.password?.[0] ? (
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
) : (
<p className="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p>
)}
</div>
{/* Passwort bestätigen */}
<div className="space-y-1.5">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
aria-invalid={!!state.errors?.confirmPassword}
/>
{state.errors?.confirmPassword?.[0] && (
<p className="text-xs text-destructive">
{state.errors.confirmPassword[0]}
</p>
)}
</div>
{/* DSGVO-Pflicht */}
<div className="rounded-md border p-4 space-y-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Einwilligungen (Pflicht)
</p>
<div className="flex items-start gap-3">
<Checkbox
id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy"
required
aria-invalid={!!state.errors?.acceptPrivacyPolicy}
/>
<div className="space-y-0.5">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
Ich habe die{" "}
<a
href="/datenschutz"
className="underline"
target="_blank"
rel="noreferrer"
>
Datenschutzerklärung
</a>{" "}
gelesen und akzeptiere sie. <span className="text-destructive">*</span>
</Label>
{state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">
{state.errors.acceptPrivacyPolicy[0]}
</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="directoryOptIn"
name="directoryOptIn"
aria-invalid={!!state.errors?.directoryOptIn}
/>
<div className="space-y-0.5">
<Label htmlFor="directoryOptIn" className="text-sm font-normal">
Ich stimme zu, dass meine Kontaktdaten im internen Kita-Adressbuch
für andere Eltern sichtbar sind (Opt-In, jederzeit widerrufbar).
</Label>
</div>
</div>
</div>
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending} size="lg">
{pending ? "Wird eingerichtet…" : "Konto aktivieren & anmelden"}
</Button>
</form>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { Baby } from "lucide-react";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { InviteForm } from "./invite-form";
export const metadata = { title: "Konto aktivieren · Kita-Planer" };
// =====================================================================
// /invite/[token] · Öffentliche Seite (kein Auth erforderlich)
// ---------------------------------------------------------------------
// 1. Token gegen DB prüfen (existiert + nicht abgelaufen)
// 2. User laden (Name für personalisierte Begrüßung)
// 3. Invite bereits eingelöst → Hinweis
// 4. Gültiger Token → InviteForm
// =====================================================================
export default async function InvitePage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
// Token in DB nachschlagen
const verificationToken = await prisma.verificationToken.findUnique({
where: { token },
});
// Unbekannter Token → 404
if (!verificationToken) {
notFound();
}
const isExpired = verificationToken.expires < new Date();
// User über identifier (= userId) laden
const user = await prisma.user.findUnique({
where: { id: verificationToken.identifier },
select: {
firstName: true,
lastName: true,
email: true,
passwordHash: true,
kita: { select: { name: true } },
},
});
if (!user) notFound();
const userName = `${user.firstName} ${user.lastName}`;
const alreadyAccepted = user.passwordHash !== "";
return (
<div className="flex min-h-screen flex-col bg-muted/30">
{/* Mini-Header */}
<header className="border-b bg-background">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-2.5 px-6">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Baby className="h-4 w-4" />
</div>
<span className="text-sm font-semibold">Kita-Planer</span>
{user.kita && (
<>
<span className="text-sm text-muted-foreground">·</span>
<span className="text-sm text-muted-foreground">
{user.kita.name}
</span>
</>
)}
</div>
</header>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<Card className="w-full max-w-md">
<CardHeader className="space-y-2">
{isExpired ? (
<>
<CardTitle>Einladungslink abgelaufen</CardTitle>
<CardDescription>
Dieser Link ist nicht mehr gültig. Bitte wende dich an deinen
Kita-Administrator, um einen neuen Link zu erhalten.
</CardDescription>
</>
) : alreadyAccepted ? (
<>
<CardTitle>Konto bereits aktiviert</CardTitle>
<CardDescription>
Du hast diesen Einladungslink bereits verwendet. Melde dich
einfach mit deiner E-Mail-Adresse an.
</CardDescription>
</>
) : (
<>
<CardTitle>Willkommen, {user.firstName}!</CardTitle>
<CardDescription>
Du wurdest zur Kita <strong>{user.kita?.name ?? "—"}</strong>{" "}
eingeladen. Setze dein Passwort, um dein Konto zu aktivieren.
</CardDescription>
</>
)}
</CardHeader>
<CardContent>
{isExpired || alreadyAccepted ? (
<div className="text-center">
<Link
href="/login"
className="text-sm font-medium underline underline-offset-4"
>
Zur Anmeldung
</Link>
</div>
) : (
<InviteForm token={token} userName={userName} />
)}
</CardContent>
</Card>
</main>
</div>
);
}