Files
craftvia/src/components/trial/platform-forms.tsx
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

116 lines
5.2 KiB
TypeScript

"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTrialTenantAction, type PlatformTrialState } from "@/server/actions/trial-platform";
const ERR = "rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]";
function ErrorText({ state }: { state: PlatformTrialState }) {
const t = useTranslations("trial.platform.errors");
if (state.status !== "error") return null;
return <p role="alert" className={ERR}>{t.has(state.code) ? t(state.code) : t("failed")}</p>;
}
/** L15 Testphase: platform wizard "Testmandant anlegen" (one page, four sections). */
export function PlatformTrialCreateForm({ defaultEnd, min, max }: { defaultEnd: string; min: string; max: string }) {
const t = useTranslations("trial.platform.wizard");
const [state, action, pending] = useActionState<PlatformTrialState, FormData>(createTrialTenantAction, { status: "idle" });
const section = "shadow-card rounded-xl border bg-card p-5";
return (
<form action={action} className="grid gap-4">
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionCompany")}</legend>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="companyName">{t("companyName")} *</Label>
<Input id="companyName" name="companyName" required minLength={2} maxLength={120} className="mt-1 h-11" />
</div>
<div>
<Label htmlFor="sector">{t("sector")}</Label>
<Input id="sector" name="sector" maxLength={80} className="mt-1 h-11" />
</div>
</div>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionAdmin")}</legend>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="adminName">{t("adminName")} *</Label>
<Input id="adminName" name="adminName" required minLength={2} maxLength={120} className="mt-1 h-11" />
</div>
<div>
<Label htmlFor="adminEmail">{t("adminEmail")} *</Label>
<Input id="adminEmail" name="adminEmail" type="email" required maxLength={200} className="mt-1 h-11" />
</div>
</div>
<p className="mt-2 text-[12px] text-muted-foreground">{t("adminHint")}</p>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionPeriod")}</legend>
<div className="mt-3 max-w-xs">
<Label htmlFor="endDate">{t("endDate")} *</Label>
<Input id="endDate" name="endDate" type="date" required min={min} max={max} defaultValue={defaultEnd} className="mt-1 h-11" />
<p className="mt-1 text-[12px] text-muted-foreground">{t("endDateHint")}</p>
</div>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionSetup")}</legend>
<label className="mt-3 flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="sampleData" defaultChecked className="size-5" /> {t("sampleData")}
</label>
</fieldset>
<ErrorText state={state} />
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" disabled={pending}>{pending ? t("submitting") : t("submit")}</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href="/admin" />}>{t("cancel")}</Button>
</div>
</form>
);
}
/** L15 Testphase: confirmation form of one lifecycle operation (bound server action). */
export function TrialLifecycleForm({
action,
withDate,
defaultEnd,
min,
max,
closeHref,
destructive,
}: {
action: (prev: PlatformTrialState, fd: FormData) => Promise<PlatformTrialState>;
withDate: boolean;
defaultEnd?: string;
min?: string;
max?: string;
closeHref: string;
destructive?: boolean;
}) {
const t = useTranslations("trial.platform.ops");
const tw = useTranslations("trial.platform.wizard");
const [state, formAction, pending] = useActionState<PlatformTrialState, FormData>(action, { status: "idle" });
return (
<form action={formAction} className="space-y-3">
{withDate && (
<div className="max-w-xs">
<Label htmlFor="op-endDate">{tw("endDate")} *</Label>
<Input id="op-endDate" name="endDate" type="date" required min={min} max={max} defaultValue={defaultEnd} className="mt-1 h-11" />
</div>
)}
<label className="flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
</label>
<ErrorText state={state} />
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" variant={destructive ? "destructive" : "default"} disabled={pending}>{pending ? t("submitting") : t("submit")}</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>{t("close")}</Button>
</div>
</form>
);
}