- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt, Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status - /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung), Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot, Enumeration-Schutz, Slug-Kollisionen - Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern, umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit) - Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi), Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil - Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand - Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding - Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
64 lines
3.0 KiB
TypeScript
64 lines
3.0 KiB
TypeScript
"use server";
|
|
|
|
import { redirect } from "next/navigation";
|
|
import { AuthError } from "next-auth";
|
|
import { getLocale } from "next-intl/server";
|
|
import { signIn } from "@/server/auth";
|
|
import { clientIp } from "@/server/auth-selfservice";
|
|
import { signLoginTicket } from "@/server/login-ticket";
|
|
import { enforceTrialRateLimit } from "@/server/services/trial/abuse";
|
|
import { checkTrialStep, confirmTrialSignup, submitTrialSignup } from "@/server/services/trial/signup";
|
|
import type { FieldErrors } from "@/lib/trial/signup";
|
|
|
|
/**
|
|
* L15 Testphase — public self-service signup (no session). Registered as PUBLIC in
|
|
* scripts/check-module-guards.ts: every action checks the rate limit (per IP, per e-mail).
|
|
* Business logic in src/server/services/trial/signup.ts.
|
|
*/
|
|
|
|
export type TrialSubmitState = { status: "sent" } | { status: "invalid"; errors: FieldErrors };
|
|
export type TrialConfirmState = { status: "idle" } | { status: "invalid" } | { status: "expired" } | { status: "rate_limited" };
|
|
|
|
async function localeOrDefault(): Promise<string> {
|
|
try {
|
|
return await getLocale();
|
|
} catch {
|
|
return "de";
|
|
}
|
|
}
|
|
|
|
/** Server-side validation of one wizard step (Weiter). Writes nothing. */
|
|
export async function checkTrialStepAction(step: string, values: unknown): Promise<FieldErrors> {
|
|
const limit = enforceTrialRateLimit("trialStepCheck", { ip: await clientIp() });
|
|
if (!limit.allowed) return { _form: "rate_limited" };
|
|
return checkTrialStep(step, values);
|
|
}
|
|
|
|
/** Final submit: validates everything again, creates the pending signup and sends the confirmation mail. */
|
|
export async function submitTrialSignupAction(values: unknown): Promise<TrialSubmitState> {
|
|
const ip = await clientIp();
|
|
const email = values && typeof values === "object" && typeof (values as { email?: unknown }).email === "string" ? (values as { email: string }).email : null;
|
|
const limit = enforceTrialRateLimit("trialSignup", { ip, email });
|
|
if (!limit.allowed) return { status: "invalid", errors: { _form: "rate_limited" } };
|
|
return submitTrialSignup(values, { ip, locale: await localeOrDefault() });
|
|
}
|
|
|
|
/** Confirmation (POST from /testen/bestaetigen — mail scanners opening the GET link consume nothing). */
|
|
export async function confirmTrialSignupAction(_prev: TrialConfirmState, fd: FormData): Promise<TrialConfirmState> {
|
|
const limit = enforceTrialRateLimit("trialConfirm", { ip: await clientIp() });
|
|
if (!limit.allowed) return { status: "rate_limited" };
|
|
const token = String(fd.get("token") ?? "");
|
|
const result = await confirmTrialSignup(token);
|
|
if (result.status !== "ok") return { status: result.status };
|
|
|
|
// Directly signed in via the existing login finalisation (login-ticket provider). If that is not
|
|
// possible (e.g. MFA policy), the regular login page takes over.
|
|
try {
|
|
await signIn("login-ticket", { ticket: signLoginTicket(result.identityId, result.tenantSlug), redirectTo: "/dashboard?welcome=1" });
|
|
} catch (err) {
|
|
if (err instanceof AuthError) redirect("/login?trial=ready");
|
|
throw err; // NEXT_REDIRECT of a successful sign-in
|
|
}
|
|
return { status: "idle" };
|
|
}
|