import { cache } from "react"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import type { Benutzer } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung"; import { darf, type Recht } from "@/server/auth/rollen"; /** * Wer ist angemeldet und was darf er (#007). * * Rolle und Aktiv-Status kommen bei **jeder** Anfrage aus der Datenbank, nicht * aus dem Cookie. Das kostet eine Abfrage und ist es wert: eine entzogene Rolle * greift sofort, und ein deaktiviertes Konto kommt nicht mit einem gueltigen * Cookie noch zwoelf Stunden weiter. */ export class RechteFehler extends Error { constructor(nachricht: string) { super(nachricht); this.name = "RechteFehler"; } } export type Urteil = | { readonly gueltig: true } | { readonly gueltig: false; readonly grund: "unbekannt" | "deaktiviert" | "widerrufen" }; /** * Reine Entscheidung, ob eine Sitzung noch traegt – ohne Datenbank und ohne Next, * damit sie sich einzeln testen laesst. */ export function sitzungBewerten( sitzung: Sitzung, benutzer: Pick | null, ): Urteil { if (!benutzer || benutzer.id !== sitzung.benutzerId) { return { gueltig: false, grund: "unbekannt" }; } if (!benutzer.aktiv) { return { gueltig: false, grund: "deaktiviert" }; } if (benutzer.sitzungenUngueltigAb) { // Der Anspruch iat steht in Sekunden, der Zeitstempel in Millisekunden. const ausgestellt = sitzung.ausgestelltAm * 1000; if (ausgestellt < benutzer.sitzungenUngueltigAb.getTime()) { return { gueltig: false, grund: "widerrufen" }; } } return { gueltig: true }; } export type Angemeldet = { readonly benutzer: Benutzer; readonly sitzung: Sitzung; }; /** * Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es * `sitzungErforderlich`. * * `cache` sorgt dafuer, dass mehrere Komponenten derselben Anfrage sich eine * Abfrage teilen. */ export const angemeldet = cache(async (): Promise => { const laden = await cookies(); const sitzung = await sitzungLesen(laden.get(SITZUNG_COOKIE)?.value); if (!sitzung) return undefined; const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } }); const urteil = sitzungBewerten(sitzung, benutzer); if (!urteil.gueltig || !benutzer) return undefined; return { benutzer, sitzung }; }); /** * Verlangt eine Anmeldung und – wenn angegeben – ein bestimmtes Recht. * * Ohne Anmeldung geht es zur Anmeldeseite, mit `ziel` auf die urspruenglich * angeforderte Adresse. Genau daran haengt, dass ein gescannter QR-Code auch * dann funktioniert, wenn noch niemand angemeldet war. */ export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise { const vorhanden = await angemeldet(); if (!vorhanden) { redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden"); } if (recht && !darf(vorhanden.benutzer.rolle, recht)) { // Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden, // der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft. // Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt // das Werfen richtig, siehe rechtErforderlich. redirect("/anmelden?fehler=keinrecht"); } return vorhanden; } /** * Dasselbe fuer Server Actions und Route Handler: wirft statt weiterzuleiten, * weil eine Weiterleitung mitten in einer Aktion nichts hilft. */ export async function rechtErforderlich(recht: Recht): Promise { const vorhanden = await angemeldet(); if (!vorhanden) { throw new RechteFehler("Nicht angemeldet."); } if (!darf(vorhanden.benutzer.rolle, recht)) { throw new RechteFehler( `Diese Aktion verlangt das Recht "${recht}", die Rolle ${vorhanden.benutzer.rolle} hat es nicht.`, ); } return vorhanden; }