Files
craftvia/src/server/app-access.ts
T
msolarczekandClaude Opus 5 95f41b29f5 L4 Einsatz mobil: Mobile Shell und Einsatz-Oberfläche
- /m in eigene Route-Group (field)/m verschoben (emergency-Platzhalter mit),
  Zugriffsprüfungen aus (app)/layout.tsx nach server/app-access.ts extrahiert
  und von Backoffice- und Mobile-Shell gemeinsam genutzt
- Mobile Shell mit Bottom-Nav (Heute · Aufträge · Notdienst · Sync · Profil)
  und Online/Offline-Badge; Startseite rollenabhängig (Feldrollen → /m),
  Login-Default-Redirect auf /
- Heute, Auftragsliste mit Tabs, Auftragsdetail mit einer Primäraktion je
  Zustand, Unterseiten Fotos (Kamera, Kompression, Upload-Fortschritt),
  Notizen + Sprachaufnahme, Material mit Stepper, Checkliste, Zeiten mit
  Korrektur, Profil; Sync-Platzhalter für L7
- Client-Wrapper submitOp (lib/field/client-ops.ts), Upload mit Fortschritt,
  Bildkompression, Formatierung; Texte in messages/{de,en}/field.json

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:30:03 +02:00

73 lines
3.5 KiB
TypeScript

import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { auth, signOut } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { isTokenStillValid } from "@/server/sessions";
import { resolveMfaRequired } from "@/lib/mfa-policy";
/**
* Gemeinsame Zugriffsprüfung der angemeldeten Mandanten-Shells (Backoffice `(app)/layout.tsx`
* und Mobile `(field)/m/layout.tsx`). Aus dem Backoffice-Layout extrahiert (ARCHITEKTUR §5),
* damit beide Shells identisch prüfen:
* Session → aktiver Mandant → Kill-Switch → Konto-/Identity-Status → Passwortzwang → MFA-Gate.
* Redirects werden hier ausgelöst; ein deaktiviertes Konto wird als `inactive` gemeldet,
* die aufrufende Shell rendert dafür `AccountInactiveNotice`.
*/
export type AppAccess =
| { kind: "inactive" }
| { kind: "ok"; session: Session; identity: { uiLocale: string } };
export async function requireAppAccess(): Promise<AppAccess> {
const session = await auth();
if (!session?.user) redirect("/login");
// Angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne Slug) →
// Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId).
if (!session.user.tenantId) redirect("/select-tenant");
// Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
// abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom
// User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity.
const account = await dbForTenant(session.user.tenantId).user.findUnique({
where: { id: session.user.id },
select: { status: true },
});
const identity = session.user.identityId
? await prisma.identity.findUnique({
where: { id: session.user.identityId },
select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true },
})
: null;
// SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden.
if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
await signOut({ redirectTo: "/login" });
}
if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") {
return { kind: "inactive" };
}
if (identity.mustChangePassword) redirect("/change-password");
// SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP
// noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb der Shells).
if (!identity.mfaEnrolledAt) {
const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } });
if (resolveMfaRequired(settings?.securityPolicy)) {
const passkeys = session.user.identityId
? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } })
: 0;
if (passkeys === 0) redirect("/enroll-mfa");
}
}
return { kind: "ok", session, identity: { uiLocale: identity.uiLocale } };
}
/**
* Startseite nach dem Login (ARCHITEKTUR §5): Feldrollen (Einsatz ausführen, keine
* Gesamtsicht auf Aufträge) landen in der Mobile-App `/m`, alle anderen im Backoffice.
* Reiner UX-Komfort — die Zielseiten prüfen Rechte selbst.
*/
export function landingPath(session: Session | null): string {
const perms = session?.user?.permissions ?? [];
return perms.includes("field:execute") && !perms.includes("work_order:read_all") ? "/m" : "/dashboard";
}