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
+2
View File
@@ -26,6 +26,7 @@ import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { GettingStarted } from "@/components/trial/getting-started";
type SP = Record<string, string | string[] | undefined>;
@@ -65,6 +66,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
<GettingStarted ctx={ctx} welcome={sp.welcome === "1"} /> {/* L15 Testphase */}
{sp.module === "disabled" && (
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
{t("moduleDisabled")}
+2
View File
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context";
import { storeFile } from "@/server/services/documents/store";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* Multipart upload for the backoffice document tabs (customer, site, /documents).
@@ -19,6 +20,7 @@ export async function POST(req: Request) {
try {
assertSameOrigin(req);
const ctx = await requireApiContext("documents");
await assertTenantWritable(ctx.tenantId); // L15: expired trial → read-only
let form: FormData;
try {
+3
View File
@@ -14,6 +14,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { NotificationBell } from "@/components/notifications/bell";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
import { BackofficeFrame } from "@/components/backoffice-frame";
import { TrialBanner } from "@/components/trial/trial-banner";
export default async function AppLayout({
children,
@@ -111,6 +112,8 @@ export default async function AppLayout({
</form>
</>
}>
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
<TrialBanner tenantId={session.user.tenantId} variant="backoffice" canExport={(session.user.permissions ?? []).includes("tenant:manage")} />
{children}
</BackofficeFrame>
);
@@ -0,0 +1,27 @@
import { requireApiContext } from "@/server/api/context";
import { toErrorResponse } from "@/server/api/respond";
import { openTenantExport } from "@/server/services/trial/export";
/**
* GET /settings/export/<id> — download of a finished tenant data export (L15 Testphase).
* Session + DB-authoritative `tenant:manage`, tenant-bound row; a read, therefore also allowed for
* an expired (read-only) trial tenant.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const ctx = await requireApiContext(null, "tenant:manage");
const { id } = await params;
const { bytes, fileName } = await openTenantExport(ctx, id);
return new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${fileName.replace(/[^\w.\-]/g, "_")}"`,
"Content-Length": String(bytes.length),
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (err) {
return toErrorResponse(err);
}
}
+73
View File
@@ -0,0 +1,73 @@
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>
);
}
+3
View File
@@ -12,6 +12,7 @@ import { BottomNav } from "@/components/field/bottom-nav";
import { OnlineBadge } from "@/components/field/online-badge";
import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
import { OfflineRuntime } from "@/components/offline/offline-runtime";
import { TrialBanner } from "@/components/trial/trial-banner";
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> {
@@ -53,6 +54,8 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
<OnlineBadge />
</div>
</header>
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} />
+11 -1
View File
@@ -19,6 +19,7 @@ import { UserTable } from "@/components/user-table";
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -27,7 +28,7 @@ export default async function AdminTenantPage({
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
@@ -216,6 +217,15 @@ export default async function AdminTenantPage({
</div>
<div className="space-y-5">
{/* L15 Testphase: Enddatum, Umwandlung, Beenden, Löschung (Bestätigung + Plattform-Audit) */}
<TrialAdminCard
tenant={tenant}
isFullAdmin={isFullAdmin}
base={base}
op={sp.trial === "created" ? undefined : sp.trial}
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
/>
{/* Lebenszyklus */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
+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>
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { PlatformTrialCreateForm } from "@/components/trial/platform-forms";
import { addDaysToKey, todayKey } from "@/lib/trial/dates";
import { PLATFORM_TRIAL_MAX_DAYS, TRIAL_DEFAULT_DAYS } from "@/server/services/trial/config";
export const dynamic = "force-dynamic";
/** L15 Testphase: platform wizard "Testmandant anlegen" (access: (platform)/layout.tsx; action: full admins). */
export default async function PlatformTrialWizardPage() {
const t = await getTranslations("trial.platform.wizard");
const ta = await getTranslations("admin");
const today = todayKey();
return (
<main className="flex-1 p-6">
<Link href="/admin" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" /> {ta("backToOverview")}
</Link>
<div className="mt-2 max-w-3xl">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<PlatformTrialCreateForm defaultEnd={addDaysToKey(today, TRIAL_DEFAULT_DAYS)} min={today} max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)} />
</div>
</main>
);
}
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context";
import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?).
@@ -17,6 +18,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
try {
assertSameOrigin(req);
const ctx = await requireApiContext("work_orders", "document:write");
await assertTenantWritable(ctx.tenantId); // L15: not wrapped in withApi → explicit trial write lock
const form = await readFormData(req);
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { TrialConfirmForm } from "@/components/trial/confirm-form";
import { formatDateKey } from "@/lib/trial/dates";
import { peekTrialSignup } from "@/server/services/trial/signup";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("confirmTitle") };
}
export const dynamic = "force-dynamic";
/**
* L15 Testphase: landing page of the confirmation link. Shows the pending signup (link is only
* checked, not consumed); provisioning happens on the POST of the button (TrialConfirmForm).
*/
export default async function TrialConfirmPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
const { token = "" } = await searchParams;
const [t, locale] = await Promise.all([getTranslations("trial.confirm"), getLocale()]);
const signup = await peekTrialSignup(token);
return (
<div className="shadow-card mt-4 w-full max-w-md self-start rounded-2xl border bg-card p-6 sm:p-8">
<h1 className="font-heading text-xl font-semibold">{t("title")}</h1>
{signup ? (
<>
<p className="mt-1 text-sm text-muted-foreground">{t("intro")}</p>
<dl className="mt-4 divide-y rounded-lg border text-sm">
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("company")}</dt><dd className="text-right font-medium">{signup.companyName}</dd></div>
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("until")}</dt><dd className="font-medium">{formatDateKey(signup.trialEndDate, locale)}</dd></div>
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("sampleData")}</dt><dd className="font-medium">{signup.sampleData ? t("yes") : t("no")}</dd></div>
</dl>
<TrialConfirmForm token={token} />
</>
) : (
<>
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("invalid")}</p>
<div className="mt-4 flex flex-col gap-1 text-center">
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
<Link href="/login" className="flex min-h-11 items-center justify-center text-sm text-muted-foreground">{t("toLogin")}</Link>
</div>
</>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("privacyTitle") };
}
/** L15 Testphase: placeholder privacy notice (operator replaces the text before go-live). */
export default async function TrialPrivacyPage() {
const t = await getTranslations("trial");
return <LegalPlaceholder title={t("meta.privacyTitle")} paragraphs={[t("legal.privacyBody1"), t("legal.privacyBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/** L15 Testphase: public shell of /testen (no session; see PUBLIC_PATHS in src/proxy.ts). */
export default async function TrialPublicLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const t = await getTranslations("trial.public");
return (
<div className="flex flex-1 flex-col bg-background">
<header className="flex items-center justify-between gap-3 px-4 py-4 sm:px-8">
<Link href="/testen" className="flex min-h-11 items-center" aria-label="Craftvia">
<CraftviaLogo variant="horizontal" height={32} />
</Link>
<p className="text-[13px] text-muted-foreground">
<span className="hidden sm:inline">{t("haveAccount")} </span>
<Link href="/login" className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)]">{t("login")}</Link>
</p>
</header>
<main className="flex flex-1 justify-center px-4 pb-10 sm:px-8">{children}</main>
<footer className="flex flex-wrap justify-center gap-4 border-t px-4 py-3 text-[12.5px] text-muted-foreground">
<Link href="/testen/nutzungsbedingungen" className="inline-flex min-h-11 items-center hover:text-foreground">{t("terms")}</Link>
<Link href="/testen/datenschutz" className="inline-flex min-h-11 items-center hover:text-foreground">{t("privacy")}</Link>
</footer>
</div>
);
}
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("termsTitle") };
}
/** L15 Testphase: placeholder terms of use (operator replaces the text before go-live). */
export default async function TrialTermsPage() {
const t = await getTranslations("trial");
return <LegalPlaceholder title={t("meta.termsTitle")} paragraphs={[t("legal.termsBody1"), t("legal.termsBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { getLocale, getTranslations } from "next-intl/server";
import { CheckCircle2 } from "lucide-react";
import { TrialSignupWizard } from "@/components/trial/signup-wizard";
import { MODULES } from "@/lib/modules";
import { DEFAULT_PASSWORD_POLICY, describePasswordPolicy } from "@/lib/password-policy";
import { currentTrialBounds } from "@/server/services/trial/signup";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("title") };
}
// the date bounds depend on "today" — never prerender
export const dynamic = "force-dynamic";
/** L15 Testphase: public wizard "Kostenlos testen". */
export default async function TrialSignupPage() {
const [t, tm, locale] = await Promise.all([getTranslations("trial.public"), getTranslations("modules"), getLocale()]);
const modules = MODULES.map((m) => ({ key: m.key, label: tm(m.nav) }));
return (
<div className="grid w-full max-w-5xl gap-8 lg:grid-cols-[1fr_1.2fr] lg:items-start">
<section className="pt-2 lg:pt-10">
<h1 className="font-heading text-[28px] leading-tight font-semibold sm:text-[34px]">{t("heading")}</h1>
<p className="mt-3 text-[15px] text-muted-foreground">{t("intro")}</p>
<ul className="mt-6 space-y-3">
{(["benefit1", "benefit2", "benefit3"] as const).map((k) => (
<li key={k} className="flex items-start gap-2.5 text-[14.5px]">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--ok)]" aria-hidden />
{t(k)}
</li>
))}
</ul>
</section>
<section className="shadow-card relative rounded-2xl border bg-card p-5 sm:p-8">
<TrialSignupWizard bounds={currentTrialBounds()} modules={modules} passwordPolicy={describePasswordPolicy(DEFAULT_PASSWORD_POLICY)} locale={locale} />
</section>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { confirmTrialSignupAction, type TrialConfirmState } from "@/server/actions/trial-signup";
/** L15 Testphase: confirmation button (POST) — the GET link alone never provisions anything. */
export function TrialConfirmForm({ token }: { token: string }) {
const t = useTranslations("trial.confirm");
const [state, action, pending] = useActionState<TrialConfirmState, FormData>(confirmTrialSignupAction, { status: "idle" });
if (state.status === "invalid" || state.status === "expired") {
return (
<div className="mt-4 space-y-3">
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(state.status)}</p>
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
</div>
);
}
return (
<form action={action} className="mt-5 space-y-3">
<input type="hidden" name="token" value={token} />
{state.status === "rate_limited" && (
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("rate_limited")}</p>
)}
<Button type="submit" className="h-11 w-full" disabled={pending}>
{pending ? t("pending") : t("button")}
</Button>
</form>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CheckCircle2, Circle } from "lucide-react";
import type { ServiceCtx } from "@/server/services/context";
import { getOnboardingChecklist } from "@/server/services/trial/onboarding";
import { getTrialState } from "@/server/services/trial/state";
import { hideOnboardingAction, toggleOnboardingItemAction } from "@/server/actions/trial-tenant";
/** L15 Testphase: "Erste Schritte" card on the dashboard (tenant admins of trial-origin tenants). */
export async function GettingStarted({ ctx, welcome = false }: { ctx: ServiceCtx; welcome?: boolean }) {
const checklist = await getOnboardingChecklist(ctx);
if (!checklist) return null;
const [t, trial] = await Promise.all([getTranslations("trial.onboarding"), getTrialState(ctx.tenantId)]);
const writable = !trial.readOnly;
const percent = Math.round((checklist.completed / checklist.total) * 100);
return (
<section aria-labelledby="getting-started-title" className="shadow-card mb-4 rounded-xl border bg-card p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
{welcome && <p className="mb-1 text-sm font-semibold text-[var(--ok)]">{t("welcome")}</p>}
<h2 id="getting-started-title" className="font-heading text-[15px] font-semibold">{t("title")}</h2>
<p className="text-[12.5px] text-muted-foreground">{t("progress", { done: checklist.completed, total: checklist.total })}</p>
</div>
{writable && (
<form action={hideOnboardingAction}>
<button type="submit" className="min-h-11 rounded-md px-3 text-[13px] font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">{t("hide")}</button>
</form>
)}
</div>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={checklist.total} aria-valuenow={checklist.completed} aria-label={t("progress", { done: checklist.completed, total: checklist.total })}>
<div className="h-full rounded-full bg-[var(--ok)]" style={{ width: `${percent}%` }} />
</div>
<ul className="mt-3 divide-y">
{checklist.items.map((item) => (
<li key={item.key} className="flex flex-wrap items-center gap-3 py-2.5">
{item.done ? <CheckCircle2 className="size-5 shrink-0 text-[var(--ok)]" aria-hidden /> : <Circle className="size-5 shrink-0 text-muted-foreground" aria-hidden />}
<div className="min-w-0 flex-1">
<p className={item.done ? "text-sm font-semibold text-muted-foreground line-through" : "text-sm font-semibold"}>
<span className="sr-only">{item.done ? t("doneLabel") : t("openLabel")}: </span>
{t(`items.${item.key}.title`)}
</p>
<p className="text-[12.5px] text-muted-foreground">{t(`items.${item.key}.text`)}{item.auto ? ` · ${t("auto")}` : ""}</p>
</div>
<Link href={item.href} className="inline-flex min-h-11 items-center rounded-md px-3 text-[13px] font-semibold text-[var(--ui-primary)] hover:bg-muted">{t("open")}</Link>
{writable && !item.auto && (
<form action={toggleOnboardingItemAction}>
<input type="hidden" name="key" value={item.key} />
<input type="hidden" name="done" value={item.done ? "0" : "1"} />
<button type="submit" className="min-h-11 rounded-md border px-3 text-[13px] hover:bg-muted">{item.done ? t("markOpen") : t("markDone")}</button>
</form>
)}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,15 @@
import Link from "next/link";
/** L15 Testphase: simple legal text page (terms/privacy placeholders under /testen). */
export function LegalPlaceholder({ title, paragraphs, placeholder, back }: { title: string; paragraphs: string[]; placeholder: string; back: string }) {
return (
<article className="shadow-card mt-4 w-full max-w-2xl self-start rounded-2xl border bg-card p-6 sm:p-8">
<h1 className="font-heading text-xl font-semibold">{title}</h1>
<p className="mt-2 rounded-lg bg-muted px-3 py-2 text-[13px] text-muted-foreground">{placeholder}</p>
{paragraphs.map((p) => (
<p key={p} className="mt-3 text-sm leading-relaxed">{p}</p>
))}
<Link href="/testen" className="mt-4 inline-flex min-h-11 items-center text-sm font-semibold text-[var(--ui-primary)]">← {back}</Link>
</article>
);
}
+115
View File
@@ -0,0 +1,115 @@
"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>
);
}
+341
View File
@@ -0,0 +1,341 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Check, MailCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { diffDayKeys, formatDateKey, type TrialBounds } from "@/lib/trial/dates";
import {
TRIAL_COMPANY_SIZES,
TRIAL_SECTOR_KEYS,
TRIAL_STEPS,
validateTrialSignup,
validateTrialStep,
type FieldErrors,
type TrialSignupValues,
type TrialStep,
} from "@/lib/trial/signup";
import { checkTrialStepAction, submitTrialSignupAction } from "@/server/actions/trial-signup";
/**
* L15 Testphase: public 5-step wizard. All values live in one state object, so "Zurück" never loses
* input. Each step is validated in the browser (same rules as the server) and then by the server
* (`checkTrialStepAction`); the final submit validates everything again.
*/
type Props = {
bounds: TrialBounds;
modules: { key: string; label: string }[];
passwordPolicy: string;
locale: string;
};
const STEP_FIELDS: Record<TrialStep, (keyof TrialSignupValues)[]> = {
company: ["companyName", "sector", "companySize"],
account: ["adminName", "email", "password"],
period: ["trialEndDate"],
setup: ["sampleData", "modules"],
summary: ["acceptTerms", "acceptPrivacy"],
};
const fieldCls = "mt-1 h-11 text-base sm:text-sm";
const selectCls = "mt-1 h-11 w-full rounded-md border border-input bg-background px-3 text-base sm:text-sm";
const errorCls = "mt-1 text-[13px] text-[var(--risk)]";
export function TrialSignupWizard({ bounds, modules, passwordPolicy, locale }: Props) {
const t = useTranslations("trial");
const [step, setStep] = useState(0);
const [sectorKey, setSectorKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [values, setValues] = useState<TrialSignupValues>({
companyName: "",
sector: "",
companySize: "",
adminName: "",
email: "",
password: "",
trialEndDate: bounds.defaultEnd,
sampleData: true,
modules: modules.map((m) => m.key),
acceptTerms: false,
acceptPrivacy: false,
website: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
const [sent, setSent] = useState(false);
const [pending, startTransition] = useTransition();
const current = TRIAL_STEPS[step];
const set = <K extends keyof TrialSignupValues>(key: K, value: TrialSignupValues[K]) => {
setValues((v) => ({ ...v, [key]: value }));
setErrors((e) => ({ ...e, [key]: undefined, _form: undefined }));
};
const days = useMemo(() => (/^\d{4}-\d{2}-\d{2}$/.test(values.trialEndDate) ? diffDayKeys(bounds.today, values.trialEndDate) : null), [values.trialEndDate, bounds.today]);
const err = (key: keyof FieldErrors) => (errors[key] ? t(`errors.${errors[key]}`) : null);
function firstStepWithError(e: FieldErrors): number {
const idx = TRIAL_STEPS.findIndex((s) => STEP_FIELDS[s].some((f) => e[f]));
return idx === -1 ? step : idx;
}
function next() {
const local = validateTrialStep(current, values, bounds);
if (Object.keys(local).length) return setErrors(local);
startTransition(async () => {
const server = await checkTrialStepAction(current, values);
if (Object.keys(server).length) return setErrors(server);
setErrors({});
setStep((s) => Math.min(s + 1, TRIAL_STEPS.length - 1));
});
}
function submit() {
const local = validateTrialSignup(values, bounds);
if (Object.keys(local).length) {
setErrors(local);
return setStep(firstStepWithError(local));
}
startTransition(async () => {
const res = await submitTrialSignupAction(values);
if (res.status === "sent") return setSent(true);
setErrors(res.errors);
setStep(firstStepWithError(res.errors));
});
}
if (sent) {
return (
<div role="status" className="space-y-3 text-center">
<MailCheck className="mx-auto size-10 text-[var(--ok)]" aria-hidden />
<h2 className="font-heading text-xl font-semibold">{t("sent.title")}</h2>
<p className="text-sm">{t("sent.body")}</p>
<p className="text-[13px] text-muted-foreground">{t("sent.hint")}</p>
<Button variant="outline" className="h-11" onClick={() => { setSent(false); setStep(0); }}>
{t("sent.again")}
</Button>
</div>
);
}
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
if (current === "summary") submit();
else next();
}}
>
{/* Progress */}
<nav aria-label={t("progressLabel")}>
<p className="text-[13px] font-medium text-muted-foreground">{t("progress", { current: step + 1, total: TRIAL_STEPS.length })}</p>
<ol className="mt-2 grid grid-cols-5 gap-1.5">
{TRIAL_STEPS.map((s, i) => (
<li key={s} aria-current={i === step ? "step" : undefined}>
<button
type="button"
disabled={i > step || pending}
onClick={() => setStep(i)}
className="flex w-full flex-col items-start gap-1 text-left disabled:cursor-default"
>
<span className={cn("h-1.5 w-full rounded-full", i <= step ? "bg-[var(--ui-primary)]" : "bg-muted")} />
<span className={cn("hidden text-[11.5px] sm:inline", i === step ? "font-semibold text-foreground" : "text-muted-foreground")}>
{i < step && <Check className="mr-0.5 inline size-3" aria-hidden />}
{t(`steps.${s}`)}
</span>
</button>
</li>
))}
</ol>
</nav>
<h2 className="mt-5 font-heading text-lg font-semibold">{t(`steps.${current}`)}</h2>
{/* Honeypot: invisible for people, not announced to screen readers */}
<div aria-hidden className="absolute -left-[10000px] top-auto size-px overflow-hidden">
<label htmlFor="website">{t("fields.website")}</label>
<input id="website" name="website" tabIndex={-1} autoComplete="off" value={values.website} onChange={(e) => set("website", e.target.value)} />
</div>
<div className="mt-4 space-y-4">
{current === "company" && (
<>
<div>
<Label htmlFor="companyName">{t("fields.companyName")} *</Label>
<Input id="companyName" className={fieldCls} autoComplete="organization" value={values.companyName} onChange={(e) => set("companyName", e.target.value)} aria-invalid={!!errors.companyName} aria-describedby={errors.companyName ? "companyName-error" : undefined} />
{err("companyName") && <p id="companyName-error" className={errorCls}>{err("companyName")}</p>}
</div>
<div>
<Label htmlFor="sector">{t("fields.sector")}</Label>
<select
id="sector"
className={selectCls}
value={sectorKey}
onChange={(e) => {
const key = e.target.value;
setSectorKey(key);
set("sector", key && key !== "other" ? t(`sectors.${key}`) : "");
}}
>
<option value="">{t("fields.sectorChoose")}</option>
{TRIAL_SECTOR_KEYS.map((k) => (
<option key={k} value={k}>{t(`sectors.${k}`)}</option>
))}
</select>
{sectorKey === "other" && (
<>
<Label htmlFor="sectorOther" className="mt-3 block">{t("fields.sectorOther")}</Label>
<Input id="sectorOther" className={fieldCls} value={values.sector} onChange={(e) => set("sector", e.target.value)} />
</>
)}
{err("sector") && <p className={errorCls}>{err("sector")}</p>}
</div>
<div>
<Label htmlFor="companySize">{t("fields.companySize")}</Label>
<select id="companySize" className={selectCls} value={values.companySize} onChange={(e) => set("companySize", e.target.value)}>
<option value="">{t("fields.companySizeNone")}</option>
{TRIAL_COMPANY_SIZES.map((s) => (
<option key={s} value={s}>{t(`sizes.${s.replace(/[^0-9]/g, "_")}`)}</option>
))}
</select>
{err("companySize") && <p className={errorCls}>{err("companySize")}</p>}
</div>
</>
)}
{current === "account" && (
<>
<div>
<Label htmlFor="adminName">{t("fields.adminName")} *</Label>
<Input id="adminName" className={fieldCls} autoComplete="name" value={values.adminName} onChange={(e) => set("adminName", e.target.value)} aria-invalid={!!errors.adminName} />
{err("adminName") && <p className={errorCls}>{err("adminName")}</p>}
</div>
<div>
<Label htmlFor="email">{t("fields.email")} *</Label>
<Input id="email" type="email" inputMode="email" className={fieldCls} autoComplete="email" value={values.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!errors.email} aria-describedby="email-hint" />
<p id="email-hint" className="mt-1 text-[12.5px] text-muted-foreground">{t("fields.emailHint")}</p>
{err("email") && <p className={errorCls}>{err("email")}</p>}
</div>
<div>
<Label htmlFor="password">{t("fields.password")} *</Label>
<Input id="password" type={showPassword ? "text" : "password"} className={fieldCls} autoComplete="new-password" value={values.password} onChange={(e) => set("password", e.target.value)} aria-invalid={!!errors.password} aria-describedby="password-hint" />
<p id="password-hint" className="mt-1 text-[12.5px] text-muted-foreground">{t("fields.passwordHint", { policy: passwordPolicy })}</p>
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" className="size-5" checked={showPassword} onChange={(e) => setShowPassword(e.target.checked)} />
{t("fields.showPassword")}
</label>
{err("password") && <p className={errorCls}>{err("password")}</p>}
</div>
</>
)}
{current === "period" && (
<div>
<Label htmlFor="trialEndDate">{t("fields.trialEndDate")} *</Label>
<Input id="trialEndDate" type="date" className={fieldCls} min={bounds.min} max={bounds.max} value={values.trialEndDate} onChange={(e) => set("trialEndDate", e.target.value)} aria-invalid={!!errors.trialEndDate} aria-describedby="period-range" />
<p id="period-range" className="mt-1 text-[12.5px] text-muted-foreground">
{t("period.range", { min: formatDateKey(bounds.min, locale), max: formatDateKey(bounds.max, locale) })}
</p>
{err("trialEndDate") ? (
<p className={errorCls}>{err("trialEndDate")}</p>
) : (
days !== null && days > 0 && (
<p className="mt-3 rounded-lg bg-[var(--ui-primary-soft)] px-3 py-2 text-sm font-semibold" aria-live="polite">
{t("period.until", { date: formatDateKey(values.trialEndDate, locale), days })}
</p>
)
)}
<p className="mt-3 text-[13px] text-muted-foreground">{t("period.afterEnd")}</p>
</div>
)}
{current === "setup" && (
<>
<label className="flex min-h-11 items-start gap-3 rounded-lg border p-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.sampleData} onChange={(e) => set("sampleData", e.target.checked)} />
<span>
<span className="block text-sm font-semibold">{t("fields.sampleData")}</span>
<span className="block text-[12.5px] text-muted-foreground">{t("fields.sampleDataHint")}</span>
</span>
</label>
<fieldset>
<legend className="text-sm font-semibold">{t("fields.modules")}</legend>
<p className="text-[12.5px] text-muted-foreground">{t("fields.modulesHint")}</p>
<div className="mt-2 grid gap-1 sm:grid-cols-2">
{modules.map((m) => (
<label key={m.key} className="flex min-h-11 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted">
<input
type="checkbox"
className="size-5"
checked={values.modules.includes(m.key)}
onChange={(e) => set("modules", e.target.checked ? [...values.modules, m.key] : values.modules.filter((k) => k !== m.key))}
/>
{m.label}
</label>
))}
</div>
{err("modules") && <p className={errorCls}>{err("modules")}</p>}
</fieldset>
</>
)}
{current === "summary" && (
<>
<p className="text-sm text-muted-foreground">{t("summary.intro")}</p>
<dl className="divide-y rounded-lg border text-sm">
{[
{ step: 0, label: t("steps.company"), value: `${values.companyName} · ${values.sector || t("summary.noSector")}` },
{ step: 1, label: t("steps.account"), value: `${values.adminName} · ${values.email}` },
{ step: 2, label: t("steps.period"), value: days !== null ? t("period.until", { date: formatDateKey(values.trialEndDate, locale), days }) : values.trialEndDate },
{ step: 3, label: t("steps.setup"), value: `${values.sampleData ? t("summary.sampleYes") : t("summary.sampleNo")} · ${t("summary.modulesCount", { count: values.modules.length, total: modules.length })}` },
].map((row) => (
<div key={row.step} className="flex items-start justify-between gap-3 px-3 py-2.5">
<div className="min-w-0">
<dt className="text-[12px] text-muted-foreground">{row.label}</dt>
<dd className="break-words">{row.value}</dd>
</div>
<button type="button" className="min-h-11 shrink-0 px-2 text-[13px] font-semibold text-[var(--ui-primary)]" onClick={() => setStep(row.step)}>
{t("summary.edit")}
</button>
</div>
))}
</dl>
<label className="flex min-h-11 items-start gap-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.acceptTerms} onChange={(e) => set("acceptTerms", e.target.checked)} aria-invalid={!!errors.acceptTerms} />
<span className="text-sm">
{t.rich("fields.acceptTerms", { link: () => <Link href="/testen/nutzungsbedingungen" target="_blank" className="font-semibold text-[var(--ui-primary)] underline">{t("public.terms")}</Link> })}
</span>
</label>
{err("acceptTerms") && <p className={errorCls}>{err("acceptTerms")}</p>}
<label className="flex min-h-11 items-start gap-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.acceptPrivacy} onChange={(e) => set("acceptPrivacy", e.target.checked)} aria-invalid={!!errors.acceptPrivacy} />
<span className="text-sm">
{t.rich("fields.acceptPrivacy", { link: () => <Link href="/testen/datenschutz" target="_blank" className="font-semibold text-[var(--ui-primary)] underline">{t("public.privacy")}</Link> })}
</span>
</label>
{err("acceptPrivacy") && <p className={errorCls}>{err("acceptPrivacy")}</p>}
</>
)}
</div>
{err("_form") && (
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">
{err("_form")}
</p>
)}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="outline" className="h-11 min-w-24" disabled={step === 0 || pending} onClick={() => { setErrors({}); setStep((s) => Math.max(0, s - 1)); }}>
{t("actions.back")}
</Button>
<Button type="submit" className="h-11 min-w-32" disabled={pending}>
{current === "summary" ? (pending ? t("actions.submitting") : t("actions.submit")) : pending ? t("actions.checking") : t("actions.next")}
</Button>
</div>
</form>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { Pill } from "@/components/mockup-ui";
import { addDaysToKey, formatDateKey, formatInstantDate, todayKey } from "@/lib/trial/dates";
import { trialLifecycleAction } from "@/server/actions/trial-platform";
import { PLATFORM_TRIAL_MAX_DAYS } from "@/server/services/trial/config";
import { computeTrialState, type TenantTrialRow } from "@/server/services/trial/state";
import { TrialLifecycleForm } from "./platform-forms";
const OPS = ["extend", "convert", "end", "schedule", "cancel"] as const;
type Op = (typeof OPS)[number];
/**
* L15 Testphase: trial card on the platform tenant detail page with lifecycle actions (full admins).
* Each action opens a confirmation popup (?trial=<op>, existing Modal pattern).
*/
export async function TrialAdminCard({
tenant,
isFullAdmin,
base,
op,
notice,
}: {
tenant: TenantTrialRow & { id: string; trialSource: string | null; status: string };
isFullAdmin: boolean;
base: string;
op?: string;
notice?: "created" | "invited" | "done" | null;
}) {
if (!tenant.trialStartedAt) return null;
const [t, locale] = await Promise.all([getTranslations("trial.platform"), getLocale()]);
const state = computeTrialState(tenant);
const deleted = !!tenant.trialDeletedAt;
const isTrial = tenant.plan === "TRIAL" && !deleted;
const today = todayKey();
const activeOp = isFullAdmin && isTrial && (OPS as readonly string[]).includes(op ?? "") ? (op as Op) : null;
const stateText = deleted ? t("card.stateDeleted") : tenant.plan === "FULL" ? t("card.stateConverted") : state.expired ? t("card.stateExpired") : t("card.stateActive");
const actions: { op: Op; show: boolean }[] = [
{ op: "extend", show: true },
{ op: "convert", show: true },
{ op: "end", show: !state.expired },
{ op: "schedule", show: !tenant.deletionDueAt },
{ op: "cancel", show: !!tenant.deletionDueAt },
];
return (
<div className="shadow-card rounded-xl border bg-card p-5" data-trial-card>
<p className="mb-2 font-heading text-sm font-semibold">{t("card.title")}</p>
{notice && <p role="status" className="mb-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-[12.5px] text-[var(--ok)]">{t(`card.${notice}`)}</p>}
<dl className="space-y-1.5 text-[13px]">
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.state")}</dt><dd><Pill tone={deleted ? "mut" : tenant.plan === "FULL" ? "ok" : "warn"}>{stateText}</Pill></dd></div>
{state.endDateKey && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.until")}</dt><dd>{formatDateKey(state.endDateKey, locale)}</dd></div>}
{isTrial && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.deletion")}</dt><dd>{tenant.deletionDueAt ? formatInstantDate(tenant.deletionDueAt, locale) : t("card.deletionNone")}</dd></div>}
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.source")}</dt><dd>{tenant.trialSource === "platform" ? t("card.sourcePlatform") : t("card.sourceSelf")}</dd></div>
</dl>
{isFullAdmin && isTrial && (
<div className="mt-3 space-y-2">
{actions.filter((a) => a.show).map((a) => (
<Link key={a.op} href={`${base}?trial=${a.op}`} scroll={false} className="block">
<Button variant="outline" size="sm" className={a.op === "end" || a.op === "schedule" ? "min-h-11 w-full justify-center border-destructive/40 text-destructive" : "min-h-11 w-full justify-center"}>
{t(`card.${a.op}`)}
</Button>
</Link>
))}
</div>
)}
{activeOp && (
<Modal title={t(`ops.${activeOp}Title`)} closeHref={base} closeLabel={t("ops.close")}>
<div className="space-y-3 p-5">
<p className="text-sm">{t(`ops.${activeOp}Text`)}</p>
<TrialLifecycleForm
action={trialLifecycleAction.bind(null, tenant.id, activeOp)}
withDate={activeOp === "extend"}
defaultEnd={state.endDateKey && state.endDateKey >= today ? addDaysToKey(state.endDateKey, 14) : addDaysToKey(today, 14)}
min={today}
max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)}
closeHref={base}
destructive={activeOp === "end" || activeOp === "schedule"}
/>
</div>
</Modal>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { getLocale, getTranslations } from "next-intl/server";
import { Pill } from "@/components/mockup-ui";
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
import { computeTrialState, type TenantTrialRow } from "@/server/services/trial/state";
/** L15 Testphase: plan badge(s) in the platform tenant list ("Test bis …", "abgelaufen – nur lesen", "Löschung am …"). */
export async function TrialBadge({ tenant }: { tenant: TenantTrialRow }) {
const [t, locale] = await Promise.all([getTranslations("trial.platform"), getLocale()]);
if (tenant.trialDeletedAt) return <Pill tone="mut">{t("badgeDeleted")}</Pill>;
const state = computeTrialState(tenant);
if (!state.isTrial) return <Pill tone="ok">{t("badgeFull")}</Pill>;
return (
<span className="flex flex-wrap gap-1.5">
{state.expired ? <Pill tone="warn">{t("badgeExpired")}</Pill> : <Pill tone="warn">{t("badgeUntil", { date: formatDateKey(state.endDateKey!, locale) })}</Pill>}
{state.deletionDueAt && <Pill tone="mut">{t("badgeDeletion", { date: formatInstantDate(state.deletionDueAt, locale) })}</Pill>}
</span>
);
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Hourglass, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
import { trialContactEmail } from "@/server/services/trial/config";
import { getTrialState } from "@/server/services/trial/state";
/**
* L15 Testphase: banner in the backoffice and mobile shell — countdown from 7 days before the end,
* read-only notice (+ deletion date, contact, export link) after expiry. Status is carried by text
* and icon, colour only supports it.
*/
export async function TrialBanner({ tenantId, variant, canExport = false }: { tenantId: string; variant: "backoffice" | "mobile"; canExport?: boolean }) {
const state = await getTrialState(tenantId);
if (!state.isTrial || (!state.expired && !state.showCountdown)) return null;
const [t, locale] = await Promise.all([getTranslations("trial.banner"), getLocale()]);
const contact = trialContactEmail();
const Icon = state.expired ? Lock : Hourglass;
return (
<div
role="status"
data-trial-banner={state.expired ? "expired" : "countdown"}
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 text-[13px]",
state.expired ? "bg-[rgba(198,66,66,0.08)] text-foreground" : "bg-[rgba(200,121,18,0.10)] text-foreground",
variant === "mobile" && "text-[14px]",
)}
>
<Icon className={cn("size-4 shrink-0", state.expired ? "text-[var(--risk)]" : "text-[var(--warn)]")} aria-hidden />
<span className="font-semibold">
{state.expired ? t("expired") : t("endsIn", { days: Math.max(0, state.daysLeft ?? 0) })}
</span>
{!state.expired && state.endDateKey && <span>{t("endsOn", { date: formatDateKey(state.endDateKey, locale) })}</span>}
{state.expired && state.deletionDueAt && <span>{t("deletion", { date: formatInstantDate(state.deletionDueAt, locale) })}</span>}
<span className="text-muted-foreground">
{contact ? (
<>
{t("contact", { email: "" })}
<a href={`mailto:${contact}`} className="font-semibold text-[var(--ui-primary)] underline">{contact}</a>
</>
) : (
t("contactGeneric")
)}
</span>
{state.expired && canExport && (
<Link href="/settings/export" className="ml-auto inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] underline">
{t("export")}
</Link>
)}
</div>
);
}
+2
View File
@@ -19,6 +19,7 @@ import {
LayoutGrid,
MapPinned,
Receipt,
Download,
type LucideIcon,
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules";
@@ -77,6 +78,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
{ href: "/settings/lotse", label: "lotse", icon: Compass, permissions: ["tenant:manage"], section: "admin" },
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
];
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
+68
View File
@@ -0,0 +1,68 @@
/**
* L15 Testphase: calendar-day helpers in Europe/Berlin (client-safe, no dependencies).
* Date keys are `YYYY-MM-DD`. A trial "until 30.09." ends at the START of 01.10. in Berlin
* (exclusive end instant, stored in `Tenant.trialEndsAt`).
*/
import { dayWindow, localDateKey } from "@/lib/reports/dates";
export const TRIAL_TZ = "Europe/Berlin";
const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
export function isDateKey(value: unknown): value is string {
if (typeof value !== "string" || !DATE_KEY.test(value)) return false;
const [y, m, d] = value.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
}
/** Today's date key in Berlin. */
export function todayKey(now: Date = new Date()): string {
return localDateKey(now, TRIAL_TZ);
}
export function addDaysToKey(key: string, days: number): string {
const [y, m, d] = key.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10);
}
/** Whole calendar days from `from` to `to` (negative when `to` lies before `from`). */
export function diffDayKeys(from: string, to: string): number {
const [y1, m1, d1] = from.split("-").map(Number);
const [y2, m2, d2] = to.split("-").map(Number);
return Math.round((Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86_400_000);
}
/** Exclusive end instant of the chosen last trial day (start of the next day in Berlin). */
export function trialEndInstant(endDateKey: string): Date {
return dayWindow(endDateKey, TRIAL_TZ).end;
}
/** Last trial day (date key) of a stored exclusive end instant. */
export function trialEndDateKey(endsAt: Date): string {
return localDateKey(new Date(endsAt.getTime() - 1), TRIAL_TZ);
}
/** `TT.MM.JJJJ` (de) or `DD/MM/YYYY` (en) of a date key. */
export function formatDateKey(key: string, locale: string = "de"): string {
const [y, m, d] = key.split("-");
return locale === "en" ? `${d}/${m}/${y}` : `${d}.${m}.${y}`;
}
/** Formatted Berlin calendar date of an instant. */
export function formatInstantDate(instant: Date, locale: string = "de"): string {
return formatDateKey(localDateKey(instant, TRIAL_TZ), locale);
}
export type TrialBounds = { today: string; min: string; max: string; defaultEnd: string; maxDays: number };
/** Allowed end dates of the self-service wizard: tomorrow … today + maxDays (default today + 14). */
export function trialBounds(now: Date, maxDays: number, defaultDays = 14): TrialBounds {
const today = todayKey(now);
return {
today,
min: addDaysToKey(today, 1),
max: addDaysToKey(today, maxDays),
defaultEnd: addDaysToKey(today, Math.min(defaultDays, maxDays)),
maxDays,
};
}
+107
View File
@@ -0,0 +1,107 @@
/**
* L15 Testphase: wizard data model + validation (client-safe — used by the wizard for instant
* feedback AND by the server actions/services as the authoritative check).
* Errors are stable codes; the UI resolves them via messages `trial.errors.<code>`.
*/
import { z } from "zod";
import { DEFAULT_PASSWORD_POLICY, validatePassword } from "@/lib/password-policy";
import { MODULE_KEYS } from "@/lib/modules";
import { isDateKey, type TrialBounds } from "@/lib/trial/dates";
/** Sector presets (labels in messages `trial.sectors.<key>`); "other" = free text. */
export const TRIAL_SECTOR_KEYS = ["shk", "electrical", "roofing", "carpentry", "painting", "facility", "construction", "metal", "other"] as const;
export const TRIAL_COMPANY_SIZES = ["1-5", "6-20", "21-50", "51-200", "200+"] as const;
export const TRIAL_STEPS = ["company", "account", "period", "setup", "summary"] as const;
export type TrialStep = (typeof TRIAL_STEPS)[number];
export type TrialSignupValues = {
companyName: string;
/** Stored sector text (preset label or free text). */
sector: string;
companySize: string;
adminName: string;
email: string;
password: string;
trialEndDate: string;
sampleData: boolean;
modules: string[];
acceptTerms: boolean;
acceptPrivacy: boolean;
/** Honeypot — must stay empty (hidden from people, filled by naive bots). */
website: string;
};
/** `_form` = error not tied to one field (malformed request, rate limit). */
export type FieldErrors = Partial<Record<keyof TrialSignupValues | "_form", string>>;
/** Shape/coercion of untrusted input (server side). Content rules follow in `validateTrialStep`. */
export const trialSignupInputSchema = z.object({
companyName: z.string().max(500).default(""),
sector: z.string().max(500).default(""),
companySize: z.string().max(20).default(""),
adminName: z.string().max(500).default(""),
email: z.string().max(500).default(""),
password: z.string().max(500).default(""),
trialEndDate: z.string().max(20).default(""),
sampleData: z.coerce.boolean().default(true),
modules: z.array(z.string().max(40)).max(50).default([...MODULE_KEYS]),
acceptTerms: z.coerce.boolean().default(false),
acceptPrivacy: z.coerce.boolean().default(false),
website: z.string().max(500).default(""),
});
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
/** Normalised values (trimmed, lower-case e-mail, known modules only); null for malformed input. */
export function normalizeTrialValues(input: unknown): TrialSignupValues | null {
const parsed = trialSignupInputSchema.safeParse(input ?? {});
if (!parsed.success) return null;
const v = parsed.data;
return {
...v,
companyName: v.companyName.trim(),
sector: v.sector.trim(),
companySize: v.companySize.trim(),
adminName: v.adminName.trim(),
email: v.email.trim().toLowerCase(),
trialEndDate: v.trialEndDate.trim(),
modules: [...new Set(v.modules.filter((m) => (MODULE_KEYS as readonly string[]).includes(m)))],
};
}
export function validateTrialStep(step: TrialStep, v: TrialSignupValues, bounds: TrialBounds): FieldErrors {
const e: FieldErrors = {};
switch (step) {
case "company":
if (v.companyName.length < 2) e.companyName = "company_required";
else if (v.companyName.length > 120) e.companyName = "too_long";
if (v.sector.length > 80) e.sector = "too_long";
if (v.companySize && !(TRIAL_COMPANY_SIZES as readonly string[]).includes(v.companySize)) e.companySize = "invalid_choice";
break;
case "account":
if (v.adminName.length < 2) e.adminName = "name_required";
else if (v.adminName.length > 120) e.adminName = "too_long";
if (!EMAIL.test(v.email) || v.email.length > 200) e.email = "email_invalid";
if (validatePassword(v.password, DEFAULT_PASSWORD_POLICY).length > 0 || v.password.length > 200) e.password = "password_policy";
break;
case "period":
if (!isDateKey(v.trialEndDate)) e.trialEndDate = "date_invalid";
else if (v.trialEndDate < bounds.min) e.trialEndDate = "date_too_early";
else if (v.trialEndDate > bounds.max) e.trialEndDate = "date_too_late";
break;
case "setup":
if (v.modules.length === 0) e.modules = "modules_required";
break;
case "summary":
if (!v.acceptTerms) e.acceptTerms = "terms_required";
if (!v.acceptPrivacy) e.acceptPrivacy = "privacy_required";
break;
}
return e;
}
/** All steps at once (final submit). */
export function validateTrialSignup(v: TrialSignupValues, bounds: TrialBounds): FieldErrors {
return TRIAL_STEPS.reduce<FieldErrors>((acc, step) => ({ ...acc, ...validateTrialStep(step, v, bounds) }), {});
}
+3 -1
View File
@@ -9,7 +9,9 @@ import { NextResponse, type NextRequest } from "next/server";
// SEC2: die Wiederherstellungs-Abläufe müssen ohne Session erreichbar sein —
// der Nutzer ist gerade ausgesperrt. Ihre Absicherung sind Rate-Limit,
// Enumeration-Neutralität und single-use-Tokens, nicht dieses Gate.
const PUBLIC_PATHS = ["/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth"];
// L15 Testphase: `/testen` (Wizard, Bestätigung, Nutzungsbedingungen, Datenschutz) ist öffentlich —
// abgesichert über Rate-Limit je IP/E-Mail, Honeypot, Double-Opt-in und Enumeration-Neutralität.
const PUBLIC_PATHS = ["/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth", "/testen"];
// Plattform-Bereich (getrennte Session/Login): diese Routen werden über das
// Plattform-Cookie gegatet und leiten anonyme Besucher auf /platform/login —
+4
View File
@@ -4,6 +4,7 @@ import { ForbiddenError, type Permission } from "@/server/rbac";
import { assertModuleEnabled } from "@/server/modules";
import { writeAuditLog } from "@/server/audit";
import { isTokenStillValid } from "@/server/sessions";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* Einheitlicher Einstieg für mutierende Server-Actions eines gegateten Moduls (§3.4).
@@ -83,6 +84,9 @@ export function moduleGuard(moduleKey: string) {
}
await assertModuleEnabled(session, moduleKey);
// L15 Testphase: abgelaufene Testmandanten sind nur lesbar — zentrale Schreibsperre
// (wirft ServiceError "blocked"/"trial_expired").
await assertTenantWritable(session.user.tenantId);
// `permissions` = DB-authoritative effective set; domain services derive their
// scope decisions from it (src/server/services/context.ts#ctxFromGuard).
return { session, db, permissions: effective as ReadonlySet<string> };
+2
View File
@@ -6,6 +6,7 @@ import { requireApiContext } from "@/server/api/context";
import { requireSession } from "@/server/auth";
import { requirePermission } from "@/server/rbac";
import { updateLotseSettings } from "@/server/services/lotse/settings";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* /settings/lotse: switch the Lotse module on/off and set the address form (lane L9).
@@ -19,6 +20,7 @@ export async function saveLotseSettings(fd: FormData): Promise<void> {
try {
requirePermission(session, "tenant:manage"); // fast JWT check; requireApiContext re-checks against the DB
const ctx = await requireApiContext(null, "tenant:manage");
await assertTenantWritable(ctx.tenantId); // L15: expired trial → settings read-only
// L10b: empty = platform default (null); invalid numbers are rejected by the service schema
const rawLimit = String(fd.get("monthlyTokenLimit") ?? "").trim();
await updateLotseSettings(ctx, {
+2
View File
@@ -6,6 +6,7 @@ import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { assertTenantWritable } from "@/server/services/trial/state";
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
@@ -14,6 +15,7 @@ export async function updateTenantSettings(formData: FormData) {
const session = await requireSession();
requirePermission(session, "tenant:manage");
const tenantId = session.user.tenantId;
await assertTenantWritable(tenantId); // L15: expired trial → read-only
const db = dbForTenant(tenantId);
const orgName = z.string().trim().min(1).parse(formData.get("orgName"));
+3
View File
@@ -11,6 +11,7 @@ import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { writeAuditLog } from "@/server/audit";
import { assertTenantWritable, isTrialExpiredError, TRIAL_READ_ONLY_MESSAGE } from "@/server/services/trial/state";
/**
* Benutzer- & Rollenverwaltung durch den Mandanten-Admin (Paket B). EXEMPT vom
@@ -38,6 +39,7 @@ export type EditUserState =
async function ctx(permission: "user:manage" | "role:manage") {
const session = await requireSession();
requirePermission(session, permission);
await assertTenantWritable(session.user.tenantId); // L15: expired trial → user administration read-only
return { session, tenantId: session.user.tenantId, db: dbForTenant(session.user.tenantId) };
}
@@ -48,6 +50,7 @@ async function ctx(permission: "user:manage" | "role:manage") {
* ("Aktion konnte nicht abgeschlossen werden" + Formularverlust).
*/
function actionError(err: unknown): string {
if (isTrialExpiredError(err)) return TRIAL_READ_ONLY_MESSAGE;
const msg = err instanceof Error ? err.message : String(err);
return msg || "Die Aktion konnte nicht ausgeführt werden.";
}
+73
View File
@@ -0,0 +1,73 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { ServiceError } from "@/server/services/context";
import {
cancelTrialDeletion,
changeTrialEndDate,
convertTenantToFull,
createPlatformTrialTenant,
endTrialNow,
scheduleTrialDeletion,
} from "@/server/services/trial/admin";
/**
* L15 Testphase — platform admin: wizard "Testmandant anlegen" and lifecycle actions.
* EXEMPT from module gating; authorisation via the separate platform session (full admins only).
* The services re-check the actor against the PlatformAdmin store and write the audit.
*/
export type PlatformTrialState = { status: "idle" } | { status: "error"; code: string };
function errorCode(err: unknown): string {
if (err instanceof ServiceError) return err.message;
if (err && typeof err === "object" && "issues" in err) return "invalid_input";
return "failed";
}
export async function createTrialTenantAction(_prev: PlatformTrialState, fd: FormData): Promise<PlatformTrialState> {
const { admin } = await requirePlatformFullAdmin();
let target: string;
try {
const result = await createPlatformTrialTenant(
{ platformAdminId: admin.id },
{
companyName: String(fd.get("companyName") ?? ""),
sector: String(fd.get("sector") ?? ""),
adminName: String(fd.get("adminName") ?? ""),
adminEmail: String(fd.get("adminEmail") ?? ""),
endDate: String(fd.get("endDate") ?? ""),
sampleData: fd.get("sampleData") === "on",
},
);
target = `/admin/${result.tenantId}?trial=created${result.invited ? "&invited=1" : ""}`;
} catch (err) {
return { status: "error", code: errorCode(err) };
}
revalidatePath("/admin");
redirect(target);
}
const OPS = ["extend", "convert", "end", "schedule", "cancel"] as const;
/** Lifecycle actions; every destructive/relevant change requires the confirmation checkbox. */
export async function trialLifecycleAction(tenantId: string, op: string, _prev: PlatformTrialState, fd: FormData): Promise<PlatformTrialState> {
const { admin } = await requirePlatformFullAdmin();
const actor = { platformAdminId: admin.id };
if (!(OPS as readonly string[]).includes(op)) return { status: "error", code: "invalid_input" };
if (fd.get("confirm") !== "on") return { status: "error", code: "confirm_required" };
try {
if (op === "extend") await changeTrialEndDate(actor, tenantId, String(fd.get("endDate") ?? ""));
else if (op === "convert") await convertTenantToFull(actor, tenantId);
else if (op === "end") await endTrialNow(actor, tenantId);
else if (op === "schedule") await scheduleTrialDeletion(actor, tenantId);
else await cancelTrialDeletion(actor, tenantId);
} catch (err) {
return { status: "error", code: errorCode(err) };
}
revalidatePath("/admin");
revalidatePath(`/admin/${tenantId}`);
redirect(`/admin/${tenantId}?trialDone=${op}`);
}
+63
View File
@@ -0,0 +1,63 @@
"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" };
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireApiContext } from "@/server/api/context";
import { requireSession } from "@/server/auth";
import { requirePermission } from "@/server/rbac";
import { ServiceError } from "@/server/services/context";
import { requestTenantExport } from "@/server/services/trial/export";
import { setOnboardingHidden, setOnboardingItem } from "@/server/services/trial/onboarding";
/**
* L15 Testphase — tenant side: data export (allowed in the read-only state, therefore NOT behind
* moduleGuard) and the "Erste Schritte" checklist (writes → trial write lock in the service).
* Auth: requireSession + requirePermission (JWT, fast) + requireApiContext (DB-authoritative).
* Registered as EXEMPT in scripts/check-module-guards.ts.
*/
async function adminCtx() {
const session = await requireSession();
requirePermission(session, "tenant:manage");
return requireApiContext(null, "tenant:manage");
}
export async function requestExportAction(): Promise<void> {
let target = "/settings/export?requested=1";
try {
await requestTenantExport(await adminCtx());
} catch (err) {
console.error("[actions/trial-tenant] export:", (err as Error).message);
target = `/settings/export?error=${err instanceof ServiceError ? encodeURIComponent(err.message) : "failed"}`;
}
revalidatePath("/settings/export");
redirect(target);
}
export async function toggleOnboardingItemAction(fd: FormData): Promise<void> {
const ctx = await adminCtx();
await setOnboardingItem(ctx, String(fd.get("key") ?? ""), fd.get("done") === "1");
revalidatePath("/dashboard");
}
export async function hideOnboardingAction(): Promise<void> {
const ctx = await adminCtx();
await setOnboardingHidden(ctx, true);
revalidatePath("/dashboard");
}
+12 -1
View File
@@ -6,7 +6,8 @@ import { assertModuleEnabled, requireModule } from "@/server/modules";
import type { Permission } from "@/server/rbac";
import type { ModuleKey } from "@/lib/modules";
import type { ServiceCtx } from "@/server/services/context";
import { ApiError } from "@/server/api/respond";
import { ApiError, isMutatingApiRequest } from "@/server/api/respond";
import { assertTenantWritable } from "@/server/services/trial/state";
import { consumeRateLimit } from "@/server/rate-limit";
// assertSameOrigin lives in respond.ts (withApi applies it to every mutation); re-exported for
@@ -63,10 +64,20 @@ export async function requireApiContext(moduleKey: ModuleKey | null, ...permissi
}
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
if (moduleKey) enforceApiRateLimit(session.user.id, moduleKey);
await assertApiWriteAllowed(tenantId);
return { db, tenantId, userId: session.user.id, permissions: effective };
}
/**
* L15 Testphase: non-GET /api/v1 requests (withApi) of an expired trial tenant → `blocked
* trial_expired` (422). GET requests (reads, PDFs, downloads, export) are never blocked. Route
* handlers outside `withApi` that write (backoffice upload) call `assertTenantWritable` themselves.
*/
export async function assertApiWriteAllowed(tenantId: string): Promise<void> {
if (isMutatingApiRequest()) await assertTenantWritable(tenantId);
}
/**
* Per-user request budget for /api/v1 (in-memory, per app instance — see rate-limit.ts).
* Field endpoints (sync outbox, uploads, offline pre-download, document cache) get the generous
+20 -2
View File
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { ZodError } from "zod";
import { ServiceError } from "@/server/services/context";
import { ForbiddenError } from "@/server/rbac";
@@ -96,6 +97,22 @@ export function parsePagination(url: URL | string, defaults = { pageSize: 25 }):
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* L15 Testphase: HTTP method of the /api/v1 request currently handled by `withApi` (async context).
* `requireApiContext` uses it to apply the trial write lock to every non-safe request centrally;
* outside `withApi` (server actions, /files downloads) the method is unknown → `null`.
*/
const apiRequest = new AsyncLocalStorage<{ method: string }>();
export function currentApiMethod(): string | null {
return apiRequest.getStore()?.method ?? null;
}
export function isMutatingApiRequest(): boolean {
const method = currentApiMethod();
return method !== null && !SAFE_METHODS.has(method);
}
/**
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
@@ -125,8 +142,9 @@ export function assertSameOrigin(req: Request): void {
export function withApi<A extends [Request, ...unknown[]]>(handler: (...args: A) => Promise<Response>) {
return async (...args: A): Promise<Response> => {
try {
if (!SAFE_METHODS.has(args[0].method.toUpperCase())) assertSameOrigin(args[0]);
return await handler(...args);
const method = args[0].method.toUpperCase();
if (!SAFE_METHODS.has(method)) assertSameOrigin(args[0]);
return await apiRequest.run({ method }, () => handler(...args));
} catch (err) {
return toErrorResponse(err);
}
+2
View File
@@ -75,6 +75,8 @@ export const TENANT_MODELS: readonly string[] = [
// L14 Abrechnungsübersicht
"WorkOrderMilestone",
"BillingRecord",
// L15 Testphase: Datenexport des Mandanten
"TenantExport",
];
/**
+2
View File
@@ -126,6 +126,8 @@ const TENANT_MODELS = new Set<string>([
// L14 Abrechnungsübersicht
"WorkOrderMilestone",
"BillingRecord",
// L15 Testphase: Datenexport des Mandanten
"TenantExport",
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
+2
View File
@@ -55,6 +55,8 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
{ model: "WorkOrderMilestone", field: "rejectedById" },
{ model: "BillingRecord", field: "billedById" },
{ model: "BillingRecord", field: "voidedById" },
// L15 Testphase: Datenexport
{ model: "TenantExport", field: "requestedById" },
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
// tenant business data under data processing — not part of the employee subject export.
];
+2
View File
@@ -16,6 +16,8 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
"geocode-site": () => import("./geocode-site").then((m) => m.process), // L13: geocoding of sites (OSM Nominatim)
"planning-watch": () => import("./planning-watch").then((m) => m.process), // L13: delay/overrun/freed-capacity alerts (every 5 min)
"billing-pdf": () => import(/* turbopackIgnore: true */ "./billing-pdf").then((m) => m.process), // L14: worker-only billing sheet PDF (react-dom/server + Chromium)
"trial-lifecycle": () => import("./trial-lifecycle").then((m) => m.process), // L15: daily trial reminders/expiry/deletion
"tenant-export": () => import("./tenant-export").then((m) => m.process), // L15: tenant data export ZIP
};
/** Inline fallback when no Redis is available (dev/demo). */
@@ -0,0 +1,7 @@
import type { JobPayload } from "../queues";
import { buildTenantExport } from "@/server/services/trial/export";
/** L15 Testphase: builds the data export ZIP of one tenant (entityId = TenantExport.id). */
export async function process(payload: JobPayload): Promise<void> {
await buildTenantExport(payload.tenantId, payload.entityId);
}
@@ -0,0 +1,11 @@
import type { JobPayload } from "../queues";
import { runTrialLifecycle } from "@/server/services/trial/lifecycle";
/** L15 Testphase: daily lifecycle run over all trial tenants (payload tenantId "*"). */
export async function process(_payload: JobPayload): Promise<void> {
void _payload;
const s = await runTrialLifecycle();
console.info(
`[trial-lifecycle] checked ${s.checked}, reminders ${s.reminders}, expired ${s.expiredNotices}, deletion notices ${s.deletionNotices}, deleted ${s.deleted.length}, purged signups ${s.purgedSignups}`,
);
}
+13
View File
@@ -16,6 +16,8 @@ export const JOB_QUEUES = {
geocodeSite: "geocode-site", // L13 Planung: site address → coordinates (OSM Nominatim, 1 req/s)
planningWatch: "planning-watch", // L13 Planung: delay/overrun/freed-capacity alerts every 5 min
billingPdf: "billing-pdf", // L14 Abrechnungsblatt
trialLifecycle: "trial-lifecycle", // L15 Testphase: daily reminders/expiry/deletion
tenantExport: "tenant-export", // L15 Testphase: tenant data export (ZIP)
} as const;
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
@@ -111,6 +113,17 @@ export async function scheduleRecurringJobs(connection: Redis): Promise<void> {
} finally {
await watch.close();
}
// L15 Testphase: trial lifecycle once a day (all trial tenants; idempotent per tenant/notice)
const trial = new Queue<JobPayload>(JOB_QUEUES.trialLifecycle, { connection });
try {
await trial.upsertJobScheduler(
"trial-lifecycle-daily",
{ every: 24 * 60 * 60 * 1000 },
{ name: JOB_QUEUES.trialLifecycle, data: { tenantId: "*", entityId: "lifecycle" }, opts: { removeOnComplete: { count: 30 }, removeOnFail: { count: 30 } } },
);
} finally {
await trial.close();
}
}
export async function closeJobQueues(): Promise<void> {
+135
View File
@@ -66,6 +66,12 @@ export type TemplateVars = {
craftvia_report_customer: {
customerName: string; tenantName: string; reportTitle: string; reportDate: string; message?: string;
};
// ---- L15 Testphase (Transaktions-/Pflichtmails, nicht abbestellbar)
trial_confirm: { name: string; companyName: string; trialEnd: string; actionUrl: string; expires: string };
trial_existing_account: { name: string; loginUrl: string; resetUrl: string };
trial_reminder: { name: string; tenantName: string; daysLeft: number; endDate: string; actionUrl: string; contact?: string };
trial_expired: { name: string; tenantName: string; endDate: string; deletionDate?: string; exportUrl: string; contact?: string };
trial_deletion_notice: { name: string; tenantName: string; deletionDate: string; exportUrl: string; contact?: string };
};
/** Why the recipient gets a Craftvia notification — controls the footer line. */
@@ -97,6 +103,9 @@ export const CRAFTVIA_TEMPLATE_KEYS = [
/** Customer-facing Craftvia mails (lane L11) — separate list, recipients are external customers. */
export const CUSTOMER_TEMPLATE_KEYS = ["craftvia_report_customer"] as const satisfies readonly TemplateKey[];
/** L15 Testphase: Anmeldung (Double-Opt-in), Hinweis bei bestehendem Konto, Erinnerungen, Ablauf, Löschung. */
export const TRIAL_TEMPLATE_KEYS = ["trial_confirm", "trial_existing_account", "trial_reminder", "trial_expired", "trial_deletion_notice"] as const satisfies readonly TemplateKey[];
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
const FOOTER_NOTE: Record<Locale, string> = {
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
@@ -307,6 +316,130 @@ const customerEn: { [K in CustomerKey]: Builder<K> } = {
}),
};
// ---- L15 Testphase ----
type TrialKey = (typeof TRIAL_TEMPLATE_KEYS)[number];
const contactDe = (c?: string) => (c ? `Fragen oder Wunsch nach der Vollversion? Schreiben Sie uns: ${c}` : "Fragen oder Wunsch nach der Vollversion? Antworten Sie einfach auf diese E-Mail.");
const contactEn = (c?: string) => (c ? `Questions or ready for the full version? Write to us: ${c}` : "Questions or ready for the full version? Simply reply to this e-mail.");
const daysDe = (n: number) => (n <= 0 ? "heute" : n === 1 ? "morgen" : `in ${n} Tagen`);
const daysEn = (n: number) => (n <= 0 ? "today" : n === 1 ? "tomorrow" : `in ${n} days`);
const trialDe: { [K in TrialKey]: Builder<K> } = {
trial_confirm: (v) => ({
subject: `${BRAND.name}: Testphase bestätigen`,
heading: "Testphase bestätigen",
paragraphs: [
greetDe(v.name),
`Sie möchten ${BRAND.name} für „${oneLine(v.companyName)}" testen. Ihre Testphase läuft bis ${v.trialEnd}.`,
"Bestätigen Sie Ihre E-Mail-Adresse – danach richten wir Ihren Zugang sofort ein.",
"Haben Sie das nicht angefordert, ignorieren Sie diese E-Mail. Es wird dann nichts angelegt.",
],
action: { label: "Testphase starten", url: v.actionUrl },
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
}),
trial_existing_account: (v) => ({
subject: `${BRAND.name}: Sie haben bereits einen Zugang`,
heading: "Sie haben bereits einen Zugang",
paragraphs: [
greetDe(v.name),
`für diese E-Mail-Adresse besteht bereits ein Zugang zu ${BRAND.name}. Eine weitere Testphase mit derselben Adresse ist nicht möglich.`,
"Melden Sie sich mit Ihrem bestehenden Zugang an. Passwort vergessen? Dann setzen Sie es über den zweiten Link zurück.",
"Haben Sie das nicht angefordert, ignorieren Sie diese E-Mail.",
],
action: { label: "Zur Anmeldung", url: v.loginUrl },
note: `Passwort zurücksetzen: ${v.resetUrl}`,
}),
trial_reminder: (v) => ({
subject: `${BRAND.name}: Testphase endet ${daysDe(v.daysLeft)}`,
heading: `Ihre Testphase endet ${daysDe(v.daysLeft)}`,
paragraphs: [
greetDe(v.name),
`die Testphase von „${oneLine(v.tenantName)}" läuft bis ${v.endDate}. Danach sind Ihre Daten nur noch lesbar und exportierbar.`,
contactDe(v.contact),
],
action: { label: `${BRAND.name} öffnen`, url: v.actionUrl },
}),
trial_expired: (v) => ({
subject: `${BRAND.name}: Testphase abgelaufen`,
heading: "Testphase abgelaufen – nur Lesezugriff",
paragraphs: [
greetDe(v.name),
`die Testphase von „${oneLine(v.tenantName)}" ist am ${v.endDate} abgelaufen. Sie können sich weiter anmelden, Daten ansehen und exportieren – Änderungen sind nicht mehr möglich.`,
...(v.deletionDate ? [`Ohne Umwandlung in die Vollversion werden die Daten am ${v.deletionDate} gelöscht.`] : []),
contactDe(v.contact),
],
action: { label: "Daten exportieren", url: v.exportUrl },
}),
trial_deletion_notice: (v) => ({
subject: `${BRAND.name}: Daten werden am ${v.deletionDate} gelöscht`,
heading: "Löschung Ihrer Testdaten",
paragraphs: [
greetDe(v.name),
`die Daten von „${oneLine(v.tenantName)}" werden am ${v.deletionDate} endgültig gelöscht. Exportieren Sie vorher alles, was Sie behalten möchten.`,
contactDe(v.contact),
],
action: { label: "Daten exportieren", url: v.exportUrl },
}),
};
const trialEn: { [K in TrialKey]: Builder<K> } = {
trial_confirm: (v) => ({
subject: `${BRAND.name}: confirm your trial`,
heading: "Confirm your trial",
paragraphs: [
greetEn(v.name),
`you would like to try ${BRAND.name} for "${oneLine(v.companyName)}". Your trial runs until ${v.trialEnd}.`,
"Confirm your e-mail address – we set up your account right away.",
"If you did not request this, ignore this e-mail. Nothing will be created.",
],
action: { label: "Start trial", url: v.actionUrl },
note: `The link is valid until ${v.expires} and can only be used once.`,
}),
trial_existing_account: (v) => ({
subject: `${BRAND.name}: you already have an account`,
heading: "You already have an account",
paragraphs: [
greetEn(v.name),
`there already is a ${BRAND.name} account for this e-mail address. Another trial with the same address is not possible.`,
"Sign in with your existing account. Forgot your password? Reset it with the second link.",
"If you did not request this, ignore this e-mail.",
],
action: { label: "Sign in", url: v.loginUrl },
note: `Reset password: ${v.resetUrl}`,
}),
trial_reminder: (v) => ({
subject: `${BRAND.name}: trial ends ${daysEn(v.daysLeft)}`,
heading: `Your trial ends ${daysEn(v.daysLeft)}`,
paragraphs: [
greetEn(v.name),
`the trial of "${oneLine(v.tenantName)}" runs until ${v.endDate}. Afterwards your data can only be viewed and exported.`,
contactEn(v.contact),
],
action: { label: `Open ${BRAND.name}`, url: v.actionUrl },
}),
trial_expired: (v) => ({
subject: `${BRAND.name}: trial expired`,
heading: "Trial expired – read-only access",
paragraphs: [
greetEn(v.name),
`the trial of "${oneLine(v.tenantName)}" expired on ${v.endDate}. You can still sign in, view and export your data – changes are no longer possible.`,
...(v.deletionDate ? [`Unless converted to the full version, the data will be deleted on ${v.deletionDate}.`] : []),
contactEn(v.contact),
],
action: { label: "Export data", url: v.exportUrl },
}),
trial_deletion_notice: (v) => ({
subject: `${BRAND.name}: data will be deleted on ${v.deletionDate}`,
heading: "Deletion of your trial data",
paragraphs: [
greetEn(v.name),
`the data of "${oneLine(v.tenantName)}" will be deleted permanently on ${v.deletionDate}. Export everything you want to keep beforehand.`,
contactEn(v.contact),
],
action: { label: "Export data", url: v.exportUrl },
}),
};
const de: { [K in TemplateKey]: Builder<K> } = {
invitation: (v) => ({
subject: `Ihr Zugang zu ${BRAND.name}`,
@@ -385,6 +518,7 @@ const de: { [K in TemplateKey]: Builder<K> } = {
}),
...craftviaDe,
...customerDe,
...trialDe,
};
const en: { [K in TemplateKey]: Builder<K> } = {
@@ -465,6 +599,7 @@ const en: { [K in TemplateKey]: Builder<K> } = {
}),
...craftviaEn,
...customerEn,
...trialEn,
};
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
+22 -10
View File
@@ -8,8 +8,15 @@ export interface ProvisionOpts {
slug: string;
short?: string;
sector?: string;
admin: { email: string; name: string; password: string };
/**
* Erster Administrator. `password` (Klartext, wird gehasht) ODER `passwordHash` (bereits
* Argon2id + Pepper, z. B. aus der bestätigten Testphasen-Anmeldung — L15).
* `mustChangePassword` gilt nur für eine NEU angelegte Identity (Einladungsweg).
*/
admin: { email: string; name: string; password?: string; passwordHash?: string; mustChangePassword?: boolean };
actorId?: string | null;
/** L15: nur diese Module aktivieren (neue Mandanten); ohne Angabe alle. */
modules?: readonly string[];
}
/**
@@ -52,12 +59,17 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
// 4. Erster Mandantenadministrator: globale Identity (Anmeldung) + Mitgliedschaft.
// Idempotent: eine bestehende Identity/ihr Passwort wird NICHT überschrieben.
const passwordHash = await hashPassword(opts.admin.password);
const identity = await prisma.identity.upsert({
where: { email: opts.admin.email },
update: {},
create: { email: opts.admin.email, passwordHash },
});
const existingIdentity = await prisma.identity.findUnique({ where: { email: opts.admin.email }, select: { id: true } });
const identity =
existingIdentity ??
(await prisma.identity.create({
data: {
email: opts.admin.email,
passwordHash: opts.admin.passwordHash ?? (await hashPassword(opts.admin.password ?? "")),
mustChangePassword: opts.admin.mustChangePassword ?? false,
},
select: { id: true },
}));
const admin = await prisma.user.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: opts.admin.email } },
update: { name: opts.admin.name, identityId: identity.id },
@@ -84,12 +96,12 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
},
});
// 6. Alle Module aktivieren
// 6. Module aktivieren (L15: optional nur die gewählten; bestehende Zeilen bleiben unverändert)
for (const m of MODULES) {
await prisma.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId: tenant.id, moduleKey: m.key } },
update: {},
create: { tenantId: tenant.id, moduleKey: m.key, enabled: true },
create: { tenantId: tenant.id, moduleKey: m.key, enabled: opts.modules ? opts.modules.includes(m.key) : true },
});
}
@@ -97,5 +109,5 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
data: { tenantId: tenant.id, scope: "platform", actorId: opts.actorId ?? null, action: "provision", entity: "tenant", entityId: tenant.id, after: { name: opts.name } },
});
return tenant;
return Object.assign(tenant, { adminUserId: admin.id, identityId: identity.id, identityCreated: !existingIdentity });
}
+6
View File
@@ -62,6 +62,12 @@ export const RATE_LIMITS = {
api: { limit: perMinute("API_RATE_LIMIT_PER_MINUTE", 300), windowMs: 60_000 },
/** L10b: Einsatz-/Sync-Endpunkte (/sync, /uploads, /field/**) je Nutzer — großzügig (Outbox, Vorab-Download). */
apiField: { limit: perMinute("API_FIELD_RATE_LIMIT_PER_MINUTE", 1200), windowMs: 60_000 },
/** L15: öffentliche Testphasen-Anmeldung — je IP und je E-Mail-Adresse. */
trialSignup: { limit: 5, windowMs: 60 * 60_000 },
/** L15: Einlösen des Bestätigungslinks je IP. */
trialConfirm: { limit: 20, windowMs: 15 * 60_000 },
/** L15: serverseitige Schrittprüfung des Wizards (schreibt nichts) je IP. */
trialStepCheck: { limit: 120, windowMs: 15 * 60_000 },
} as const satisfies Record<string, RateLimitRule>;
export type RateLimitScope = keyof typeof RATE_LIMITS;
+13
View File
@@ -0,0 +1,13 @@
import { checkRateLimit, type RateLimitResult } from "@/server/rate-limit";
/**
* L15 Testphase: abuse protection of the public endpoints (no external CAPTCHA).
* Counts per IP AND per e-mail address (both counters are always increased, see rate-limit.ts).
* The key material is hashed inside rate-limit.ts — no clear-text IPs/addresses in memory.
*/
export function enforceTrialRateLimit(
scope: "trialSignup" | "trialConfirm" | "trialStepCheck",
parts: { ip?: string | null; email?: string | null },
): RateLimitResult {
return checkRateLimit(scope, { ip: parts.ip ?? null, account: parts.email ? parts.email.trim().toLowerCase() : null });
}
+174
View File
@@ -0,0 +1,174 @@
import { z } from "zod";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { ServiceError } from "@/server/services/context";
import { addDaysToKey, isDateKey, todayKey, trialEndInstant } from "@/lib/trial/dates";
import { daysMs, PLATFORM_TRIAL_MAX_DAYS, TRIAL_DELETION_GRACE_DAYS, TRIAL_DELETION_NOTICE_DAYS } from "./config";
import { provisionTrialTenant, type ProvisionTrialResult } from "./provision";
/**
* L15 Testphase: platform-admin operations on trial tenants. ONLY for active platform full admins —
* every function re-checks the actor against the PlatformAdmin store (tenant administrators have no
* PlatformAdmin row and are rejected with `forbidden`). The adapter additionally requires the
* platform session (src/server/actions/trial-platform.ts). Every change → audit (scope platform,
* attached to the tenant so it shows in the tenant's audit trail) with before/after.
*/
export type PlatformActor = { platformAdminId: string };
async function assertPlatformFullAdmin(actor: PlatformActor): Promise<void> {
const admin = actor.platformAdminId
? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true, role: true } })
: null;
if (!admin || admin.status !== "ACTIVE" || admin.role !== "full") throw new ServiceError("forbidden", "platform_admin_required");
}
const LIFECYCLE_SELECT = {
id: true,
name: true,
status: true,
plan: true,
trialEndsAt: true,
convertedAt: true,
readOnlySince: true,
deletionDueAt: true,
trialDeletedAt: true,
} as const;
async function loadTrialTenant(tenantId: string) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: LIFECYCLE_SELECT });
if (!tenant) throw new ServiceError("not_found", "tenant not found");
if (tenant.trialDeletedAt || tenant.status === "ARCHIVED") throw new ServiceError("invalid", "tenant_deleted");
if (tenant.plan !== "TRIAL" || !tenant.trialEndsAt) throw new ServiceError("invalid", "not_a_trial");
return tenant;
}
type Snapshot = { plan: string; trialEndsAt: Date | null; readOnlySince: Date | null; deletionDueAt: Date | null; convertedAt: Date | null };
const snap = (t: Snapshot) => ({ plan: t.plan, trialEndsAt: t.trialEndsAt, readOnlySince: t.readOnlySince, deletionDueAt: t.deletionDueAt, convertedAt: t.convertedAt });
async function audit(actor: PlatformActor, tenantId: string, entity: string, before: Snapshot, after: Snapshot) {
await writeAuditLog({ tenantId, scope: "platform", actorId: actor.platformAdminId, action: "update", entity, entityId: tenantId, before: snap(before), after: snap(after) });
}
const RESET_NOTICES = { trialReminder7At: null, trialReminder3At: null, trialReminder1At: null, trialExpiredNoticeAt: null, trialDeletionNoticeAt: null } as const;
export const platformTrialSchema = z.object({
companyName: z.string().trim().min(2).max(120),
sector: z.string().trim().max(80).default(""),
adminName: z.string().trim().min(2).max(120),
adminEmail: z.string().trim().toLowerCase().max(200).regex(/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/),
endDate: z.string().refine(isDateKey, "date_invalid"),
sampleData: z.boolean().default(true),
});
function assertEndDateRange(endDateKey: string, now: Date) {
if (!isDateKey(endDateKey)) throw new ServiceError("invalid", "date_invalid", { field: "endDate" });
const today = todayKey(now);
if (endDateKey < today) throw new ServiceError("invalid", "date_in_past", { field: "endDate" });
if (endDateKey > addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)) throw new ServiceError("invalid", "date_too_late", { field: "endDate" });
}
/** Wizard "Testmandant anlegen": provisioning + invitation of a NEW admin identity (existing people keep their access). */
export async function createPlatformTrialTenant(
actor: PlatformActor,
raw: z.input<typeof platformTrialSchema>,
opts: { now?: Date; invite?: (input: Parameters<typeof sendUserInvitationMail>[0]) => Promise<unknown> } = {},
): Promise<ProvisionTrialResult & { invited: boolean }> {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const input = platformTrialSchema.parse(raw);
assertEndDateRange(input.endDate, now);
const result = await provisionTrialTenant({
companyName: input.companyName,
sector: input.sector,
admin: { name: input.adminName, email: input.adminEmail },
endDateKey: input.endDate,
sampleData: input.sampleData,
source: "platform",
actorId: actor.platformAdminId,
now,
});
let invited = false;
if (result.identityCreated) {
const { raw: token, expiresAt } = await issueToken({ principalType: "identity", principalId: result.identityId, tenantId: result.tenantId, type: "invitation" });
await (opts.invite ?? sendUserInvitationMail)({ to: input.adminEmail, name: input.adminName, tenantId: result.tenantId, tenantName: input.companyName, rawToken: token, expiresAt });
invited = true;
}
return { ...result, invited };
}
/** Change/extend the end date — also after expiry (the tenant becomes writable again). */
export async function changeTrialEndDate(actor: PlatformActor, tenantId: string, endDateKey: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
assertEndDateRange(endDateKey, now);
const before = await loadTrialTenant(tenantId);
const endsAt = trialEndInstant(endDateKey);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: {
trialEndsAt: endsAt,
readOnlySince: null,
// a cancelled deletion stays cancelled; otherwise the grace period follows the new end
deletionDueAt: before.deletionDueAt ? new Date(endsAt.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)) : null,
...RESET_NOTICES,
},
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_end_date", before, after);
return after;
}
/** Convert to the full version: never deleted, never read-only again. */
export async function convertTenantToFull(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: { plan: "FULL", convertedAt: opts.now ?? new Date(), readOnlySince: null, deletionDueAt: null, trialDeletionNoticeAt: null },
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_converted", before, after);
return after;
}
/** End the trial immediately (read-only from now on). */
export async function endTrialNow(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: {
trialEndsAt: now,
readOnlySince: now,
deletionDueAt: before.deletionDueAt ? new Date(now.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)) : null,
trialExpiredNoticeAt: null,
trialDeletionNoticeAt: null,
},
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_ended", before, after);
return after;
}
/** Schedule the automatic deletion (end + 30 days, at least TRIAL_DELETION_NOTICE_DAYS from now so the notice can go out). */
export async function scheduleTrialDeletion(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const before = await loadTrialTenant(tenantId);
const due = Math.max(before.trialEndsAt!.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS), now.getTime() + daysMs(TRIAL_DELETION_NOTICE_DAYS));
const after = await prisma.tenant.update({ where: { id: tenantId }, data: { deletionDueAt: new Date(due), trialDeletionNoticeAt: null }, select: LIFECYCLE_SELECT });
await audit(actor, tenantId, "trial_deletion_scheduled", before, after);
return after;
}
export async function cancelTrialDeletion(actor: PlatformActor, tenantId: string) {
await assertPlatformFullAdmin(actor);
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({ where: { id: tenantId }, data: { deletionDueAt: null, trialDeletionNoticeAt: null }, select: LIFECYCLE_SELECT });
await audit(actor, tenantId, "trial_deletion_cancelled", before, after);
return after;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* L15 Testphase: operating parameters (env, read at call time so tests can override them).
* TRIAL_MAX_DAYS longest self-service trial in days (1–365, default 30)
* TRIAL_CONTACT_EMAIL contact shown in banners and trial mails (optional)
*/
export const TRIAL_DEFAULT_DAYS = 14;
/** Automatic deletion of an unconverted trial tenant after its end. */
export const TRIAL_DELETION_GRACE_DAYS = 30;
/** Deletion notice before the deletion date. */
export const TRIAL_DELETION_NOTICE_DAYS = 7;
/** Reminder mails before the end (calendar days in Berlin). */
export const TRIAL_REMINDER_DAYS = [7, 3, 1] as const;
/** Banner countdown from this many days before the end. */
export const TRIAL_BANNER_DAYS = 7;
/** Double opt-in link validity. */
export const TRIAL_SIGNUP_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
/** Unconfirmed/finished signups are purged this long after their link expired (data minimisation). */
export const TRIAL_SIGNUP_RETENTION_DAYS = 7;
/** Platform admins may set end dates up to one year ahead. */
export const PLATFORM_TRIAL_MAX_DAYS = 365;
const DAY_MS = 24 * 60 * 60 * 1000;
export const daysMs = (days: number) => days * DAY_MS;
export function trialMaxDays(): number {
const v = Number(process.env.TRIAL_MAX_DAYS);
return Number.isInteger(v) && v >= 1 && v <= 365 ? v : 30;
}
export function trialContactEmail(): string | null {
const v = process.env.TRIAL_CONTACT_EMAIL?.trim();
return v && /^[^\s@]+@[^\s@]+$/.test(v) ? v : null;
}
+194
View File
@@ -0,0 +1,194 @@
import { prisma, dbForTenant } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { buildZip, type ZipEntry } from "@/server/backup/zip";
import { enqueueJob, JOB_QUEUES } from "@/server/jobs/queues";
import { storage } from "@/server/storage/adapter";
import { readStoredBytes } from "@/server/services/documents/read";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { todayKey } from "@/lib/trial/dates";
import { daysMs } from "./config";
/**
* L15 Testphase: data export for tenant administrators — ZIP with CSV (semicolon, UTF-8 BOM, Excel)
* and JSON of master data, work orders, times, material, reports, plus the stored files (documents,
* report PDFs, photos). Runs as worker job `tenant-export`; the result is stored under
* `<tenantId>/uploads/…` in the object storage and downloaded via /settings/export/<id> (session +
* tenant:manage, never a public link). Allowed in the read-only state (not a business mutation).
*
* The existing backup/DSGVO export (src/server/backup/dsgvo-zip.ts) is an operator tool (platform
* portal, all tables incl. internal ones, JSON only); its ZIP writer is reused here.
*/
export const EXPORT_DOWNLOAD_DAYS = 7;
/** Upper bound for embedded files; beyond that the README lists what was left out. */
const MAX_FILE_BYTES = 500 * 1024 * 1024;
type Dispatch = (tenantId: string, exportId: string, actorId: string) => Promise<void>;
const defaultDispatch: Dispatch = async (tenantId, exportId, actorId) => {
if (!(await enqueueJob(JOB_QUEUES.tenantExport, { tenantId, entityId: exportId, actorId }))) {
await buildTenantExport(tenantId, exportId); // no Redis (dev/demo): inline
}
};
export async function requestTenantExport(ctx: ServiceCtx, deps: { dispatch?: Dispatch; now?: Date } = {}) {
assertCan(ctx, "tenant:manage");
const now = deps.now ?? new Date();
const open = await ctx.db.tenantExport.findFirst({ where: { status: { in: ["queued", "running"] }, createdAt: { gt: new Date(now.getTime() - 30 * 60_000) } }, select: { id: true } });
if (open) throw new ServiceError("conflict", "export_running");
const row = await ctx.db.tenantExport.create({ data: { tenantId: ctx.tenantId, requestedById: ctx.userId } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "export", entity: "tenant_export", entityId: row.id, after: { status: "queued" } });
await (deps.dispatch ?? defaultDispatch)(ctx.tenantId, row.id, ctx.userId);
return row;
}
export async function listTenantExports(ctx: ServiceCtx, opts: { now?: Date } = {}) {
assertCan(ctx, "tenant:manage");
const now = (opts.now ?? new Date()).getTime();
const rows = await ctx.db.tenantExport.findMany({
orderBy: { createdAt: "desc" },
take: 10,
select: { id: true, status: true, fileName: true, bytes: true, error: true, expiresAt: true, createdAt: true },
});
return rows.map((r) => ({ ...r, expired: r.status === "done" && !!r.expiresAt && r.expiresAt.getTime() <= now }));
}
// ---------------------------------------------------------------- serialisation
function scalar(v: unknown): string {
if (v === null || v === undefined) return "";
if (v instanceof Date) return v.toISOString();
if (typeof v === "bigint") return v.toString();
if (typeof v === "object") {
// Prisma.Decimal
if (typeof (v as { toFixed?: unknown }).toFixed === "function") return String(v);
return JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? x.toString() : x));
}
return String(v);
}
function csvCell(v: unknown): string {
let s = scalar(v);
// CSV/formula injection: never let a cell start with a formula character
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return /[";\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
export function toCsv(rows: Record<string, unknown>[]): string {
const headers: string[] = [];
for (const row of rows) for (const key of Object.keys(row)) if (!headers.includes(key)) headers.push(key);
const lines = [headers.map(csvCell).join(";"), ...rows.map((row) => headers.map((h) => csvCell(row[h])).join(";"))];
return `${lines.join("\r\n")}\r\n`;
}
function toJson(rows: unknown): string {
return JSON.stringify(rows, (_k, v) => (typeof v === "bigint" ? v.toString() : v && typeof v === "object" && typeof (v as { toFixed?: unknown }).toFixed === "function" && !(v instanceof Date) ? String(v) : v), 2);
}
const safeName = (s: string) => s.replace(/[^\w.\-]+/g, "_").slice(0, 100) || "datei";
/** Builds the ZIP for one export row (worker). Idempotent: only a `queued` row is processed. */
export async function buildTenantExport(tenantId: string, exportId: string, opts: { now?: Date } = {}): Promise<void> {
const now = opts.now ?? new Date();
const db = dbForTenant(tenantId);
const claim = await db.tenantExport.updateMany({ where: { id: exportId, status: "queued" }, data: { status: "running" } });
if (claim.count !== 1) return;
const row = await db.tenantExport.findFirst({ where: { id: exportId }, select: { requestedById: true } });
try {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true, slug: true } });
if (!tenant) throw new Error("tenant not found");
const datasets: [string, Promise<Record<string, unknown>[]>][] = [
["kunden", db.customer.findMany({ orderBy: { createdAt: "asc" } })],
["ansprechpartner", db.contact.findMany({ orderBy: { createdAt: "asc" } })],
["objekte", db.site.findMany({ orderBy: { createdAt: "asc" } })],
["teams", db.team.findMany({ orderBy: { createdAt: "asc" } })],
["team-mitglieder", db.teamMember.findMany()],
["nutzer", db.user.findMany({ select: { id: true, name: true, email: true, status: true, createdAt: true } })],
["auftraege", db.workOrder.findMany({ orderBy: { createdAt: "asc" } })],
["checklisten", db.checklistItem.findMany()],
["material-vorgabe", db.materialPlan.findMany()],
["material", db.materialUsage.findMany({ orderBy: { createdAt: "asc" } })],
["einsaetze", db.workSession.findMany()],
["zeiten", db.timeEntry.findMany({ orderBy: { startedAt: "asc" } })],
["notizen", db.activityNote.findMany({ orderBy: { createdAt: "asc" } })],
["berichte", db.report.findMany({ orderBy: { createdAt: "asc" } })],
["unterschriften", db.signature.findMany()],
["fotos", db.photo.findMany()],
["dokumente", db.document.findMany({ where: { deletedAt: null }, orderBy: { createdAt: "asc" } })],
["meilensteine", db.workOrderMilestone.findMany()],
["abrechnung", db.billingRecord.findMany()],
];
const entries: ZipEntry[] = [];
const counts: Record<string, number> = {};
let documents: { id: string; fileName: string; storageKey: string; fileSize: number }[] = [];
for (const [name, query] of datasets) {
const rows = await query;
counts[name] = rows.length;
entries.push({ name: `csv/${name}.csv`, data: toCsv(rows) });
entries.push({ name: `json/${name}.json`, data: toJson(rows) });
if (name === "dokumente") documents = rows as unknown as typeof documents;
}
let fileBytes = 0;
let files = 0;
const skipped: string[] = [];
for (const doc of documents) {
if (fileBytes + doc.fileSize > MAX_FILE_BYTES) {
skipped.push(`${doc.id} ${doc.fileName} (Größenlimit)`);
continue;
}
const bytes = await readStoredBytes(doc.storageKey).catch(() => null);
if (!bytes) {
skipped.push(`${doc.id} ${doc.fileName} (nicht im Speicher)`);
continue;
}
entries.push({ name: `dateien/${doc.id}-${safeName(doc.fileName)}`, data: bytes });
fileBytes += bytes.length;
files++;
}
entries.unshift({
name: "LIESMICH.txt",
data:
`Craftvia – Datenexport\r\nBetrieb: ${tenant.name} (${tenant.slug})\r\nErstellt: ${now.toISOString()}\r\n\r\n` +
`csv/ Tabellen im CSV-Format (Semikolon, UTF-8) – öffnen z. B. mit Excel oder LibreOffice\r\n` +
`json/ dieselben Daten als JSON (maschinenlesbar)\r\n` +
`dateien/ gespeicherte Dateien (Dokumente, Berichts-PDFs, Fotos, Unterschriften); Dateiname beginnt mit der Dokument-ID aus dokumente.csv\r\n\r\n` +
`Datensätze: ${Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ")}\r\n` +
`Dateien: ${files}${skipped.length ? `\r\nNicht enthalten:\r\n${skipped.join("\r\n")}` : ""}\r\n`,
});
const zip = buildZip(entries);
const fileName = `craftvia-export-${tenant.slug}-${todayKey(now)}.zip`;
const stored = await storage.put({ tenantId, filename: fileName, contentType: "application/zip", bytes: zip });
if (stored.storageKey.startsWith("stub://")) throw new Error("object storage not configured");
const summary = { counts, files, skipped: skipped.length };
await db.tenantExport.update({
where: { id: exportId },
data: { status: "done", storageKey: stored.storageKey, fileName, bytes: zip.length, summary, expiresAt: new Date(now.getTime() + daysMs(EXPORT_DOWNLOAD_DAYS)), error: null },
});
await writeAuditLog({ tenantId, actorId: row?.requestedById ?? undefined, action: "export", entity: "tenant_export", entityId: exportId, after: { status: "done", bytes: zip.length, ...summary } });
} catch (err) {
console.error(`[tenant-export] ${exportId} failed:`, (err as Error).message);
await db.tenantExport.update({ where: { id: exportId }, data: { status: "failed", error: (err as Error).message.slice(0, 300) } });
}
}
/** Download of a finished export (tenant:manage; expired links → invalid). */
export async function openTenantExport(ctx: ServiceCtx, exportId: string, opts: { now?: Date } = {}): Promise<{ bytes: Buffer; fileName: string }> {
assertCan(ctx, "tenant:manage");
const now = opts.now ?? new Date();
const row = await ctx.db.tenantExport.findFirst({ where: { id: exportId, status: "done" }, select: { id: true, storageKey: true, fileName: true, expiresAt: true } });
if (!row || !row.storageKey) throw new ServiceError("not_found", "export not found");
if (row.expiresAt && row.expiresAt.getTime() <= now.getTime()) throw new ServiceError("invalid", "export_expired");
// defence in depth: the key must belong to this tenant
if (!row.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "export not found");
const bytes = await readStoredBytes(row.storageKey);
if (!bytes) throw new ServiceError("not_found", "export file missing");
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "export", entity: "tenant_export_download", entityId: row.id });
return { bytes, fileName: row.fileName ?? "craftvia-export.zip" };
}
+15
View File
@@ -0,0 +1,15 @@
import { getTrialState } from "./state";
/**
* L15 Testphase: background jobs that write ON BEHALF OF A USER must not run for an expired trial
* tenant (e.g. an import extraction or transcription queued shortly before the end).
* System derivations of data committed before the end (report/billing PDFs, thumbnails, geocoding)
* and platform maintenance jobs keep running; the trial jobs themselves are never blocked.
*/
export const TRIAL_BLOCKED_QUEUES: readonly string[] = ["import-extraction", "transcription"];
export async function isJobBlockedByTrial(queue: string, payload: { tenantId?: string | null }): Promise<boolean> {
if (!TRIAL_BLOCKED_QUEUES.includes(queue)) return false;
if (!payload.tenantId || payload.tenantId === "*") return false;
return (await getTrialState(payload.tenantId)).readOnly;
}
+180
View File
@@ -0,0 +1,180 @@
import { prisma } from "@/server/db";
import { writePlatformAudit } from "@/server/audit";
import { offboardTenant } from "@/server/dsgvo/deletion";
import { absoluteUrl } from "@/server/mail/config";
import { enqueueMail } from "@/server/mail/service";
import { normalizeLocale } from "@/server/mail/templates";
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
import { daysMs, trialContactEmail, TRIAL_DELETION_NOTICE_DAYS, TRIAL_REMINDER_DAYS, TRIAL_SIGNUP_RETENTION_DAYS } from "./config";
import { computeTrialState, TRIAL_SELECT } from "./state";
import { tenantAdminRecipients } from "./mail";
import { purgeTenantObjects } from "./storage-purge";
/**
* L15 Testphase: daily lifecycle job (BullMQ job scheduler `trial-lifecycle-daily`, idempotent).
* - reminders 7/3/1 days before the end (each at most once; a missed run sends only the closest)
* - expiry mail on the end day (+ readOnlySince)
* - deletion notice TRIAL_DELETION_NOTICE_DAYS before `deletionDueAt`
* - deletion at `deletionDueAt` through the existing tenant offboarding (topology of all
* TENANT_MODELS + storage prefix `<tenantId>/`), platform audit
* - purge of old signup rows (data minimisation)
* Every mail is "claimed" with a conditional update (column still NULL) before sending, so parallel
* workers or re-runs never send twice. Converted or extended tenants are never deleted: the
* deletion re-checks and claims the row immediately before offboarding.
*/
export type LifecycleSummary = {
checked: number;
reminders: number;
expiredNotices: number;
deletionNotices: number;
deleted: string[];
purgedSignups: number;
};
type Deps = { now?: Date; tenantIds?: string[]; sendMail?: typeof enqueueMail };
const REMINDER_COLUMN = { 7: "trialReminder7At", 3: "trialReminder3At", 1: "trialReminder1At" } as const;
async function mailAdmins(
tenantId: string,
send: typeof enqueueMail,
build: (r: { name: string; email: string; locale: "de" | "en" }) => Parameters<typeof enqueueMail>[0],
): Promise<number> {
let n = 0;
for (const r of await tenantAdminRecipients(tenantId)) {
const res = await send(build({ name: r.name, email: r.email, locale: normalizeLocale(r.locale) }));
if (res.status !== "duplicate") n++;
}
return n;
}
export async function runTrialLifecycle(deps: Deps = {}): Promise<LifecycleSummary> {
const now = deps.now ?? new Date();
const send = deps.sendMail ?? enqueueMail;
const contact = trialContactEmail() ?? undefined;
const summary: LifecycleSummary = { checked: 0, reminders: 0, expiredNotices: 0, deletionNotices: 0, deleted: [], purgedSignups: 0 };
const tenants = await prisma.tenant.findMany({
where: { plan: "TRIAL", trialDeletedAt: null, status: { not: "ARCHIVED" }, trialEndsAt: { not: null }, ...(deps.tenantIds ? { id: { in: deps.tenantIds } } : {}) },
select: { id: true, name: true, ...TRIAL_SELECT, trialReminder7At: true, trialReminder3At: true, trialReminder1At: true, trialExpiredNoticeAt: true, trialDeletionNoticeAt: true },
orderBy: { createdAt: "asc" },
});
for (const t of tenants) {
summary.checked++;
try {
const state = computeTrialState(t, now);
const endDate = (locale: string) => formatDateKey(state.endDateKey!, locale);
if (!state.expired) {
const threshold = [...TRIAL_REMINDER_DAYS].sort((a, b) => a - b).find((d) => state.daysLeft! <= d);
if (threshold !== undefined && !t[REMINDER_COLUMN[threshold]]) {
// claim this threshold and every larger one (a missed run must not send an older reminder later)
const columns = TRIAL_REMINDER_DAYS.filter((d) => d >= threshold).map((d) => REMINDER_COLUMN[d]);
const claim = await prisma.tenant.updateMany({
where: { id: t.id, plan: "TRIAL", [REMINDER_COLUMN[threshold]]: null },
data: Object.fromEntries(columns.map((c) => [c, now])),
});
if (claim.count === 1) {
await mailAdmins(t.id, send, (r) => ({
template: "trial_reminder",
to: r.email,
tenantId: t.id,
locale: r.locale,
vars: { name: r.name, tenantName: t.name, daysLeft: state.daysLeft!, endDate: endDate(r.locale), actionUrl: absoluteUrl("/dashboard"), contact },
}));
summary.reminders++;
}
}
continue;
}
if (!t.trialExpiredNoticeAt) {
const claim = await prisma.tenant.updateMany({
where: { id: t.id, plan: "TRIAL", trialExpiredNoticeAt: null },
data: { trialExpiredNoticeAt: now, readOnlySince: t.readOnlySince ?? t.trialEndsAt },
});
if (claim.count === 1) {
await mailAdmins(t.id, send, (r) => ({
template: "trial_expired",
to: r.email,
tenantId: t.id,
locale: r.locale,
vars: { name: r.name, tenantName: t.name, endDate: endDate(r.locale), deletionDate: t.deletionDueAt ? formatInstantDate(t.deletionDueAt, r.locale) : undefined, exportUrl: absoluteUrl("/settings/export"), contact },
}));
summary.expiredNotices++;
}
}
if (!t.deletionDueAt) continue;
if (now.getTime() >= t.deletionDueAt.getTime()) {
const res = await deleteTrialTenant(t.id, { now });
if (res.deleted) summary.deleted.push(t.id);
} else if (now.getTime() >= t.deletionDueAt.getTime() - daysMs(TRIAL_DELETION_NOTICE_DAYS) && !t.trialDeletionNoticeAt) {
const claim = await prisma.tenant.updateMany({ where: { id: t.id, plan: "TRIAL", trialDeletionNoticeAt: null, deletionDueAt: t.deletionDueAt }, data: { trialDeletionNoticeAt: now } });
if (claim.count === 1) {
await mailAdmins(t.id, send, (r) => ({
template: "trial_deletion_notice",
to: r.email,
tenantId: t.id,
locale: r.locale,
vars: { name: r.name, tenantName: t.name, deletionDate: formatInstantDate(t.deletionDueAt!, r.locale), exportUrl: absoluteUrl("/settings/export"), contact },
}));
summary.deletionNotices++;
}
}
} catch (err) {
// one broken tenant must not stop the others; the next run retries
console.error(`[trial-lifecycle] tenant ${t.id} failed:`, (err as Error).message);
}
}
if (!deps.tenantIds) {
const { count } = await prisma.trialSignup.deleteMany({ where: { expiresAt: { lt: new Date(now.getTime() - daysMs(TRIAL_SIGNUP_RETENTION_DAYS)) } } });
summary.purgedSignups = count;
}
return summary;
}
/**
* Deletes an expired, unconverted trial tenant. Double check + claim right before the deletion:
* the conditional update only matches while the tenant is still TRIAL, unconverted, expired and due —
* a conversion/extension a moment earlier makes it a no-op. The tenant is suspended during deletion
* (no logins) and archived by the offboarding afterwards.
*/
export async function deleteTrialTenant(tenantId: string, opts: { now?: Date } = {}): Promise<{ deleted: boolean }> {
const now = opts.now ?? new Date();
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, slug: true } });
if (!tenant) return { deleted: false };
const claim = await prisma.tenant.updateMany({
where: {
id: tenantId,
plan: "TRIAL",
convertedAt: null,
trialDeletedAt: null,
status: { not: "ARCHIVED" },
trialEndsAt: { lte: now },
deletionDueAt: { lte: now },
},
data: { status: "SUSPENDED" },
});
if (claim.count !== 1) return { deleted: false };
const result = await offboardTenant(tenantId, { reason: "trial_expired", purgeFiles: true, portabilitySnapshot: false });
let files = 0;
try {
files = await purgeTenantObjects(tenantId);
} catch (err) {
console.error(`[trial-lifecycle] storage purge for ${tenantId} failed:`, (err as Error).message);
}
await prisma.tenant.update({ where: { id: tenantId }, data: { trialDeletedAt: now, deletionDueAt: null } });
await prisma.trialSignup.deleteMany({ where: { provisionedTenantId: tenantId } });
await writePlatformAudit({
action: "delete",
entity: "trial_tenant",
entityId: tenantId,
after: { slug: tenant.slug, deletedRows: result.deletedRows, deletedIdentities: result.deletedIdentities, files, certificateId: result.certificateId },
});
return { deleted: true };
}
+11
View File
@@ -0,0 +1,11 @@
import { dbForTenant } from "@/server/db";
/** L15 Testphase: active tenant administrators (recipients of reminder/expiry/deletion mails). */
export async function tenantAdminRecipients(tenantId: string): Promise<{ userId: string; email: string; name: string; locale: string }[]> {
const users = await dbForTenant(tenantId).user.findMany({
where: { status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } },
select: { id: true, email: true, name: true, identity: { select: { status: true, uiLocale: true } } },
orderBy: { createdAt: "asc" },
});
return users.filter((u) => u.identity.status === "ACTIVE").map((u) => ({ userId: u.id, email: u.email, name: u.name, locale: u.identity.uiLocale }));
}
+84
View File
@@ -0,0 +1,84 @@
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { SAMPLE_ORDER_PREFIX, SAMPLE_TEAM_NAME } from "./sample-data";
import { assertTenantWritable } from "./state";
/**
* L15 Testphase: "Erste Schritte" checklist on the dashboard of tenants that started as a trial.
* Items are detected automatically where possible (sample data does not count) and can be ticked
* manually; the whole card can be hidden. State in `TenantSettings.onboarding`.
*/
export const ONBOARDING_ITEMS = [
{ key: "company", href: "/settings" },
{ key: "team", href: "/teams" },
{ key: "technician", href: "/settings/users" },
{ key: "first_order", href: "/work-orders/new" },
{ key: "mobile", href: "/m" },
] as const;
export type OnboardingKey = (typeof ONBOARDING_ITEMS)[number]["key"];
type OnboardingJson = { done: string[]; hidden: boolean };
function parse(value: unknown): OnboardingJson {
const v = value && typeof value === "object" ? (value as Record<string, unknown>) : {};
return { done: Array.isArray(v.done) ? v.done.filter((x): x is string => typeof x === "string") : [], hidden: v.hidden === true };
}
export function isOnboardingKey(key: string): key is OnboardingKey {
return ONBOARDING_ITEMS.some((i) => i.key === key);
}
export async function getOnboardingChecklist(ctx: ServiceCtx) {
if (!can(ctx, "tenant:manage")) return null;
const tenant = await prisma.tenant.findUnique({ where: { id: ctx.tenantId }, select: { trialStartedAt: true } });
if (!tenant?.trialStartedAt) return null;
const settings = await ctx.db.tenantSettings.findFirst({ select: { onboarding: true, address: true, phone: true, email: true } });
const state = parse(settings?.onboarding);
if (state.hidden) return null;
const [teams, fieldUsers, orders] = await Promise.all([
ctx.db.team.count({ where: { deletedAt: null, name: { not: SAMPLE_TEAM_NAME } } }),
ctx.db.user.count({ where: { userRoles: { some: { role: { key: { in: ["technician", "team-lead"] } } } } } }),
ctx.db.workOrder.count({ where: { OR: [{ externalOrderNumber: null }, { NOT: { externalOrderNumber: { startsWith: SAMPLE_ORDER_PREFIX } } }] } }),
]);
const auto: Record<OnboardingKey, boolean> = {
company: !!(settings?.address || settings?.phone || settings?.email),
team: teams > 0,
technician: fieldUsers > 0,
first_order: orders > 0,
mobile: false,
};
const items = ONBOARDING_ITEMS.map((i) => ({ key: i.key, href: i.href, auto: auto[i.key], done: auto[i.key] || state.done.includes(i.key) }));
return { items, completed: items.filter((i) => i.done).length, total: items.length };
}
async function write(ctx: ServiceCtx, next: OnboardingJson, before: OnboardingJson) {
await ctx.db.tenantSettings.update({ where: { tenantId: ctx.tenantId }, data: { onboarding: next } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "onboarding", entityId: ctx.tenantId, before, after: next });
}
async function load(ctx: ServiceCtx): Promise<OnboardingJson> {
const settings = await ctx.db.tenantSettings.findFirst({ select: { onboarding: true } });
if (!settings) throw new ServiceError("not_found", "tenant settings not found");
return parse(settings.onboarding);
}
export async function setOnboardingItem(ctx: ServiceCtx, key: string, done: boolean) {
assertCan(ctx, "tenant:manage");
if (!isOnboardingKey(key)) throw new ServiceError("invalid", "unknown_item", { field: "key" });
await assertTenantWritable(ctx.tenantId);
const before = await load(ctx);
const set = new Set(before.done);
if (done) set.add(key);
else set.delete(key);
await write(ctx, { ...before, done: [...set] }, before);
}
export async function setOnboardingHidden(ctx: ServiceCtx, hidden: boolean) {
assertCan(ctx, "tenant:manage");
await assertTenantWritable(ctx.tenantId);
const before = await load(ctx);
await write(ctx, { ...before, hidden }, before);
}
+132
View File
@@ -0,0 +1,132 @@
import { Prisma } from "@prisma/client";
import { randomBytes } from "node:crypto";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { generateCompliantPassword, hashPassword } from "@/server/password";
import { provisionTenant } from "@/server/provision";
import { MODULE_KEYS } from "@/lib/modules";
import { trialEndInstant, todayKey } from "@/lib/trial/dates";
import { daysMs, TRIAL_DELETION_GRACE_DAYS } from "./config";
import { seedTrialSampleData } from "./sample-data";
/**
* L15 Testphase: create a trial tenant (self-service signup after double opt-in, or platform wizard).
*
* The tenant row is CREATED here first (unique slug, trial fields) and only then completed by the
* idempotent `provisionTenant` (roles, admin, settings, modules). `provisionTenant` upserts by slug —
* reserving the slug with a plain `create` first guarantees that two concurrent signups with the same
* company name never end up in the same tenant.
*/
export function slugifyCompany(name: string): string {
const base = name
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40)
.replace(/-+$/g, "");
return base.length >= 3 ? base : "betrieb";
}
export type ProvisionTrialInput = {
companyName: string;
sector?: string | null;
companySize?: string | null;
admin: { name: string; email: string; passwordHash?: string };
/** Last trial day YYYY-MM-DD (Berlin). */
endDateKey: string;
sampleData: boolean;
modules?: readonly string[];
source: "self_signup" | "platform";
actorId?: string | null;
now?: Date;
};
export type ProvisionTrialResult = {
tenantId: string;
tenantSlug: string;
adminUserId: string;
identityId: string;
identityCreated: boolean;
sampleData: "created" | "skipped" | "failed";
};
async function reserveTenant(input: ProvisionTrialInput, now: Date) {
const base = slugifyCompany(input.companyName);
const endsAt = trialEndInstant(input.endDateKey < todayKey(now) ? todayKey(now) : input.endDateKey);
const candidates = [base, ...Array.from({ length: 19 }, (_, i) => `${base}-${i + 2}`), `${base}-${randomBytes(3).toString("hex")}`, `${base}-${randomBytes(4).toString("hex")}`];
for (const slug of candidates) {
try {
return await prisma.tenant.create({
data: {
name: input.companyName,
slug,
sector: input.sector || null,
plan: "TRIAL",
trialSource: input.source,
trialStartedAt: now,
trialEndsAt: endsAt,
deletionDueAt: new Date(endsAt.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)),
config: { trial: { companySize: input.companySize || null, source: input.source } },
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") continue; // slug taken → next candidate
throw err;
}
}
throw new Error("no free tenant slug");
}
export async function provisionTrialTenant(input: ProvisionTrialInput): Promise<ProvisionTrialResult> {
const now = input.now ?? new Date();
const email = input.admin.email.trim().toLowerCase();
const tenant = await reserveTenant(input, now);
const modules = input.modules?.length ? input.modules.filter((m) => (MODULE_KEYS as readonly string[]).includes(m)) : [...MODULE_KEYS];
// Without a password (platform wizard): unusable random password + forced change; the admin sets
// their own password through the existing invitation link (actions/platform-users.ts pattern).
const passwordHash = input.admin.passwordHash ?? (await hashPassword(generateCompliantPassword()));
const provisioned = await provisionTenant(prisma, {
name: input.companyName,
slug: tenant.slug,
sector: input.sector || undefined,
admin: { email, name: input.admin.name, passwordHash, mustChangePassword: !input.admin.passwordHash },
modules,
actorId: input.actorId ?? null,
});
let sampleData: ProvisionTrialResult["sampleData"] = "skipped";
if (input.sampleData) {
try {
await seedTrialSampleData(tenant.id, provisioned.adminUserId);
sampleData = "created";
} catch (err) {
// sample data is a convenience — the trial itself must not fail because of it
console.error("[trial] sample data failed:", (err as Error).message);
sampleData = "failed";
}
}
await writeAuditLog({
tenantId: tenant.id,
scope: "platform",
actorId: input.actorId ?? undefined,
action: "create",
entity: "trial",
entityId: tenant.id,
after: { source: input.source, endDate: input.endDateKey, trialEndsAt: tenant.trialEndsAt, deletionDueAt: tenant.deletionDueAt, modules, sampleData },
});
return {
tenantId: tenant.id,
tenantSlug: tenant.slug,
adminUserId: provisioned.adminUserId,
identityId: provisioned.identityId,
identityCreated: provisioned.identityCreated,
sampleData,
};
}
+65
View File
@@ -0,0 +1,65 @@
import { dbForTenant } from "@/server/db";
import { ROLE_DEFS } from "@/server/rbac";
import type { ServiceCtx } from "@/server/services/context";
import { createCustomer } from "@/server/services/customers/customers";
import { createSite } from "@/server/services/sites/sites";
import { createTeam } from "@/server/services/teams/teams";
import { ensureDefaultOrderTypes } from "@/server/services/work-orders/settings";
import { createWorkOrder } from "@/server/services/work-orders/create";
import { assignWorkOrder } from "@/server/services/work-orders/assign";
/**
* L15 Testphase: compact sample data for a new trial tenant (customers, sites, one team, work
* orders in several states). Same approach as the demo seed (scripts/lib/demo-seed.ts): every
* record is created through the REAL domain services (numbering, audit, status history). The demo
* seed itself needs seven users with fixed roles and a PDF/photo pipeline; a trial tenant starts
* with ONE administrator, so this is a trimmed variant for exactly that situation.
* All names and addresses are fictitious.
*/
export const SAMPLE_ORDER_PREFIX = "BEISPIEL-";
export const SAMPLE_TEAM_NAME = "Beispielteam";
const SAMPLE_NOTE = "Beispieldaten der Testphase – können jederzeit gelöscht werden.";
function inDays(days: number, hour: number): Date {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(hour, 0, 0, 0);
return d;
}
export async function seedTrialSampleData(tenantId: string, adminUserId: string): Promise<{ customers: number; sites: number; teams: number; orders: number }> {
const ctx: ServiceCtx = { db: dbForTenant(tenantId), tenantId, userId: adminUserId, permissions: new Set<string>(ROLE_DEFS["tenant-admin"].permissions) };
await ensureDefaultOrderTypes(ctx.db, tenantId);
const types = new Map((await ctx.db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id]));
const type = (key: string) => types.get(key) ?? null;
const customers = [
{ companyName: "Hausverwaltung Musterhof GmbH", street: "Lindenstraße", houseNumber: "12", postalCode: "10969", city: "Berlin", phone: "030 555 0100", email: "service@musterhof.example" },
{ companyName: "Bäckerei Sonnenschein", street: "Marktplatz", houseNumber: "3", postalCode: "04109", city: "Leipzig", phone: "0341 555 0200" },
{ firstName: "Erika", lastName: "Beispiel", street: "Am Wiesengrund", houseNumber: "7", postalCode: "30159", city: "Hannover", phone: "0511 555 0300", email: "erika.beispiel@example.org" },
];
const customerIds: string[] = [];
for (const c of customers) {
customerIds.push((await createCustomer(ctx, { ...c, notes: SAMPLE_NOTE }, { acknowledgeDuplicates: true })).id);
}
const sites = [
{ customerId: customerIds[0], name: "Wohnanlage Musterhof – Haus 1", street: "Lindenstraße", houseNumber: "12a", postalCode: "10969", city: "Berlin", accessNotes: "Schlüssel beim Hausmeister (Erdgeschoss links)." },
{ customerId: customerIds[1], name: "Backstube", street: "Marktplatz", houseNumber: "3", postalCode: "04109", city: "Leipzig", accessNotes: "Lieferanteneingang hinten, ab 5 Uhr besetzt." },
{ customerId: customerIds[2], name: "Einfamilienhaus Am Wiesengrund", street: "Am Wiesengrund", houseNumber: "7", postalCode: "30159", city: "Hannover", accessNotes: "Kundin ist vormittags zu Hause." },
];
const siteIds: string[] = [];
for (const s of sites) siteIds.push((await createSite(ctx, s)).id);
const team = await createTeam(ctx, { name: SAMPLE_TEAM_NAME, leaderUserId: adminUserId, members: [{ userId: adminUserId }], notes: SAMPLE_NOTE });
await createWorkOrder(ctx, { title: "Heizungswartung vor der Heizperiode", customerId: customerIds[0], siteId: siteIds[0], orderTypeId: type("wartung"), status: "draft", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}01`, plannedStart: inDays(5, 8), plannedEnd: inDays(5, 12), description: SAMPLE_NOTE });
await createWorkOrder(ctx, { title: "Wasserenthärtung prüfen", customerId: customerIds[1], siteId: siteIds[1], orderTypeId: type("wartung"), status: "planned", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}02`, plannedStart: inDays(2, 7), plannedEnd: inDays(2, 9), materials: [{ name: "Filterkartusche", plannedQuantity: 1, unit: "Stk" }] });
const assigned = await createWorkOrder(ctx, { title: "Heizkörper im Wohnzimmer tauschen", customerId: customerIds[2], siteId: siteIds[2], orderTypeId: type("montage"), status: "planned", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}03`, plannedStart: inDays(1, 8), plannedEnd: inDays(1, 15), materials: [{ name: "Flachheizkörper 22/600/1000", plannedQuantity: 1, unit: "Stk" }] });
await assignWorkOrder(ctx, { workOrderId: assigned.id, teamId: team.id, userIds: [adminUserId] });
await createWorkOrder(ctx, { title: "Undichte Leitung im Keller", customerId: customerIds[0], siteId: siteIds[0], orderTypeId: type("reparatur"), priority: "high", status: "review_required", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}04`, description: "Aus einer Kundenmail übernommen – bitte prüfen." });
return { customers: customerIds.length, sites: siteIds.length, teams: 1, orders: 4 };
}
+184
View File
@@ -0,0 +1,184 @@
import { createHash, createHmac, randomBytes } from "node:crypto";
import { prisma } from "@/server/db";
import { writePlatformAudit } from "@/server/audit";
import { hashPassword } from "@/server/password";
import { absoluteUrl } from "@/server/mail/config";
import { enqueueMail } from "@/server/mail/service";
import { formatWhen, normalizeLocale } from "@/server/mail/templates";
import { formatDateKey, trialBounds, type TrialBounds } from "@/lib/trial/dates";
import { normalizeTrialValues, TRIAL_STEPS, validateTrialSignup, validateTrialStep, type FieldErrors, type TrialStep } from "@/lib/trial/signup";
import { trialMaxDays, TRIAL_DEFAULT_DAYS, TRIAL_SIGNUP_TOKEN_TTL_MS } from "./config";
import { provisionTrialTenant } from "./provision";
/**
* L15 Testphase: public self-service signup with double opt-in.
*
* - Nothing is provisioned before the e-mail address is confirmed; the pending signup lives in
* `TrialSignup` (platform table) with the Argon2id(+pepper) password hash and the SHA-256 of the
* link token — never the raw password/token. The IP is stored only as HMAC.
* - Enumeration protection: an address that already has an account gets exactly the same response
* (and the same work: the password is hashed in both branches), but a hint mail instead of a
* confirmation link.
* - Rate limits and the honeypot check live in the action adapter (src/server/actions/trial-signup.ts
* via ./abuse.ts), so this service stays testable without a request.
*/
export type SendMail = typeof enqueueMail;
export type SignupResult = { status: "sent" } | { status: "invalid"; errors: FieldErrors };
export type ConfirmResult =
| { status: "ok"; tenantId: string; tenantSlug: string; identityId: string }
| { status: "invalid" }
| { status: "expired" };
export function hashSignupToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
export function hashIp(ip: string | null | undefined): string | null {
if (!ip) return null;
return createHmac("sha256", process.env.AUTH_SECRET ?? "craftvia-trial").update(ip).digest("hex");
}
export function currentTrialBounds(now: Date = new Date()): TrialBounds {
return trialBounds(now, trialMaxDays(), TRIAL_DEFAULT_DAYS);
}
/** Server-side validation of one wizard step (no writes). Unknown step → form error. */
export function checkTrialStep(step: string, raw: unknown, now: Date = new Date()): FieldErrors {
if (!(TRIAL_STEPS as readonly string[]).includes(step)) return { _form: "invalid_request" };
const values = normalizeTrialValues(raw);
if (!values) return { _form: "invalid_request" };
return validateTrialStep(step as TrialStep, values, currentTrialBounds(now));
}
export async function submitTrialSignup(
raw: unknown,
opts: { ip?: string | null; now?: Date; locale?: string | null; sendMail?: SendMail } = {},
): Promise<SignupResult> {
const now = opts.now ?? new Date();
const send = opts.sendMail ?? enqueueMail;
const locale = normalizeLocale(opts.locale);
const values = normalizeTrialValues(raw);
if (!values) return { status: "invalid", errors: { _form: "invalid_request" } };
// Honeypot filled → pretend success, do nothing.
if (values.website.trim() !== "") return { status: "sent" };
const errors = validateTrialSignup(values, currentTrialBounds(now));
if (Object.keys(errors).length > 0) return { status: "invalid", errors };
// Hash in BOTH branches — equal work, no timing oracle for existing addresses.
const passwordHash = await hashPassword(values.password);
const existing = await prisma.identity.findUnique({ where: { email: values.email }, select: { id: true } });
if (existing) {
const hourBucket = Math.floor(now.getTime() / 3_600_000);
await send({
template: "trial_existing_account",
to: values.email,
tenantId: null,
locale,
vars: { name: values.adminName, loginUrl: absoluteUrl("/login"), resetUrl: absoluteUrl("/forgot-password") },
// at most one hint mail per address per hour
dedupeKey: `trial_existing:${hashSignupToken(values.email)}:${hourBucket}`,
});
await writePlatformAudit({ action: "denied", entity: "trial_signup", after: { reason: "existing_account" } });
return { status: "sent" };
}
const rawToken = randomBytes(32).toString("base64url");
const expiresAt = new Date(now.getTime() + TRIAL_SIGNUP_TOKEN_TTL_MS);
// A new request makes older open links of the same address useless.
await prisma.trialSignup.updateMany({ where: { email: values.email, status: "pending" }, data: { status: "superseded", passwordHash: "" } });
const signup = await prisma.trialSignup.create({
data: {
companyName: values.companyName,
sector: values.sector || null,
companySize: values.companySize || null,
adminName: values.adminName,
email: values.email,
passwordHash,
trialEndDate: values.trialEndDate,
sampleData: values.sampleData,
modules: values.modules,
locale,
tokenHash: hashSignupToken(rawToken),
expiresAt,
ipHash: hashIp(opts.ip),
acceptedTermsAt: now,
},
select: { id: true },
});
await send({
template: "trial_confirm",
to: values.email,
tenantId: null,
locale,
vars: {
name: values.adminName,
companyName: values.companyName,
trialEnd: formatDateKey(values.trialEndDate, locale),
actionUrl: absoluteUrl(`/testen/bestaetigen?token=${encodeURIComponent(rawToken)}`),
expires: formatWhen(expiresAt, locale),
},
});
await writePlatformAudit({ action: "create", entity: "trial_signup", entityId: signup.id, after: { trialEndDate: values.trialEndDate, sampleData: values.sampleData, modules: values.modules.length } });
return { status: "sent" };
}
/** Shows the pending signup behind a link (without consuming it). */
export async function peekTrialSignup(rawToken: string, now: Date = new Date()) {
if (!rawToken || rawToken.length > 200) return null;
const row = await prisma.trialSignup.findUnique({
where: { tokenHash: hashSignupToken(rawToken) },
select: { status: true, expiresAt: true, companyName: true, trialEndDate: true, sampleData: true },
});
if (!row || row.status !== "pending" || row.expiresAt <= now) return null;
return { companyName: row.companyName, trialEndDate: row.trialEndDate, sampleData: row.sampleData };
}
/** Consumes the link (single use, 24 h) and provisions the trial tenant. */
export async function confirmTrialSignup(rawToken: string, opts: { now?: Date } = {}): Promise<ConfirmResult> {
const now = opts.now ?? new Date();
if (!rawToken || rawToken.length > 200) return { status: "invalid" };
const row = await prisma.trialSignup.findUnique({ where: { tokenHash: hashSignupToken(rawToken) } });
if (!row || row.status !== "pending") return { status: "invalid" };
if (row.expiresAt <= now) {
await prisma.trialSignup.updateMany({ where: { id: row.id, status: "pending" }, data: { status: "expired", passwordHash: "" } });
return { status: "expired" };
}
// Claim: only one of several parallel clicks wins.
const claim = await prisma.trialSignup.updateMany({ where: { id: row.id, status: "pending" }, data: { status: "confirming" } });
if (claim.count !== 1) return { status: "invalid" };
// The address may have got an account in the meantime (invitation, other signup).
if (await prisma.identity.findUnique({ where: { email: row.email }, select: { id: true } })) {
await prisma.trialSignup.update({ where: { id: row.id }, data: { status: "existing_account", passwordHash: "" } });
await writePlatformAudit({ action: "denied", entity: "trial_signup", entityId: row.id, after: { reason: "existing_account" } });
return { status: "invalid" };
}
try {
const result = await provisionTrialTenant({
companyName: row.companyName,
sector: row.sector,
companySize: row.companySize,
admin: { name: row.adminName, email: row.email, passwordHash: row.passwordHash },
endDateKey: row.trialEndDate,
sampleData: row.sampleData,
modules: row.modules,
source: "self_signup",
now,
});
await prisma.trialSignup.update({
where: { id: row.id },
data: { status: "confirmed", confirmedAt: now, provisionedTenantId: result.tenantId, passwordHash: "" },
});
await writePlatformAudit({ action: "update", entity: "trial_signup", entityId: row.id, after: { status: "confirmed", tenantId: result.tenantId } });
return { status: "ok", tenantId: result.tenantId, tenantSlug: result.tenantSlug, identityId: result.identityId };
} catch (err) {
await prisma.trialSignup.update({ where: { id: row.id }, data: { status: "failed", passwordHash: "" } });
throw err;
}
}
+100
View File
@@ -0,0 +1,100 @@
import { prisma } from "@/server/db";
import { ServiceError } from "@/server/services/context";
import { diffDayKeys, todayKey, trialEndDateKey } from "@/lib/trial/dates";
import { TRIAL_BANNER_DAYS } from "./config";
/**
* L15 Testphase: lifecycle state of a tenant and the CENTRAL write lock.
*
* After the chosen end the trial tenant is read-only: every mutation path calls
* `assertTenantWritable(tenantId)` (moduleGuard, requireApiContext for non-GET /api/v1 requests,
* backoffice upload route, settings/user administration). Reads, file downloads, PDFs, the data
* export and the login stay possible. The lock depends only on `trialEndsAt` — not on the daily
* job — so it applies to the second.
*
* `Tenant` is a platform table (no tenant_id / RLS); it is read via the owner client by id.
*/
export type TenantTrialRow = {
plan: "FULL" | "TRIAL";
trialStartedAt: Date | null;
trialEndsAt: Date | null;
convertedAt: Date | null;
readOnlySince: Date | null;
deletionDueAt: Date | null;
trialDeletedAt?: Date | null;
};
export type TrialState = {
isTrial: boolean;
endsAt: Date | null;
/** Last trial day (YYYY-MM-DD, Berlin). */
endDateKey: string | null;
/** Calendar days until the last trial day (0 = ends today); null without trial. */
daysLeft: number | null;
expired: boolean;
readOnly: boolean;
deletionDueAt: Date | null;
/** Show "trial ends in X days" (from TRIAL_BANNER_DAYS on). */
showCountdown: boolean;
};
export const TRIAL_SELECT = {
plan: true,
trialStartedAt: true,
trialEndsAt: true,
convertedAt: true,
readOnlySince: true,
deletionDueAt: true,
trialDeletedAt: true,
} as const;
export function computeTrialState(row: TenantTrialRow | null, now: Date = new Date()): TrialState {
if (!row || row.plan !== "TRIAL" || !row.trialEndsAt) {
return { isTrial: false, endsAt: null, endDateKey: null, daysLeft: null, expired: false, readOnly: false, deletionDueAt: null, showCountdown: false };
}
const endDateKey = trialEndDateKey(row.trialEndsAt);
const expired = now.getTime() >= row.trialEndsAt.getTime();
const daysLeft = diffDayKeys(todayKey(now), endDateKey);
return {
isTrial: true,
endsAt: row.trialEndsAt,
endDateKey,
daysLeft,
expired,
readOnly: expired,
deletionDueAt: row.deletionDueAt,
showCountdown: !expired && daysLeft <= TRIAL_BANNER_DAYS,
};
}
export async function getTrialState(tenantId: string, now: Date = new Date()): Promise<TrialState> {
const row = await prisma.tenant.findUnique({ where: { id: tenantId }, select: TRIAL_SELECT });
return computeTrialState(row, now);
}
/** Plain-text reason for API clients and legacy action forms that show `err.message`-style texts. */
export const TRIAL_READ_ONLY_MESSAGE = "Testphase abgelaufen – nur Lesezugriff. Export und Downloads bleiben möglich.";
/** `blocked trial_expired` — mapped to HTTP 422 by respond.ts, shown as plain text by the UI. */
export class TrialExpiredError extends ServiceError {
constructor(deletionDueAt: Date | null) {
super("blocked", "trial_expired", {
reason: "trial_expired",
readOnly: true,
deletionDueAt: deletionDueAt?.toISOString() ?? null,
message: TRIAL_READ_ONLY_MESSAGE,
});
this.name = "TrialExpiredError";
}
}
export function isTrialExpiredError(err: unknown): boolean {
return err instanceof ServiceError && err.code === "blocked" && err.message === "trial_expired";
}
/** Throws `TrialExpiredError` when the tenant is an expired, unconverted trial. */
export async function assertTenantWritable(tenantId: string, now: Date = new Date()): Promise<void> {
const state = await getTrialState(tenantId, now);
if (state.readOnly) throw new TrialExpiredError(state.deletionDueAt);
}
@@ -0,0 +1,33 @@
import { DeleteObjectsCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
/**
* L15 Testphase: remove ALL objects of a tenant from the document/upload bucket (`<tenantId>/…`).
*
* The upload adapter (src/server/storage/adapter.ts, foundation) only offers put/get; the DSGVO
* offboarding removes `<tenantId>/uploads/` from the BACKUP store, which is not necessarily the
* upload bucket (backup target can be local or a different bucket). Same S3_* configuration as the
* upload adapter; without S3 (stub storage) there are no bytes to delete.
*/
export async function purgeTenantObjects(tenantId: string): Promise<number> {
if (!/^[A-Za-z0-9_-]{8,}$/.test(tenantId)) throw new Error("purgeTenantObjects: invalid tenant id");
const endpoint = process.env.S3_ENDPOINT?.trim();
const accessKeyId = process.env.S3_ACCESS_KEY?.trim();
const secretAccessKey = process.env.S3_SECRET_KEY?.trim();
const bucket = process.env.S3_BUCKET?.trim();
if (!endpoint || !accessKeyId || !secretAccessKey || !bucket) return 0;
const client = new S3Client({ endpoint, region: process.env.S3_REGION?.trim() || "us-east-1", forcePathStyle: true, credentials: { accessKeyId, secretAccessKey } });
const prefix = `${tenantId}/`;
let removed = 0;
let token: string | undefined;
do {
const page = await client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ContinuationToken: token }));
const keys = (page.Contents ?? []).map((o) => o.Key).filter((k): k is string => !!k && k.startsWith(prefix));
if (keys.length) {
await client.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: keys.map((Key) => ({ Key })), Quiet: true } }));
removed += keys.length;
}
token = page.IsTruncated ? page.NextContinuationToken : undefined;
} while (token);
return removed;
}