Files
craftvia/src/server/actions/trial-signup.ts
T
msolarczekandClaude Opus 5 d9290a187c L15 Testphase & Onboarding: Selbstanmeldung mit Double-Opt-in, Plattform-Wizard, Nur-Lesen-Sperre, Export, Lebenszyklus-Job
- 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>
2026-09-15 19:01:47 +02:00

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