Add non-destructive dev seed on startup
This commit is contained in:
@@ -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 {};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user