Files
craftvia/src/app/(app)/settings/export/page.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

74 lines
4.2 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { Download, PackageOpen } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { formatInstantDate } from "@/lib/trial/dates";
import { requestExportAction } from "@/server/actions/trial-tenant";
import { listTenantExports } from "@/server/services/trial/export";
const TONE = { queued: "warn", running: "warn", done: "ok", failed: "mut", expired: "mut" } as const;
/** L15 Testphase: data export for tenant administrators (also available in the read-only state). */
export default async function DataExportPage({ searchParams }: { searchParams: Promise<{ requested?: string; error?: string }> }) {
const session = await requireSession();
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
const sp = await searchParams;
const [t, locale] = await Promise.all([getTranslations("trial.export"), getLocale()]);
const ctx = { db: dbForTenant(session.user.tenantId), tenantId: session.user.tenantId, userId: session.user.id, permissions: new Set(session.user.permissions ?? []) };
const exports = await listTenantExports(ctx);
const errorKey = sp.error === "export_running" ? "export_running" : "failed";
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<div className="shadow-card rounded-xl border bg-card p-5">
<div className="flex flex-wrap items-start gap-4">
<PackageOpen className="size-6 shrink-0 text-[var(--ui-primary)]" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm">{t("contents")}</p>
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("readOnlyHint")}</p>
</div>
<form action={requestExportAction}>
<Button type="submit" className="h-11">{t("request")}</Button>
</form>
</div>
{sp.requested && <p role="status" className="mt-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-sm text-[var(--ok)]">{t("requested")}</p>}
{sp.error && <p role="alert" className="mt-3 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(`errors.${errorKey}`)}</p>}
</div>
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
<h2 className="font-heading text-[15px] font-semibold">{t("listTitle")}</h2>
{exports.length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="mt-2 divide-y">
{exports.map((e) => {
const status = e.expired ? "expired" : (e.status as keyof typeof TONE);
return (
<li key={e.id} className="flex flex-wrap items-center gap-3 py-2.5 text-sm">
<Pill tone={TONE[status] ?? "mut"}>{t(`status.${status}`)}</Pill>
<span className="text-muted-foreground">{t("created")}: {e.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", { timeZone: "Europe/Berlin" })}</span>
{e.bytes != null && <span className="text-muted-foreground">{(e.bytes / 1024 / 1024).toFixed(1)} MB</span>}
{e.status === "failed" && e.error && <span className="text-[12.5px] text-[var(--risk)]">{e.error}</span>}
{e.status === "done" && !e.expired && (
<Link href={`/settings/export/${e.id}`} prefetch={false} className="ml-auto inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 font-semibold text-[var(--ui-primary)] hover:bg-muted">
<Download className="size-4" aria-hidden /> {t("download")}
{e.expiresAt && <span className="font-normal text-muted-foreground">({t("expires", { date: formatInstantDate(e.expiresAt, locale) })})</span>}
</Link>
)}
</li>
);
})}
</ul>
)}
</div>
</main>
);
}