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>
This commit is contained in:
2026-09-15 19:01:47 +02:00
co-authored by Claude Opus 5
parent 6b8cdf543b
commit d9290a187c
79 changed files with 4576 additions and 19 deletions
+27 -1
View File
@@ -16,16 +16,22 @@ import {
import { createTenant } from "@/server/actions/admin";
import { getMailStatus } from "@/server/actions/mail";
import { MailStatusPanel } from "@/components/mail-status-panel";
import { getTranslations } from "next-intl/server";
import { TrialBadge } from "@/components/trial/trial-badge";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string; plan?: string }> }) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const params = await searchParams;
// L15 Testphase: Filter Test/Voll
const tt = await getTranslations("trial.platform");
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
const [tenants, mailStatus] = await Promise.all([
prisma.tenant.findMany({
where: planFilter ? { plan: planFilter } : undefined,
include: { _count: { select: { users: true } }, modules: true },
orderBy: { createdAt: "asc" },
}),
@@ -42,6 +48,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
actions={
<span className="flex items-center gap-2">
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/admin/trial" />}>{tt("newTrial")}</Button>
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
<Plus className="size-4" /> Neuer Kunde
</Button>
@@ -92,6 +99,23 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</div>
)}
<nav aria-label={tt("filter")} className="mt-4 flex flex-wrap gap-2">
{[
{ key: null, href: "/admin", label: tt("filterAll") },
{ key: "TRIAL", href: "/admin?plan=trial", label: tt("filterTrial") },
{ key: "FULL", href: "/admin?plan=full", label: tt("filterFull") },
].map((f) => (
<Link
key={f.href}
href={f.href}
aria-current={planFilter === f.key ? "page" : undefined}
className={`inline-flex min-h-11 items-center rounded-full border px-4 text-[13px] font-semibold ${planFilter === f.key ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]" : "text-muted-foreground hover:bg-muted"}`}
>
{f.label}
</Link>
))}
</nav>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
@@ -99,6 +123,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<TableHead>Kunde</TableHead>
<TableHead>Kürzel</TableHead>
<TableHead>Status</TableHead>
<TableHead>{tt("colPlan")}</TableHead>
<TableHead>Nutzer</TableHead>
<TableHead>Aktive Module</TableHead>
</TableRow>
@@ -114,6 +139,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</TableCell>
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
<TableCell><TrialBadge tenant={t} /></TableCell>
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
</TableRow>