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 {};
}