- 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>
116 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|