"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 = { // Bumped per call so the client can key uncontrolled inputs and force a // remount after a failed submit (React's
wipes them // otherwise). attempt: number; // Echoed checkbox states so failed submits don't lose the user's consent // ticks. Passwords are NEVER echoed back from the server. values?: { acceptPrivacyPolicy?: boolean; directoryOptIn?: boolean; }; errors?: { password?: string[]; confirmPassword?: string[]; acceptPrivacyPolicy?: string[]; directoryOptIn?: string[]; _form?: string[]; }; }; export const initialInviteState: InviteState = { attempt: 0 }; function echoCheckboxValues(formData: FormData): InviteState["values"] { return { acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on", directoryOptIn: formData.get("directoryOptIn") === "on", }; } export async function acceptInviteAction( prev: InviteState, formData: FormData, ): Promise { const attempt = prev.attempt + 1; const values = echoCheckboxValues(formData); 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 { attempt, values, 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 { attempt, values, errors: { _form: ["Einladungslink ist ungültig."] }, }; } if (verificationToken.expires < now) { return { attempt, values, 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 { attempt, values, errors: { _form: ["Benutzer nicht gefunden."] }, }; } if (user.passwordHash !== "") { // Invite wurde bereits eingelöst return { attempt, values, 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 { attempt }; }