Phase 10: Mandantenfähigkeit (#15)

This commit was merged in pull request #15.
This commit is contained in:
2026-09-05 13:47:46 +00:00
parent 2b53556175
commit 91742a0dd7
98 changed files with 3810 additions and 201 deletions
+5
View File
@@ -20,6 +20,11 @@ export default async function AnmeldemarkeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
// Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören
// einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen
// Ausweis für eine Wehr, der man nicht angehört, gibt es nicht.
if (!sitzung.personId) redirect("/");
const marke = markeErzeugen(sitzung.personId, sitzung.organisationId);
const bild = await qrTextAlsDataUri(marke, 420);
+11 -2
View File
@@ -25,9 +25,18 @@ export default async function AusweisSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
// Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören
// einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen
// Ausweis für eine Wehr, der man nicht angehört, gibt es nicht.
if (!sitzung.personId) redirect("/");
// In eine eigene Konstante, damit die Einengung auch im Rueckruf gilt:
// TypeScript verfolgt sie ueber eine Eigenschaft hinweg nicht.
const eigeneAkteId = sitzung.personId;
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const person = await db.person.findUnique({
where: { id: sitzung.personId },
where: { id: eigeneAkteId },
select: {
vorname: true,
nachname: true,
@@ -48,7 +57,7 @@ export default async function AusweisSeite() {
return person;
});
const code = ausweisErzeugen(sitzung.personId, sitzung.organisationId);
const code = ausweisErzeugen(eigeneAkteId, sitzung.organisationId);
const adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`;
const bild = await qrTextAlsDataUri(adresse, 420);
+3
View File
@@ -3,6 +3,7 @@ import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { rechteVon } from "@/lib/authz";
import { Seitenleiste } from "@/components/layout/seitenleiste";
import { AufsichtHinweis } from "@/components/layout/wehrwaehler";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { TrpcProvider } from "@/lib/trpc/provider";
@@ -31,9 +32,11 @@ export default async function AngemeldetLayout({
rollen={sitzung.rollen}
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
personName={sitzung.name}
istVerwalter={sitzung.istVerwalter ?? false}
/>
<div className="md:pl-64">
<AufsichtHinweis />
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
<ThemeToggle />
</header>
+33 -1
View File
@@ -48,7 +48,17 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
eintrittsdatum: true,
austrittsdatum: true,
aktiv: true,
letzteAnmeldung: true,
// Seit Phase 10 hängt die Anmeldung am Konto, nicht an der Akte. Ob
// ein Mitglied überhaupt eines hat, ist die nützlichere Auskunft als
// ein Zeitstempel: ohne Konto kommt es nicht in FLORIAN hinein.
mitgliedschaft: {
select: {
art: true,
benutzer: {
select: { email: true, letzteAnmeldung: true, aktiv: true },
},
},
},
rollen: { include: { rolle: true } },
qualifikationen: {
where: { geloeschtAm: null },
@@ -86,6 +96,28 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
"Anschrift",
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
],
[
"Zugang",
person.mitgliedschaft?.benutzer ? (
<>
{person.mitgliedschaft.benutzer.email}
<span className="block text-xs text-[var(--muted-foreground)]">
{person.mitgliedschaft.benutzer.aktiv
? person.mitgliedschaft.benutzer.letzteAnmeldung
? `zuletzt angemeldet am ${alsDatum(person.mitgliedschaft.benutzer.letzteAnmeldung)}`
: "noch nie angemeldet"
: "Konto abgeschaltet"}
</span>
</>
) : (
<>
kein Konto
<span className="block text-xs text-[var(--muted-foreground)]">
Diese Person ist geführt, meldet sich aber nicht an.
</span>
</>
),
],
];
/* eslint-enable react/jsx-key */
+6
View File
@@ -24,6 +24,12 @@ export default async function NeuePruefungSeite({
redirect("/pruefungen");
}
// Eine Prüfung braucht einen Prüfer mit Namen und Akte — sie ist der
// Nachweis gegenüber der Unfallkasse. Das Recht oben schließt die
// Kreisaufsicht bereits aus; die Zusicherung steht hier trotzdem, weil
// sich Rechte verschieben lassen und ein Protokoll ohne Prüfer wertlos ist.
if (!sitzung.personId) redirect("/pruefungen");
const { geraetId, terminId, vorlageId } = await searchParams;
if (!geraetId) redirect("/pruefungen");
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { sitzungNeuAusstellen } from "@/lib/auth";
/**
* In eine andere Wehr wechseln.
*
* Eine Server Action und keine tRPC-Mutation: der Wechsel ändert das
* Sitzungstoken im Cookie, und das kann nur die Anmeldeschicht. tRPC läuft
* über dieselbe Anfrage, hat aber keinen Zugriff auf das Setzen des Cookies.
*
* Übergeben wird die Kennung der **Mitgliedschaft**, nicht die der Wehr. Das
* ist der Unterschied zwischen einem Wunsch und einer Anweisung: eine fremde
* Wehr zu nennen bringt nichts, weil es dazu keine Mitgliedschaft dieses
* Kontos gibt. Geprüft wird das serverseitig im `jwt`-Rückruf, nicht hier.
*/
export async function wehrWechseln(mitgliedschaftId: string): Promise<void> {
await sitzungNeuAusstellen({ user: { mitgliedschaftId } });
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { Wehrverwaltung } from "@/app/(app)/wehren/verwaltung";
export const metadata = { title: "Wehren und Konten" };
/**
* Die Verwaltung der Installation.
*
* Der einzige Bereich, der über Mandantengrenzen hinweg arbeitet — und
* deshalb der einzige, dessen Zugang nicht an einem Recht innerhalb einer Wehr
* hängt, sondern am Konto selbst.
*/
export default async function WehrenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!sitzung.istVerwalter) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Wehren und Konten</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Wehren dieser Installation, die Anmeldekonten und wer zu welcher Wehr gehört.
</p>
</div>
<Wehrverwaltung />
</div>
);
}
+611
View File
@@ -0,0 +1,611 @@
"use client";
import * as React from "react";
import { Building2, Eye, KeyRound, Plus, Trash2, UserPlus } from "lucide-react";
import { MitgliedschaftArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { alsDatum } from "@/lib/utils";
/**
* Wehren und Konten.
*
* Zwei Listen, die zusammengehören: eine Wehr ohne Konten kann niemand
* benutzen, und ein Konto ohne Mitgliedschaft kommt nirgends hinein. Deshalb
* stehen sie auf einer Seite und nicht in zwei Menüpunkten.
*/
export function Wehrverwaltung() {
const werkzeuge = trpc.useUtils();
const [wehrOffen, setWehrOffen] = React.useState(false);
const [kontoOffen, setKontoOffen] = React.useState(false);
const [zuordnen, setZuordnen] = React.useState<{ id: string; email: string } | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const wehren = trpc.wehrverwaltung.wehren.useQuery();
const konten = trpc.wehrverwaltung.konten.useQuery();
const entfernen = trpc.wehrverwaltung.mitgliedschaftEntfernen.useMutation({
onSuccess: () => {
setHinweis("Zuordnung entfernt. Die Personenakte in der Wehr bleibt bestehen.");
void werkzeuge.wehrverwaltung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="verwaltungsfehler" />}
{/* Eine gescheiterte Abfrage sähe sonst aus wie „es gibt nichts" — und
genau dieser Unterschied ist hier wichtig, weil eine leere
Wehrenliste ein plausibler Zustand ist. */}
{(wehren.error || konten.error) && (
<Fehlermeldung
meldung={wehren.error?.message ?? konten.error?.message ?? "Unbekannter Fehler"}
kennung="verwaltungsabfrage"
/>
)}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{/* --- Wehren ---------------------------------------------------- */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Wehren</h2>
<Button onClick={() => setWehrOffen(true)}>
<Plus aria-hidden />
Wehr anlegen
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{wehren.isLoading &&
[0, 1, 2].map((stelle) => (
<div
key={stelle}
className="h-32 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
/>
))}
{wehren.data?.map((wehr) => (
<Card key={wehr.id}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">{wehr.kurzname}</CardTitle>
{!wehr.aktiv && <Badge variant="ausgesondert">abgeschaltet</Badge>}
</div>
<CardDescription className="truncate">{wehr.name}</CardDescription>
</CardHeader>
<CardContent className="text-sm">
<p className="text-[var(--muted-foreground)]">
Kennung <code className="text-xs">{wehr.slug}</code>
{wehr.ort && ` · ${wehr.ort}`}
</p>
<p className="mt-1">
{wehr._count.personen} {wehr._count.personen === 1 ? "Person" : "Personen"} ·{" "}
{wehr._count.mitgliedschaften}{" "}
{wehr._count.mitgliedschaften === 1 ? "Konto" : "Konten"}
</p>
</CardContent>
</Card>
))}
</div>
</section>
{/* --- Konten ---------------------------------------------------- */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Konten</h2>
<Button variant="outline" onClick={() => setKontoOffen(true)}>
<KeyRound aria-hidden />
Konto anlegen
</Button>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Ein Konto gehört keiner Wehr — es gehört zu einer oder mehreren. Wer in zwei Wehren
geführt wird, bekommt deshalb kein zweites Konto, sondern eine zweite Zuordnung.
</p>
{!konten.isLoading && (konten.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Konten"
hinweis="Ohne Konto kommt niemand hinein — auch nicht in eine gerade angelegte Wehr."
/>
)}
<div className="space-y-3">
{konten.isLoading &&
[0, 1].map((stelle) => (
<div
key={stelle}
className="h-32 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
/>
))}
{konten.data?.map((konto) => (
<Card key={konto.id}>
<CardHeader className="pb-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate text-base">{konto.email}</CardTitle>
<CardDescription>
{konto.letzteAnmeldung
? `zuletzt angemeldet am ${alsDatum(konto.letzteAnmeldung)}`
: "noch nie angemeldet"}
{konto.oidcSubject && " · über authentik verknüpft"}
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
{konto.istVerwalter && <Badge variant="secondary">Verwalter</Badge>}
{!konto.aktiv && <Badge variant="ausgesondert">abgeschaltet</Badge>}
<Button
size="sm"
variant="outline"
onClick={() => setZuordnen({ id: konto.id, email: konto.email })}
>
<UserPlus aria-hidden />
Wehr zuordnen
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{konto.mitgliedschaften.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Keiner Wehr zugeordnet — die Anmeldung führt damit nirgendwohin.
</p>
) : (
<ul className="space-y-1.5 text-sm">
{konto.mitgliedschaften.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-center gap-2 rounded-md bg-[var(--muted)] px-3 py-1.5"
>
{eintrag.art === MitgliedschaftArt.AUFSICHT ? (
<Eye className="size-4 shrink-0" aria-hidden />
) : (
<Building2 className="size-4 shrink-0" aria-hidden />
)}
<span className="font-medium">{eintrag.organisation.kurzname}</span>
<span className="text-[var(--muted-foreground)]">
{eintrag.art === MitgliedschaftArt.AUFSICHT
? "Kreisaufsicht — nur lesen"
: eintrag.person
? `${eintrag.person.nachname}, ${eintrag.person.vorname}`
: "Mitglied"}
</span>
<Button
size="sm"
variant="ghost"
className="ml-auto"
onClick={() => entfernen.mutate({ id: eintrag.id })}
disabled={entfernen.isPending}
>
<Trash2 aria-hidden />
<span className="sr-only">Zuordnung entfernen</span>
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
))}
</div>
</section>
<WehrAnlegen
offen={wehrOffen}
schliessen={() => setWehrOffen(false)}
fehler={setMeldung}
fertig={(name) => {
setWehrOffen(false);
setHinweis(`${name} angelegt, samt Rollen. Jetzt fehlt noch ein Konto dafür.`);
void werkzeuge.wehrverwaltung.invalidate();
}}
/>
<KontoAnlegen
offen={kontoOffen}
schliessen={() => setKontoOffen(false)}
fehler={setMeldung}
fertig={(email) => {
setKontoOffen(false);
setHinweis(`Konto ${email} angelegt. Es gehört noch keiner Wehr an.`);
void werkzeuge.wehrverwaltung.invalidate();
}}
/>
<WehrZuordnen
konto={zuordnen}
schliessen={() => setZuordnen(null)}
fehler={setMeldung}
fertig={() => {
setZuordnen(null);
void werkzeuge.wehrverwaltung.invalidate();
}}
/>
</div>
);
}
function WehrAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (name: string) => void;
fehler: (nachricht: string) => void;
}) {
const [slug, setSlug] = React.useState("");
const [name, setName] = React.useState("");
const [kurzname, setKurzname] = React.useState("");
const [ort, setOrt] = React.useState("");
const [landkreis, setLandkreis] = React.useState("");
const anlegen = trpc.wehrverwaltung.wehrAnlegen.useMutation({
onSuccess: (wehr) => {
setSlug("");
setName("");
setKurzname("");
setOrt("");
setLandkreis("");
fertig(wehr.kurzname);
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Wehr anlegen</DialogTitle>
<DialogDescription>
Sie bekommt sofort ihre Systemrollen. Ein Konto bekommt sie nicht — wer sie führen
soll, wird im zweiten Schritt zugeordnet.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="wehr-name">Vollständiger Name</Label>
<Input
id="wehr-name"
value={name}
onChange={(ereignis) => setName(ereignis.target.value)}
placeholder="Freiwillige Feuerwehr Musterhausen"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="wehr-kurzname">Kurzname</Label>
<Input
id="wehr-kurzname"
value={kurzname}
onChange={(ereignis) => setKurzname(ereignis.target.value)}
placeholder="FF Musterhausen"
/>
</div>
<div className="space-y-2">
<Label htmlFor="wehr-slug">Kennung</Label>
<Input
id="wehr-slug"
value={slug}
onChange={(ereignis) => setSlug(ereignis.target.value.toLowerCase())}
placeholder="ff-musterhausen"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Kleinbuchstaben, Ziffern, Bindestriche. Sie steht später im Terminal und in
Dateipfaden und lässt sich nicht mehr ändern.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="wehr-ort">Ort</Label>
<Input
id="wehr-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="wehr-landkreis">Landkreis</Label>
<Input
id="wehr-landkreis"
value={landkreis}
onChange={(ereignis) => setLandkreis(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
slug,
name,
kurzname,
ort: ort || null,
landkreis: landkreis || null,
})
}
disabled={!slug || !name || !kurzname || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function KontoAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (email: string) => void;
fehler: (nachricht: string) => void;
}) {
const [email, setEmail] = React.useState("");
const [passwort, setPasswort] = React.useState("");
const [verwalter, setVerwalter] = React.useState(false);
const anlegen = trpc.wehrverwaltung.kontoAnlegen.useMutation({
onSuccess: (konto) => {
setEmail("");
setPasswort("");
setVerwalter(false);
fertig(konto.email);
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto anlegen</DialogTitle>
<DialogDescription>
Die E-Mail-Adresse ist die Anmeldung und gilt über alle Wehren hinweg. Das Passwort
geben Sie der Person selbst weiter — FLORIAN verschickt keines.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="konto-email">E-Mail-Adresse</Label>
<Input
id="konto-email"
type="email"
value={email}
onChange={(ereignis) => setEmail(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="konto-passwort">Erstes Passwort</Label>
<Input
id="konto-passwort"
type="text"
value={passwort}
onChange={(ereignis) => setPasswort(ereignis.target.value)}
placeholder="mindestens zwölf Zeichen"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Steht im Klartext, damit Sie es weitergeben können. Es gehört nach der ersten
Anmeldung geändert.
</p>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={verwalter}
onChange={(ereignis) => setVerwalter(ereignis.target.checked)}
className="mt-1"
/>
<span>
Verwaltet die Installation
<span className="block text-xs text-[var(--muted-foreground)]">
Darf Wehren anlegen und in jede hineinschalten. Sparsam vergeben — dieses
Merkmal hebt die Trennung zwischen den Wehren auf.
</span>
</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => anlegen.mutate({ email, passwort, istVerwalter: verwalter })}
disabled={!email || passwort.length < 12 || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Ein Konto einer Wehr zuordnen — als Mitglied oder als Kreisaufsicht. */
function WehrZuordnen({
konto,
schliessen,
fertig,
fehler,
}: {
konto: { id: string; email: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [organisationId, setOrganisationId] = React.useState("");
const [art, setArt] = React.useState<MitgliedschaftArt>(MitgliedschaftArt.MITGLIED);
const [personId, setPersonId] = React.useState("");
const wehren = trpc.wehrverwaltung.wehren.useQuery(undefined, { enabled: konto !== null });
const personen = trpc.wehrverwaltung.personenIn.useQuery(
{ organisationId },
{ enabled: konto !== null && Boolean(organisationId) && art === MitgliedschaftArt.MITGLIED },
);
const anlegen = trpc.wehrverwaltung.mitgliedschaftAnlegen.useMutation({
onSuccess: () => {
setOrganisationId("");
setPersonId("");
setArt(MitgliedschaftArt.MITGLIED);
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
// Die Wehr wechselt, die vorher gewählte Akte gehört zur alten.
React.useEffect(() => setPersonId(""), [organisationId, art]);
return (
<Dialog open={konto !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Wehr zuordnen</DialogTitle>
<DialogDescription>
{konto?.email} — als Mitglied mit eigener Akte oder als Kreisaufsicht, die nur
zusieht.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="zuordnung-wehr">Wehr</Label>
<Select value={organisationId} onValueChange={setOrganisationId}>
<SelectTrigger id="zuordnung-wehr">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{wehren.data?.map((wehr) => (
<SelectItem key={wehr.id} value={wehr.id}>
{wehr.kurzname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
{Object.values(MitgliedschaftArt).map((wert) => (
<Button
key={wert}
type="button"
variant={art === wert ? "default" : "outline"}
className="flex-1"
onClick={() => setArt(wert)}
>
{wert === MitgliedschaftArt.AUFSICHT ? "Kreisaufsicht" : "Mitglied"}
</Button>
))}
</div>
{art === MitgliedschaftArt.MITGLIED ? (
<div className="space-y-2">
<Label htmlFor="zuordnung-person">Personenakte in dieser Wehr</Label>
<Select value={personId} onValueChange={setPersonId}>
<SelectTrigger id="zuordnung-person">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{personen.data?.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
{person.mitgliedsnummer && ` (${person.mitgliedsnummer})`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-[var(--muted-foreground)]">
Zur Auswahl steht, wer noch kein Konto hat. Eine Akte gehört zu höchstens einem
— sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat.
</p>
</div>
) : (
<p className="rounded-md bg-[var(--muted)] p-3 text-sm">
Die Kreisaufsicht bekommt <strong>keine</strong> Personenakte. Sie gehört der Wehr
nicht an, taucht in keiner Mitgliederliste auf und kann dort nichts ändern — auch
die Kasse und das Protokoll bleiben ihr verschlossen.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
konto &&
anlegen.mutate({
benutzerId: konto.id,
organisationId,
art,
personId: art === MitgliedschaftArt.MITGLIED ? personId : null,
})
}
disabled={
!organisationId ||
(art === MitgliedschaftArt.MITGLIED && !personId) ||
anlegen.isPending
}
>
{anlegen.isPending ? "Wird zugeordnet …" : "Zuordnen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+13
View File
@@ -107,6 +107,19 @@ export async function POST(anfrage: Request) {
);
}
// Wer hochlädt, muss eine Akte in dieser Wehr haben: die Datei bekommt
// einen Urheber, und „irgendwer aus der Kreisaufsicht“ ist keiner.
if (!sitzung.personId) {
return NextResponse.json(
{
fehler:
"Die Kreisaufsicht hat in dieser Wehr keine Mitgliedschaft und kann deshalb "
+ "keine Dateien hochladen.",
},
{ status: 403 },
);
}
const inhalt = Buffer.from(await datei.arrayBuffer());
try {
+33 -8
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Album,
Building2,
BellRing,
BookMarked,
BookOpenCheck,
@@ -30,6 +31,7 @@ import {
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Wehrwaehler } from "@/components/layout/wehrwaehler";
import { Button } from "@/components/ui/button";
import type { Recht, Rolle } from "@/lib/authz";
@@ -52,6 +54,8 @@ interface Eintrag {
rechte?: Recht[];
/** Ab welcher Phase es die Ansicht wirklich gibt. */
abPhase?: number;
/** Nur für den, der die Installation verwaltet — nicht über ein Recht. */
nurVerwalter?: boolean;
}
const EINTRAEGE: Eintrag[] = [
@@ -174,27 +178,50 @@ const EINTRAEGE: Eintrag[] = [
rechte: ["system.auditlog"],
abPhase: 8,
},
{
// Ganz unten und nur für den Betreiber: der einzige Eintrag, der über die
// eigene Wehr hinausreicht.
titel: "Wehren und Konten",
pfad: "/wehren",
symbol: Building2,
nurVerwalter: true,
abPhase: 10,
},
];
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 9;
const AKTUELLE_PHASE = 10;
export interface SeitenleisteProps {
rechte: Recht[];
rollen: Rolle[];
organisationName: string;
personName: string;
/**
* Verwaltet die Installation.
*
* Kein Recht, sondern ein Merkmal des Kontos — Rechte gelten je Wehr, und
* wer Wehren anlegt, steht über allen. Deshalb kommt es hier als eigene
* Angabe an und nicht über `rechte`.
*/
istVerwalter?: boolean;
}
export function Seitenleiste({ rechte, organisationName, personName }: SeitenleisteProps) {
export function Seitenleiste({
rechte,
organisationName,
personName,
istVerwalter = false,
}: SeitenleisteProps) {
const pfad = usePathname();
const [offen, setOffen] = React.useState(false);
const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]);
const sichtbar = EINTRAEGE.filter(
(eintrag) => !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)),
);
const sichtbar = EINTRAEGE.filter((eintrag) => {
if (eintrag.nurVerwalter) return istVerwalter;
return !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht));
});
return (
<>
@@ -221,9 +248,7 @@ export function Seitenleiste({ rechte, organisationName, personName }: Seitenlei
<Flame className="size-6 text-[var(--primary)]" aria-hidden />
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-tight">FLORIAN</p>
<p className="truncate text-xs text-[var(--muted-foreground)]">
{organisationName}
</p>
<Wehrwaehler organisationName={organisationName} />
</div>
</div>
+160
View File
@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Building2, Check, ChevronsUpDown, Eye } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { wehrWechseln } from "@/app/(app)/wehr-wechseln";
import { cn } from "@/lib/utils";
/**
* Der Wehrwähler.
*
* Er erscheint **nur**, wenn das Konto zu mehr als einer Wehr gehört. Für die
* allermeisten ist das nie der Fall, und ein Auswahlfeld mit genau einem
* Eintrag ist kein Angebot, sondern eine Frage ohne Antwortmöglichkeit.
*
* Der Wechsel läuft über eine Aktualisierung der Sitzung und nicht über einen
* Aufruf, der irgendwo eine Wehr „setzt": das Sitzungstoken trägt die Wehr,
* also muss es neu ausgestellt werden. Welche Wehr dabei zulässig ist,
* entscheidet der Server anhand der Mitgliedschaften des Kontos — was hier
* geschickt wird, ist ein Wunsch, keine Anweisung.
*/
export function Wehrwaehler({ organisationName }: { organisationName: string }) {
const router = useRouter();
const [offen, setOffen] = React.useState(false);
const [wechseltZu, setWechseltZu] = React.useState<string | null>(null);
const ich = trpc.sitzung.ich.useQuery();
const werkzeuge = trpc.useUtils();
const wehren = ich.data?.wehren ?? [];
const aktive = ich.data?.mitgliedschaftId ?? null;
// Eine Wehr — dann gibt es nichts zu wählen, und der Name steht schlicht da.
if (wehren.length <= 1) {
return (
<p className="truncate text-xs text-[var(--muted-foreground)]">{organisationName}</p>
);
}
async function wechseln(mitgliedschaftId: string) {
if (mitgliedschaftId === aktive) {
setOffen(false);
return;
}
setWechseltZu(mitgliedschaftId);
try {
await wehrWechseln(mitgliedschaftId);
// Alles neu holen: die zwischengespeicherten Antworten gehören zur
// vorherigen Wehr. Sie stehen zu lassen wäre der sicherste Weg, Daten
// der einen Wehr unter dem Namen der anderen anzuzeigen.
await werkzeuge.invalidate();
router.refresh();
} finally {
setWechseltZu(null);
setOffen(false);
}
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOffen((wert) => !wert)}
aria-expanded={offen}
aria-haspopup="listbox"
// Ein Knopf, auf dem nur ein Wehrname und ein Pfeil stehen, sagt
// niemandem, was er tut — einem Screenreader schon gar nicht.
aria-label={`Wehr wechseln — gerade ${organisationName}`}
className="flex w-full items-center gap-1 rounded text-left text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span className="truncate">{organisationName}</span>
<ChevronsUpDown className="size-3 shrink-0" aria-hidden />
</button>
{offen && (
<>
<button
type="button"
aria-label="Auswahl schließen"
className="fixed inset-0 z-40 cursor-default"
onClick={() => setOffen(false)}
/>
<ul
role="listbox"
aria-label="Wehr wechseln"
className="absolute left-0 top-full z-50 mt-2 max-h-80 w-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--card)] p-1 shadow-lg"
>
{wehren.map((eintrag) => {
const istAktiv = eintrag.id === aktive;
const istAufsicht = eintrag.art === "AUFSICHT";
return (
<li key={eintrag.id}>
<button
type="button"
role="option"
aria-selected={istAktiv}
disabled={wechseltZu !== null}
onClick={() => void wechseln(eintrag.id)}
className={cn(
"flex w-full items-start gap-2 rounded px-2 py-2 text-left text-sm",
istAktiv
? "bg-[var(--accent)] text-[var(--accent-foreground)]"
: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
wechseltZu !== null && "opacity-60",
)}
>
{istAufsicht ? (
<Eye className="mt-0.5 size-4 shrink-0" aria-hidden />
) : (
<Building2 className="mt-0.5 size-4 shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1">
<span className="block truncate">{eintrag.organisation.kurzname}</span>
<span className="block truncate text-xs text-[var(--muted-foreground)]">
{istAufsicht ? "Aufsicht — nur lesen" : "Mitglied"}
</span>
</span>
{istAktiv && <Check className="mt-0.5 size-4 shrink-0" aria-hidden />}
</button>
</li>
);
})}
</ul>
</>
)}
</div>
);
}
/**
* Der Hinweis, dass hier nichts geändert werden kann.
*
* Er steht bewusst dauerhaft und nicht als kurz aufblitzende Meldung: wer als
* Kreisaufsicht in einer fremden Wehr unterwegs ist, soll das nicht erst
* merken, wenn ein Knopf fehlt.
*/
export function AufsichtHinweis() {
const ich = trpc.sitzung.ich.useQuery();
if (!ich.data?.nurLesend) return null;
return (
<div
role="note"
className="flex items-start gap-2 border-b border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 px-4 py-2 text-sm nicht-drucken"
>
<Eye className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Sie sehen diese Wehr als <strong>Kreisaufsicht</strong>. Geändert werden kann hier
nichts — auch die Kasse und das Protokoll bleiben der Wehr vorbehalten.
</span>
</div>
);
}
+334 -93
View File
@@ -48,6 +48,93 @@ async function personMitRollen(personId: string) {
});
}
/**
* Alle Wehren, zu denen ein Konto gehört.
*
* Ohne RLS, denn genau hier ist der Mandant noch nicht bekannt — das ist der
* einzige Zweck des Systemzugangs. Nach der Wahl einer Mitgliedschaft läuft
* wieder alles über `fuerOrganisation`.
*/
async function mitgliedschaftenVon(benutzerId: string) {
return prismaSystem.mitgliedschaft.findMany({
where: {
benutzerId,
aktiv: true,
organisation: { aktiv: true, geloeschtAm: null },
},
include: {
organisation: { select: { id: true, slug: true, name: true, kurzname: true } },
person: {
select: { id: true, vorname: true, nachname: true, aktiv: true, geloeschtAm: true },
},
},
orderBy: [{ art: "asc" }, { organisation: { name: "asc" } }],
});
}
/**
* Aus einer Mitgliedschaft die Angaben für die Sitzung machen.
*
* Zwei Fälle, und der Unterschied ist der Kern dieser Phase:
*
* MITGLIED — es gibt eine Akte, und deren Rollen gelten.
* AUFSICHT — es gibt keine Akte. Die Kreisebene bekommt genau eine Rolle,
* und die trägt nur Leserechte. Sie hier zu setzen statt in der
* Oberfläche ist Absicht: eine Aufsicht, die schreiben darf,
* weil irgendwo eine Prüfung fehlt, wäre ein Mandantenleck.
*/
async function sitzungAus(mitgliedschaft: {
id: string;
art: "MITGLIED" | "AUFSICHT";
organisationId: string;
personId: string | null;
}): Promise<{
personId: string | null;
organisationId: string;
mitgliedschaftId: string;
name: string | null;
rollen: Rolle[];
nurLesend: boolean;
} | null> {
if (mitgliedschaft.art === "AUFSICHT") {
return {
personId: null,
organisationId: mitgliedschaft.organisationId,
mitgliedschaftId: mitgliedschaft.id,
name: null,
rollen: ["AUFSICHT"],
nurLesend: true,
};
}
if (!mitgliedschaft.personId) return null;
const person = await personMitRollen(mitgliedschaft.personId);
if (!person || !person.organisation.aktiv) return null;
return {
personId: person.id,
organisationId: person.organisationId,
mitgliedschaftId: mitgliedschaft.id,
name: `${person.vorname} ${person.nachname}`,
rollen: rollenAus(person.rollen),
nurLesend: false,
};
}
/**
* Welche Wehr nach der Anmeldung offen ist.
*
* Die eigene vor der betreuten: wer Mitglied einer Wehr ist und daneben acht
* andere beaufsichtigt, will in seiner Wehr landen. Wechseln kann er danach
* mit einem Klick.
*/
function ersteMitgliedschaft<T extends { art: "MITGLIED" | "AUFSICHT" }>(
alle: T[],
): T | undefined {
return alle.find((eintrag) => eintrag.art === "MITGLIED") ?? alle[0];
}
function rollenAus(
eintraege: Array<{ rolle: { schluessel: RolleSchluessel } }>,
): Rolle[] {
@@ -60,19 +147,24 @@ function rollenAus(
}
/** Ist das Konto gerade wegen zu vieler Fehlversuche gesperrt? */
function istGesperrt(person: { gesperrtBis: Date | null }): boolean {
return person.gesperrtBis !== null && person.gesperrtBis > new Date();
function istGesperrt(konto: { gesperrtBis: Date | null }): boolean {
return konto.gesperrtBis !== null && konto.gesperrtBis > new Date();
}
/** Fehlversuch zählen und ab der Grenze sperren. */
async function fehlversuchVermerken(personId: string): Promise<void> {
const person = await prismaSystem.person.findUnique({
where: { id: personId },
/**
* Fehlversuch zählen und ab der Grenze sperren.
*
* Am Konto, nicht an der Akte: sonst könnte man dieselbe Anmeldung über eine
* zweite Wehr weiterprobieren, in der derselbe Mensch eine zweite Akte hat.
*/
async function fehlversuchVermerken(benutzerId: string): Promise<void> {
const konto = await prismaSystem.benutzer.findUnique({
where: { id: benutzerId },
select: { fehlversuche: true },
});
const neu = (person?.fehlversuche ?? 0) + 1;
await prismaSystem.person.update({
where: { id: personId },
const neu = (konto?.fehlversuche ?? 0) + 1;
await prismaSystem.benutzer.update({
where: { id: benutzerId },
data: {
fehlversuche: neu,
gesperrtBis:
@@ -83,9 +175,9 @@ async function fehlversuchVermerken(personId: string): Promise<void> {
});
}
async function anmeldungVermerken(personId: string): Promise<void> {
await prismaSystem.person.update({
where: { id: personId },
async function anmeldungVermerken(benutzerId: string): Promise<void> {
await prismaSystem.benutzer.update({
where: { id: benutzerId },
data: { letzteAnmeldung: new Date(), fehlversuche: 0, gesperrtBis: null },
});
}
@@ -122,64 +214,75 @@ const passwortAnbieter = Credentials({
const { email, passwort } = geprueft.data;
// Die E-Mail-Suche geht bewusst über alle Wehren — zu diesem Zeitpunkt ist
// der Mandant noch nicht bekannt.
const person = await prismaSystem.person.findFirst({
// Die E-Mail-Suche geht über die Konten, nicht über die Personenakten:
// seit Phase 10 ist sie dort global eindeutig. Vorher war sie es nur je
// Wehr, und wer in zwei Wehren stand, meldete sich in einer zufälligen an.
const konto = await prismaSystem.benutzer.findFirst({
where: { email: email.toLowerCase(), aktiv: true, geloeschtAm: null },
include: {
rollen: { include: { rolle: true } },
organisation: { select: { id: true, aktiv: true } },
},
});
if (!person || !person.organisation.aktiv) {
if (!konto) {
// Trotzdem hashen, damit "Konto gibt es nicht" zeitlich nicht auffällt.
await pruefen(passwort, null);
return null;
}
if (istGesperrt(person)) {
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: false,
grund: "Konto vorübergehend gesperrt",
});
const mitgliedschaften = await mitgliedschaftenVon(konto.id);
const gewaehlt = ersteMitgliedschaft(mitgliedschaften);
// Ein Konto ohne Mitgliedschaft ist ausgetreten oder noch nicht
// zugeordnet. Es kommt bis zur Wehr gar nicht, deshalb gibt es auch
// nichts zu protokollieren — das Audit-Log hängt an einer Organisation.
if (!gewaehlt) {
await pruefen(passwort, null);
return null;
}
const stimmt = await pruefen(passwort, person.passwortHash);
const melden = (erfolgreich: boolean, grund?: string) =>
protokolliereAnmeldung({
organisationId: gewaehlt.organisationId,
personId: gewaehlt.personId,
name: gewaehlt.person
? `${gewaehlt.person.vorname} ${gewaehlt.person.nachname}`
: konto.email,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich,
grund,
});
if (istGesperrt(konto)) {
await melden(false, "Konto vorübergehend gesperrt");
return null;
}
const stimmt = await pruefen(passwort, konto.passwortHash);
if (!stimmt) {
await fehlversuchVermerken(person.id);
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: false,
grund: "Falsches Passwort",
});
await fehlversuchVermerken(konto.id);
await melden(false, "Falsches Passwort");
return null;
}
await anmeldungVermerken(person.id);
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: true,
});
const sitzung = await sitzungAus(gewaehlt);
if (!sitzung) {
await melden(false, "Personenakte in dieser Wehr nicht nutzbar");
return null;
}
await anmeldungVermerken(konto.id);
await melden(true);
return {
id: person.id,
name: `${person.vorname} ${person.nachname}`,
email: person.email ?? undefined,
organisationId: person.organisationId,
rollen: rollenAus(person.rollen),
id: sitzung.personId ?? konto.id,
name: sitzung.name ?? konto.email,
email: konto.email,
organisationId: sitzung.organisationId,
rollen: sitzung.rollen,
anmeldeArt: "LOKAL" as const,
benutzerId: konto.id,
mitgliedschaftId: sitzung.mitgliedschaftId,
personId: sitzung.personId,
nurLesend: sitzung.nurLesend,
istVerwalter: konto.istVerwalter,
};
},
});
@@ -383,36 +486,41 @@ interface OidcProfil {
/**
* Verknüpft ein authentik-Konto mit einer Person. Reihenfolge:
* 1. bereits verknüpft (oidcSubject)
* 1. bereits verknüpft (oidcSubject am Konto)
* 2. E-Mail-Adresse stimmt überein -> verknüpfen
* 3. AUTH_OIDC_AUTO_PROVISION -> neue Person anlegen
* 3. AUTH_OIDC_AUTO_PROVISION -> neues Konto samt Akte anlegen
* Sonst: Anmeldung abgelehnt.
*
* Seit Phase 10 hängt das OIDC-Subject am Konto, nicht an der Akte. Wer über
* authentik kommt und in zwei Wehren geführt wird, ist damit derselbe Mensch
* und nicht zwei — vorher entschied die Reihenfolge in der Datenbank, welche
* Akte gefunden wurde.
*/
async function personAusOidc(profil: OidcProfil): Promise<{
personId: string;
personId: string | null;
organisationId: string;
name: string;
rollen: Rolle[];
} | null> {
const email = profil.email?.toLowerCase();
const vorhanden = await prismaSystem.person.findFirst({
const vorhanden = await prismaSystem.benutzer.findFirst({
where: {
OR: [{ oidcSubject: profil.sub }, ...(email ? [{ email }] : [])],
geloeschtAm: null,
},
include: {
rollen: { include: { rolle: true } },
organisation: { select: { id: true, aktiv: true } },
},
});
const gruppen = gruppenAusProfil(profil);
if (vorhanden) {
if (!vorhanden.aktiv || !vorhanden.organisation.aktiv) return null;
if (!vorhanden.aktiv) return null;
await prismaSystem.person.update({
const mitgliedschaften = await mitgliedschaftenVon(vorhanden.id);
const gewaehlt = ersteMitgliedschaft(mitgliedschaften);
if (!gewaehlt) return null;
await prismaSystem.benutzer.update({
where: { id: vorhanden.id },
data: {
oidcSubject: profil.sub,
@@ -424,17 +532,21 @@ async function personAusOidc(profil: OidcProfil): Promise<{
});
// Gruppen wirken nur als Vorbelegung: Rollen, die jemand in FLORIAN von
// Hand vergeben hat, bleiben unangetastet.
await gruppenRollenVorbelegen(vorhanden.organisationId, vorhanden.id, gruppen);
// Hand vergeben hat, bleiben unangetastet. Und sie wirken nur auf die
// Wehr, in der die Akte liegt — eine authentik-Gruppe darf nicht über
// Mandantengrenzen hinweg Rollen verteilen.
if (gewaehlt.personId) {
await gruppenRollenVorbelegen(gewaehlt.organisationId, gewaehlt.personId, gruppen);
}
const aktualisiert = await personMitRollen(vorhanden.id);
if (!aktualisiert) return null;
const sitzung = await sitzungAus(gewaehlt);
if (!sitzung) return null;
return {
personId: aktualisiert.id,
organisationId: aktualisiert.organisationId,
name: `${aktualisiert.vorname} ${aktualisiert.nachname}`,
rollen: rollenAus(aktualisiert.rollen),
personId: sitzung.personId,
organisationId: sitzung.organisationId,
name: sitzung.name ?? vorhanden.email,
rollen: sitzung.rollen,
};
}
@@ -458,20 +570,37 @@ async function personAusOidc(profil: OidcProfil): Promise<{
const nachname =
profil.family_name ?? profil.name?.split(" ").slice(1).join(" ") ?? "Unbekannt";
// Ohne E-Mail kein Konto: sie ist die Kennung, unter der es geführt wird.
if (!email) {
console.error("[auth] OIDC-Profil ohne E-Mail — Konto kann nicht angelegt werden.");
return null;
}
const neu = await fuerOrganisation(organisation.id, (db) =>
db.person.create({
data: {
organisationId: organisation.id,
vorname,
nachname,
email: email ?? null,
oidcSubject: profil.sub,
oidcGruppen: gruppen,
letzteAnmeldung: new Date(),
email,
},
}),
);
// Konto und Mitgliedschaft über den Systemzugang: `benutzer` steht über
// allen Mandanten, und die Anwendungsrolle darf dort nichts anlegen.
await prismaSystem.benutzer.create({
data: {
email,
oidcSubject: profil.sub,
oidcGruppen: gruppen,
letzteAnmeldung: new Date(),
mitgliedschaften: {
create: { organisationId: organisation.id, personId: neu.id },
},
},
});
await gruppenRollenVorbelegen(organisation.id, neu.id, gruppen);
const mitRollen = await personMitRollen(neu.id);
@@ -581,7 +710,7 @@ export const authConfig: NextAuthConfig = {
return true;
},
async jwt({ token, user, account, trigger }) {
async jwt({ token, user, account, trigger, session }) {
// Erste Anmeldung über einen Credentials-Anbieter.
if (user && "organisationId" in user) {
const angemeldet = user as unknown as {
@@ -590,44 +719,122 @@ export const authConfig: NextAuthConfig = {
organisationId: string;
rollen: Rolle[];
anmeldeArt: Sitzung["anmeldeArt"];
benutzerId?: string;
mitgliedschaftId?: string;
personId?: string | null;
nurLesend?: boolean;
istVerwalter?: boolean;
};
token.personId = angemeldet.id;
// `id` trägt bei einer Aufsichtssitzung die Kontokennung, weil es
// keine Akte gibt. `personId` ist deshalb das verlässliche Feld.
token.personId = angemeldet.personId ?? angemeldet.id;
token.organisationId = angemeldet.organisationId;
token.rollen = angemeldet.rollen;
token.anmeldeArt = angemeldet.anmeldeArt;
token.benutzerId = angemeldet.benutzerId;
token.mitgliedschaftId = angemeldet.mitgliedschaftId;
token.nurLesend = angemeldet.nurLesend ?? false;
token.istVerwalter = angemeldet.istVerwalter ?? false;
token.geladenAm = Date.now();
return token;
}
// Erste Anmeldung über OIDC — hier steht das Profil im account/profile.
if (account?.provider === "authentik" && token.sub) {
const person = await prismaSystem.person.findFirst({
const konto = await prismaSystem.benutzer.findFirst({
where: { oidcSubject: token.sub, geloeschtAm: null },
include: { rollen: { include: { rolle: true } } },
});
if (person) {
token.personId = person.id;
token.organisationId = person.organisationId;
token.rollen = rollenAus(person.rollen);
token.anmeldeArt = "OIDC";
if (konto) {
const gewaehlt = ersteMitgliedschaft(await mitgliedschaftenVon(konto.id));
const sitzung = gewaehlt ? await sitzungAus(gewaehlt) : null;
if (sitzung) {
token.personId = sitzung.personId ?? undefined;
token.organisationId = sitzung.organisationId;
token.rollen = sitzung.rollen;
token.anmeldeArt = "OIDC";
token.benutzerId = konto.id;
token.mitgliedschaftId = sitzung.mitgliedschaftId;
token.nurLesend = sitzung.nurLesend;
token.istVerwalter = konto.istVerwalter;
token.geladenAm = Date.now();
}
}
return token;
}
/**
* Nachladen.
*
* Zwei Anlässe: eine Rollenänderung soll nicht bis zum nächsten Login
* warten, und ein Wehrwechsel muss sofort greifen. Beim Wechsel setzt
* die Oberfläche `wehrWechselAuf` und stösst ein `update` an — die
* Prüfung, ob die Mitgliedschaft dem Konto überhaupt gehört, findet
* hier statt und nicht dort. Alles andere wäre eine Einladung, sich
* über einen selbst gewählten Wert in eine fremde Wehr zu schreiben.
*/
/**
* Der Wunsch, in eine andere Wehr zu wechseln.
*
* Er kommt aus der Server Action und ist eine Behauptung: geprueft wird
* hier, gegen die Mitgliedschaften des angemeldeten Kontos. Wer eine
* fremde Kennung schickt, bekommt seine bisherige Sitzung zurueck.
*/
const wunsch = session as { user?: { mitgliedschaftId?: unknown } } | undefined;
const gewuenschteWehr =
trigger === "update" && typeof wunsch?.user?.mitgliedschaftId === "string"
? wunsch.user.mitgliedschaftId
: null;
if (gewuenschteWehr && typeof token.benutzerId === "string") {
const ziel = (await mitgliedschaftenVon(token.benutzerId)).find(
(eintrag) => eintrag.id === gewuenschteWehr,
);
if (!ziel) {
console.warn("[auth] Wehrwechsel auf eine fremde Mitgliedschaft abgelehnt.");
return token;
}
const sitzung = await sitzungAus(ziel);
if (sitzung) {
token.personId = sitzung.personId ?? undefined;
token.organisationId = sitzung.organisationId;
token.rollen = sitzung.rollen;
token.mitgliedschaftId = sitzung.mitgliedschaftId;
token.nurLesend = sitzung.nurLesend;
token.geladenAm = Date.now();
}
return token;
}
// Rollen nachladen, damit eine Änderung nicht bis zum nächsten Login wartet.
const veraltet =
typeof token.geladenAm !== "number" ||
Date.now() - token.geladenAm > ROLLEN_NACHLADEN_NACH_MS;
if (trigger === "update" || (veraltet && typeof token.personId === "string")) {
const person = await personMitRollen(token.personId as string);
if (!person) {
// Person deaktiviert oder gelöscht: Sitzung entwerten.
if (
(trigger === "update" || veraltet) &&
typeof token.mitgliedschaftId === "string" &&
typeof token.benutzerId === "string"
) {
const aktuell = (await mitgliedschaftenVon(token.benutzerId)).find(
(eintrag) => eintrag.id === token.mitgliedschaftId,
);
// Mitgliedschaft beendet, Konto oder Wehr abgeschaltet: entwerten.
if (!aktuell) {
return { ...token, personId: undefined, organisationId: undefined, rollen: [] };
}
token.rollen = rollenAus(person.rollen);
token.organisationId = person.organisationId;
const sitzung = await sitzungAus(aktuell);
if (!sitzung) {
return { ...token, personId: undefined, organisationId: undefined, rollen: [] };
}
token.personId = sitzung.personId ?? undefined;
token.organisationId = sitzung.organisationId;
token.rollen = sitzung.rollen;
token.nurLesend = sitzung.nurLesend;
token.geladenAm = Date.now();
}
@@ -641,13 +848,30 @@ export const authConfig: NextAuthConfig = {
organisationId: (token.organisationId as string) ?? "",
rollen: (token.rollen as Rolle[]) ?? [],
anmeldeArt: (token.anmeldeArt as Sitzung["anmeldeArt"]) ?? "LOKAL",
benutzerId: token.benutzerId as string | undefined,
mitgliedschaftId: token.mitgliedschaftId as string | undefined,
nurLesend: (token.nurLesend as boolean | undefined) ?? false,
istVerwalter: (token.istVerwalter as boolean | undefined) ?? false,
};
return session;
},
},
};
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
export const {
handlers,
auth,
signIn,
signOut,
/**
* Stellt das Sitzungstoken neu aus.
*
* Der einzige Weg, die aktive Wehr zu wechseln: sie steht im Token, also
* muss ein neues her. Ohne `SessionProvider` im Browser läuft das über eine
* Server Action — siehe `src/app/(app)/wehr-wechseln.ts`.
*/
unstable_update: sitzungNeuAusstellen,
} = NextAuth(authConfig);
/**
* Die Sitzung, wie der Rest der Anwendung sie erwartet. Gibt null zurück,
@@ -655,13 +879,30 @@ export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
*/
export async function aktuelleSitzung(): Promise<Sitzung | null> {
const sitzung = await auth();
if (!sitzung?.user?.id || !sitzung.user.organisationId) return null;
if (!sitzung?.user?.organisationId) return null;
/**
* Eine Sitzung ohne Personenakte ist gueltig — seit Phase 10.
*
* Die Kreisaufsicht gehoert der Wehr nicht an und hat dort keine Akte. Bis
* hierher galt genau das als „nicht angemeldet“, und die Folge war, dass
* jeder Aufruf nach dem Wechsel auf der Anmeldeseite endete. Was eine
* Sitzung ausmacht, ist die Wehr und das Konto — nicht die Akte.
*/
if (!sitzung.user.id && !sitzung.user.benutzerId) return null;
return {
personId: sitzung.user.id,
// Bei einer Aufsichtssitzung gibt es keine Akte in dieser Wehr. Dann
// steht hier null, und jede Stelle, die einen Urheber festhalten will,
// muss sich dazu verhalten — statt eine fremde Kennung einzutragen.
personId: sitzung.user.nurLesend ? null : sitzung.user.id,
organisationId: sitzung.user.organisationId,
name: sitzung.user.name ?? "Unbekannt",
rollen: sitzung.user.rollen ?? [],
anmeldeArt: sitzung.user.anmeldeArt ?? "LOKAL",
benutzerId: sitzung.user.benutzerId,
mitgliedschaftId: sitzung.user.mitgliedschaftId,
nurLesend: sitzung.user.nurLesend ?? false,
istVerwalter: sitzung.user.istVerwalter ?? false,
};
}
+71 -2
View File
@@ -14,7 +14,16 @@
* sie nachträglich anpassen kann, ohne dass jemand Code ändern muss.
*/
/** Rollen exakt wie im Prisma-Schema (enum RolleSchluessel). */
/**
* Rollen wie im Prisma-Schema (enum RolleSchluessel) — mit einer Ausnahme.
*
* `AUFSICHT` steht dort **nicht**, und das ist Absicht. Alle anderen Rollen
* werden einem Menschen in einer Wehr zugewiesen und liegen als
* `PersonRolle` in der Datenbank. AUFSICHT wird nicht zugewiesen: sie ergibt
* sich daraus, dass ein Konto zu einer Wehr als `MitgliedschaftArt.AUFSICHT`
* gehört. Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr
* Rechte an einer anderen verteilen könnte.
*/
export const ROLLEN = [
"SUPERADMIN",
"KOMMANDANT",
@@ -25,6 +34,7 @@ export const ROLLEN = [
"AUSBILDER",
"MITGLIED",
"KIOSK",
"AUFSICHT",
] as const;
export type Rolle = (typeof ROLLEN)[number];
@@ -40,9 +50,12 @@ export const ROLLEN_BEZEICHNUNG: Record<Rolle, string> = {
AUSBILDER: "Ausbilder",
MITGLIED: "Mitglied",
KIOSK: "Terminal im Gerätehaus",
AUFSICHT: "Kreisebene",
};
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
AUFSICHT:
"Kreisbrandrat oder Kreisbrandinspektion: sieht die betreuten Wehren, ändert nichts.",
SUPERADMIN:
"Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.",
KOMMANDANT:
@@ -296,6 +309,39 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
// Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich
// zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person.
KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"],
/**
* Die Kreisebene.
*
* Nicht aufgezählt, sondern gefiltert: alles, was `.lesen` heißt, und
* sonst nichts. Eine Liste von Hand wäre bei der nächsten Phase veraltet,
* und zwar unbemerkt — ein neues Modul brächte Rechte mit, die hier
* niemand nachträgt. So bekommt die Aufsicht neue Leserechte automatisch
* und neue Schreibrechte nie.
*
* Bewusst NICHT dabei, obwohl es auf `.lesen` endet:
*
* person.export das Auskunftsrecht nach DSGVO ist Sache der Wehr,
* die die Daten führt, nicht der Aufsicht.
* system.auditlog wer wann was getan hat, bleibt innerhalb der Wehr.
* ki.protokoll dasselbe.
* kasse.lesen die Kasse gehört dem Verein oder der Kommune, nicht
* der Kreisaufsicht. Sie wäre nur deshalb hier
* gelandet, weil das Recht zufällig auf „.lesen“
* endet — und genau das soll die Ausnahmeliste
* verhindern.
*
* Der Atemschutz steht dagegen bewusst drin: ob ein Träger tauglich ist,
* ist genau die Frage, für die es eine Kreisebene gibt.
*/
AUFSICHT: ALLE_RECHTE.filter(
(recht) =>
recht.endsWith(".lesen") &&
recht !== "person.export" &&
recht !== "system.auditlog" &&
recht !== "ki.protokoll" &&
recht !== "kasse.lesen",
),
};
// -----------------------------------------------------------------------------
@@ -304,12 +350,35 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
/** Was die Anwendung über den gerade Angemeldeten weiß. */
export interface Sitzung {
personId: string;
/**
* Die Personenakte in der aktiven Wehr.
*
* Seit Phase 10 kann sie fehlen: eine Sitzung der Kreisebene (AUFSICHT)
* gehört der Wehr nicht an und hat dort keine Akte. Genau deshalb ist das
* Feld nullbar und nicht bloß leer — jede Stelle, die einen Urheber
* festhalten will, muss sich dazu verhalten, statt einen Namen zu erfinden.
*/
personId: string | null;
organisationId: string;
name: string;
rollen: Rolle[];
/** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */
anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM";
/** Das Anmeldekonto. Bleibt beim Wechsel der Wehr dasselbe. */
benutzerId?: string;
/** Die gewählte Mitgliedschaft — sie bestimmt Wehr und Rollen. */
mitgliedschaftId?: string;
/**
* Aufsicht statt Mitgliedschaft: die Kreisebene sieht, ändert aber nichts.
*
* Die Rollen sind bei einer solchen Sitzung ohnehin auf die Leserechte
* beschränkt; dieses Merkmal macht es zusätzlich sichtbar — für die
* Oberfläche, die dann keine Knöpfe anbietet, die zu nichts führen.
*/
nurLesend?: boolean;
/** Verwaltet die Installation: Wehren anlegen, in jede hineinschalten. */
istVerwalter?: boolean;
}
/** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */
+69 -17
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
import { datenbankErreichbar } from "@/lib/db";
import { datenbankErreichbar, prismaSystem } from "@/lib/db";
import { speicherErreichbar } from "@/lib/storage";
import { geraetRouter } from "@/server/routers/stammdaten/geraet";
import { fahrzeugRouter } from "@/server/routers/stammdaten/fahrzeug";
@@ -9,6 +9,7 @@ import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/o
import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
import { importRouter } from "@/server/routers/stammdaten/import";
import { wehrverwaltungRouter } from "@/server/routers/verwaltung/wehr";
import { wartungsvorlageRouter } from "@/server/routers/wartung/vorlage";
import { wartungsterminRouter } from "@/server/routers/wartung/termin";
import { pruefungRouter } from "@/server/routers/wartung/pruefung";
@@ -59,30 +60,80 @@ const systemRouter = router({
});
const sitzungRouter = router({
/** Wer bin ich, welcher Wehr gehöre ich an, was darf ich? */
/**
* Wer bin ich, in welcher Wehr bin ich gerade, was darf ich — und welche
* Wehren stehen mir sonst noch offen?
*
* Der letzte Teil ist neu in Phase 10 und speist den Wehrwähler. Er kommt
* bewusst aus derselben Abfrage: eine eigene für die Mitgliedschaften wäre
* ein zweiter Weg zu derselben Auskunft, und zwei Wege bedeuten früher oder
* später zwei Antworten.
*/
ich: angemeldet.query(async ({ ctx }) => {
const person = await ctx.db((db) =>
db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: {
id: true,
vorname: true,
nachname: true,
email: true,
dienstgrad: true,
mitgliedsnummer: true,
letzteAnmeldung: true,
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
},
}),
);
// Ohne Akte — die Kreisaufsicht. Dann gibt es hier nichts zu laden, und
// die Oberfläche zeigt das Konto statt eines Mitglieds.
const person = ctx.sitzung.personId
? await ctx.db((db) =>
db.person.findUnique({
where: { id: ctx.sitzung.personId as string },
select: {
id: true,
vorname: true,
nachname: true,
email: true,
dienstgrad: true,
mitgliedsnummer: true,
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
},
}),
)
: null;
/**
* Die Wehren dieses Kontos.
*
* Über den Systemzugang, und nur hier: die Frage lautet ja gerade „wo
* gehöre ich überall hin", und die lässt sich im Mandantenkontext einer
* einzelnen Wehr nicht beantworten. Zurückgegeben wird deshalb auch nur
* das Nötigste — Name der Wehr und Art der Zugehörigkeit, nichts aus
* ihren Daten.
*/
const wehren = ctx.sitzung.benutzerId
? await prismaSystem.mitgliedschaft.findMany({
where: {
benutzerId: ctx.sitzung.benutzerId,
aktiv: true,
organisation: { aktiv: true, geloeschtAm: null },
},
select: {
id: true,
art: true,
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
},
orderBy: [{ art: "asc" }, { organisation: { name: "asc" } }],
})
: [];
const konto = ctx.sitzung.benutzerId
? await prismaSystem.benutzer.findUnique({
where: { id: ctx.sitzung.benutzerId },
select: { id: true, email: true, letzteAnmeldung: true, istVerwalter: true },
})
: null;
return {
person,
konto,
rollen: ctx.sitzung.rollen,
rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]),
rechte: [...rechteVon(ctx.sitzung.rollen)],
anmeldeArt: ctx.sitzung.anmeldeArt,
/** Alle Wehren dieses Kontos, für den Wehrwähler. */
wehren,
/** Die gerade aktive Mitgliedschaft. */
mitgliedschaftId: ctx.sitzung.mitgliedschaftId ?? null,
nurLesend: ctx.sitzung.nurLesend ?? false,
istVerwalter: ctx.sitzung.istVerwalter ?? false,
};
}),
});
@@ -152,6 +203,7 @@ export const appRouter = router({
system: systemRouter,
sitzung: sitzungRouter,
organisation: organisationRouter,
wehrverwaltung: wehrverwaltungRouter,
audit: auditRouter,
// Phase 1 — Stammdaten
@@ -1,7 +1,7 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { DruckArt, EreignisArt, TruppStatus } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { env } from "@/lib/env";
@@ -390,7 +390,7 @@ export const atemschutzRouter = router({
bezeichnung: input.bezeichnung,
einsatzId: input.einsatzId ?? null,
uebungId: input.uebungId ?? null,
ueberwacherId: ctx.sitzung.personId,
ueberwacherId: eigeneAkte(ctx.sitzung),
abfrageIntervall: input.abfrageIntervall ?? env.ATEMSCHUTZ_ABFRAGE_INTERVALL,
sicherheitszuschlag: input.sicherheitszuschlag ?? env.ATEMSCHUTZ_ZUSCHLAG,
warndruck: input.warndruck ?? env.ATEMSCHUTZ_WARNDRUCK,
+5 -5
View File
@@ -9,7 +9,7 @@ import {
Prisma,
WaescheStatus,
} from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import type { MandantenClient } from "@/lib/db";
@@ -322,7 +322,7 @@ export const kioskRouter = router({
const ergebnisId = await vorgangAnwenden(
db,
ctx.sitzung.organisationId,
ctx.sitzung.personId,
eigeneAkte(ctx.sitzung),
ctx.sitzung.name,
eingabe,
);
@@ -458,12 +458,12 @@ export const kioskRouter = router({
ctx.db(async (db) => {
const [anwesenheit, fahrt, waesche] = await Promise.all([
db.anwesenheit.findFirst({
where: { personId: ctx.sitzung.personId, abgemeldetUm: null },
where: { personId: eigeneAkte(ctx.sitzung), abgemeldetUm: null },
orderBy: { angemeldetUm: "desc" },
select: { id: true, anlass: true, angemeldetUm: true },
}),
db.fahrt.findFirst({
where: { fahrerId: ctx.sitzung.personId, rueckkehrUm: null },
where: { fahrerId: eigeneAkte(ctx.sitzung), rueckkehrUm: null },
orderBy: { abfahrtUm: "desc" },
select: {
id: true,
@@ -474,7 +474,7 @@ export const kioskRouter = router({
},
}),
db.waescheabgabe.count({
where: { personId: ctx.sitzung.personId, status: { not: WaescheStatus.ZURUECK } },
where: { personId: eigeneAkte(ctx.sitzung), status: { not: WaescheStatus.ZURUECK } },
}),
]);
@@ -1,4 +1,4 @@
import { router, angemeldet } from "@/server/trpc";
import { router, angemeldet, eigeneAkte } from "@/server/trpc";
import { AUSWEIS_GUELTIG_SEKUNDEN, ausweisErzeugen } from "@/lib/dienstausweis";
import { env } from "@/lib/env";
@@ -16,7 +16,7 @@ export const dienstausweisRouter = router({
eigener: angemeldet.query(async ({ ctx }) => {
const person = await ctx.db((db) =>
db.person.findUnique({
where: { id: ctx.sitzung.personId },
where: { id: eigeneAkte(ctx.sitzung) },
select: {
vorname: true,
nachname: true,
@@ -26,7 +26,7 @@ export const dienstausweisRouter = router({
}),
);
const code = ausweisErzeugen(ctx.sitzung.personId, ctx.sitzung.organisationId);
const code = ausweisErzeugen(eigeneAkte(ctx.sitzung), ctx.sitzung.organisationId);
return {
person,
+7 -8
View File
@@ -146,10 +146,11 @@ export const personRouter = router({
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
// Der Hash gehört auch dann nicht nach draußen, wenn er nur im Speicher
// liegt — das Feld wird hier ausdrücklich entfernt.
const { passwortHash: _hash, ...ohneGeheimnis } = person;
return ohneGeheimnis;
// Bis Phase 9 wurde hier der Passwort-Hash aus dem Ergebnis geschnitten.
// Das ist nicht mehr nötig: die Anmeldedaten liegen seit Phase 10 am
// `Benutzer`, und eine Personenabfrage kann sie gar nicht mehr
// mitbringen. Die Sorgfaltsmaßnahme ist durch eine Struktur ersetzt.
return person;
});
}),
@@ -177,8 +178,7 @@ export const personRouter = router({
userAgent: ctx.userAgent,
});
const { passwortHash: _hash, ...ohneGeheimnis } = person;
return ohneGeheimnis;
return person;
}),
),
@@ -215,8 +215,7 @@ export const personRouter = router({
userAgent: ctx.userAgent,
});
const { passwortHash: _hash, ...ohneGeheimnis } = nachher;
return ohneGeheimnis;
return nachher;
}),
),
@@ -2,7 +2,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createHash, randomBytes } from "node:crypto";
import { EreignisTyp, Kanal, Zustellstand } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import {
@@ -79,7 +79,7 @@ export const benachrichtigungRouter = router({
eigeneKanaele: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) =>
ctx.db((db) =>
db.zustellkanal.findMany({
where: { personId: ctx.sitzung.personId },
where: { personId: eigeneAkte(ctx.sitzung) },
orderBy: [{ kanal: "asc" }, { erstelltAm: "asc" }],
select: {
id: true,
@@ -116,7 +116,7 @@ export const benachrichtigungRouter = router({
if (input.kanal === Kanal.MAIL && input.ziel) {
const person = await ctx.db((db) =>
db.person.findUnique({
where: { id: ctx.sitzung.personId },
where: { id: eigeneAkte(ctx.sitzung) },
select: { email: true },
}),
);
@@ -136,14 +136,14 @@ export const benachrichtigungRouter = router({
const kanal = await db.zustellkanal.upsert({
where: {
personId_kanal_ziel: {
personId: ctx.sitzung.personId,
personId: eigeneAkte(ctx.sitzung),
kanal: input.kanal,
ziel: input.ziel,
},
},
create: {
organisationId: ctx.sitzung.organisationId,
personId: ctx.sitzung.personId,
personId: eigeneAkte(ctx.sitzung),
kanal: input.kanal,
ziel: input.ziel,
p256dh: input.p256dh ?? null,
@@ -284,7 +284,7 @@ export const benachrichtigungRouter = router({
db.benachrichtigung.create({
data: {
organisationId: ctx.sitzung.organisationId,
personId: ctx.sitzung.personId,
personId: eigeneAkte(ctx.sitzung),
ereignis: EreignisTyp.TERMIN_ERINNERUNG,
kanal: kanal.kanal,
ziel: kanal.ziel,
@@ -313,7 +313,7 @@ export const benachrichtigungRouter = router({
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.benachrichtigung.findMany({
where: { personId: ctx.sitzung.personId },
where: { personId: eigeneAkte(ctx.sitzung) },
orderBy: { erstelltAm: "desc" },
take: input?.anzahl ?? 50,
select: {
@@ -487,7 +487,7 @@ export const benachrichtigungRouter = router({
kalenderzugaenge: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) =>
ctx.db((db) =>
db.kalenderzugang.findMany({
where: { personId: ctx.sitzung.personId },
where: { personId: eigeneAkte(ctx.sitzung) },
orderBy: { erstelltAm: "desc" },
select: {
id: true,
@@ -529,7 +529,7 @@ export const benachrichtigungRouter = router({
const eintrag = await db.kalenderzugang.create({
data: {
organisationId: ctx.sitzung.organisationId,
personId: ctx.sitzung.personId,
personId: eigeneAkte(ctx.sitzung),
bezeichnung: input.bezeichnung,
nurEigene: input.nurEigene,
tokenHash,
+6 -6
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { EreignisTyp, Serienmuster, Teilnahmestand, TerminArt } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { HOECHSTZAHL, serientermine } from "@/lib/termin/serie";
@@ -128,7 +128,7 @@ export const terminRouter = router({
...(input?.art ? { art: input.art } : {}),
...(input?.mitAbgesagten === false ? { abgesagtAm: null } : {}),
...(input?.nurEigene
? { anmeldungen: { some: { personId: ctx.sitzung.personId } } }
? { anmeldungen: { some: { personId: eigeneAkte(ctx.sitzung) } } }
: {}),
},
orderBy: { beginn: rueckwaerts ? "desc" : "asc" },
@@ -137,7 +137,7 @@ export const terminRouter = router({
...listenauswahl,
_count: { select: { anmeldungen: { where: { stand: Teilnahmestand.ZUGESAGT } } } },
anmeldungen: {
where: { personId: ctx.sitzung.personId },
where: { personId: eigeneAkte(ctx.sitzung) },
select: { stand: true },
take: 1,
},
@@ -540,7 +540,7 @@ export const terminRouter = router({
}
const bisher = await db.terminanmeldung.findUnique({
where: { terminId_personId: { terminId: termin.id, personId: ctx.sitzung.personId } },
where: { terminId_personId: { terminId: termin.id, personId: eigeneAkte(ctx.sitzung) } },
select: { id: true, stand: true },
});
@@ -560,11 +560,11 @@ export const terminRouter = router({
}
const eintrag = await db.terminanmeldung.upsert({
where: { terminId_personId: { terminId: termin.id, personId: ctx.sitzung.personId } },
where: { terminId_personId: { terminId: termin.id, personId: eigeneAkte(ctx.sitzung) } },
create: {
organisationId: ctx.sitzung.organisationId,
terminId: termin.id,
personId: ctx.sitzung.personId,
personId: eigeneAkte(ctx.sitzung),
stand,
grund: input.grund ?? null,
},
+360
View File
@@ -0,0 +1,360 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { MitgliedschaftArt, Prisma, RolleSchluessel } from "@prisma/client";
import { router, nurVerwalter } from "@/server/trpc";
import { prismaSystem, fuerOrganisation } from "@/lib/db";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { hashen } from "@/lib/passwort";
import { ROLLEN, ROLLEN_BESCHREIBUNG, ROLLEN_BEZEICHNUNG, ROLLEN_RECHTE } from "@/lib/authz";
/**
* Die Verwaltung der Installation.
*
* Wehren anlegen, Konten führen, Mitgliedschaften vergeben — das Einzige, was
* es in FLORIAN über Mandantengrenzen hinweg gibt.
*
* Drei Festlegungen bestimmen alles hier:
*
* 1. **Es läuft über den Systemzugang.** Row Level Security bindet jede
* Abfrage an eine Organisation; genau die gibt es hier nicht. Deshalb
* hängt der ganze Bereich an einer eigenen Prozedur (`nurVerwalter`) und
* nicht an einem Recht, das innerhalb einer Wehr vergeben werden könnte.
*
* 2. **Ein Konto entsteht ohne Wehr.** Erst die Mitgliedschaft ordnet es
* einer zu. Wer das umdreht, kann Doppelmitgliedschaften nicht abbilden —
* und genau darum ging es in dieser Phase.
*
* 3. **Jede Änderung wird in der betroffenen Wehr protokolliert.** Eine
* Mitgliedschaft, die von außen vergeben wurde, muss innerhalb der Wehr
* sichtbar sein. Sonst hätte der Betreiber einen Weg, sich still in
* fremde Daten zu setzen.
*/
const slugMuster = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export const wehrverwaltungRouter = router({
/** Alle Wehren dieser Installation, mit ein paar Kennzahlen. */
wehren: nurVerwalter.query(async () => {
const wehren = await prismaSystem.organisation.findMany({
where: { geloeschtAm: null },
orderBy: { name: "asc" },
select: {
id: true,
slug: true,
name: true,
kurzname: true,
ort: true,
landkreis: true,
aktiv: true,
erstelltAm: true,
_count: { select: { personen: true, mitgliedschaften: true } },
},
});
return wehren;
}),
/**
* Eine Wehr anlegen.
*
* Sie bekommt sofort ihre neun Systemrollen mit den Rechten aus
* `lib/authz.ts` — ohne sie könnte niemand darin etwas tun, und die erste
* Amtshandlung wäre eine Fehlersuche.
*
* Was sie **nicht** bekommt, ist ein Konto. Wer sie führen soll, wird im
* zweiten Schritt zugeordnet: eine Wehr anzulegen und gleich jemanden
* hineinzusetzen sind zwei Entscheidungen, und die zweite trifft in aller
* Regel jemand anders.
*/
wehrAnlegen: nurVerwalter
.input(
z.object({
slug: z
.string()
.trim()
.min(3)
.max(64)
.regex(
slugMuster,
"Nur Kleinbuchstaben, Ziffern und Bindestriche, z. B. ff-musterhausen.",
),
name: z.string().trim().min(3).max(200),
kurzname: z.string().trim().min(2).max(60),
ort: z.string().trim().max(120).nullish(),
landkreis: z.string().trim().max(120).nullish(),
}),
)
.mutation(async ({ ctx, input }) => {
const vorhanden = await prismaSystem.organisation.findUnique({
where: { slug: input.slug },
select: { id: true },
});
if (vorhanden) {
throw new TRPCError({
code: "CONFLICT",
message: `Die Kennung „${input.slug}" ist bereits vergeben.`,
});
}
const wehr = await prismaSystem.organisation.create({
data: {
slug: input.slug,
name: input.name,
kurzname: input.kurzname,
ort: input.ort ?? null,
landkreis: input.landkreis ?? null,
},
});
// Die Systemrollen samt Rechten. AUFSICHT bleibt außen vor: sie wird
// nicht vergeben, sondern ergibt sich aus der Mitgliedschaftsart.
const katalog = await prismaSystem.berechtigung.findMany({
select: { id: true, schluessel: true },
});
const rechteKarte = new Map(katalog.map((eintrag) => [eintrag.schluessel, eintrag.id]));
for (const schluessel of ROLLEN) {
if (schluessel === "AUFSICHT") continue;
const rolle = await prismaSystem.rolle.create({
data: {
organisationId: wehr.id,
schluessel: schluessel as RolleSchluessel,
name: ROLLEN_BEZEICHNUNG[schluessel],
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
systemRolle: true,
},
});
const rechte =
schluessel === "SUPERADMIN" ? [...rechteKarte.keys()] : ROLLEN_RECHTE[schluessel];
for (const recht of rechte) {
const berechtigungId = rechteKarte.get(recht);
if (berechtigungId) {
await prismaSystem.rolleBerechtigung.create({
data: { organisationId: wehr.id, rolleId: rolle.id, berechtigungId },
});
}
}
}
// In der neuen Wehr protokollieren, nicht in der des Verwalters: dort
// ist es geschehen, und dort muss es nachlesbar sein.
await fuerOrganisation(wehr.id, (db) =>
protokolliereIn(db, {
organisationId: wehr.id,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Organisation",
entitaetId: wehr.id,
beschreibung: `Wehr ${wehr.name} angelegt`,
nachher: { slug: wehr.slug, name: wehr.name },
}),
);
return wehr;
}),
/** Alle Konten der Installation, mit ihren Wehren. */
konten: nurVerwalter.query(async () =>
prismaSystem.benutzer.findMany({
where: { geloeschtAm: null },
orderBy: { email: "asc" },
select: {
id: true,
email: true,
aktiv: true,
istVerwalter: true,
letzteAnmeldung: true,
oidcSubject: true,
mitgliedschaften: {
select: {
id: true,
art: true,
aktiv: true,
organisation: { select: { id: true, kurzname: true } },
person: { select: { id: true, vorname: true, nachname: true } },
},
orderBy: { organisation: { name: "asc" } },
},
},
}),
),
/**
* Ein Konto anlegen.
*
* Ohne Wehr — die kommt über die Mitgliedschaft. Das Passwort wird hier
* vergeben und nicht zugeschickt: FLORIAN verschickt keine Passwörter, und
* ein Einladungsverfahren gibt es (noch) nicht.
*/
kontoAnlegen: nurVerwalter
.input(
z.object({
email: z.string().trim().toLowerCase().email().max(200),
passwort: z.string().min(12, "Mindestens zwölf Zeichen."),
istVerwalter: z.boolean().default(false),
}),
)
.mutation(async ({ input }) => {
const vorhanden = await prismaSystem.benutzer.findUnique({
where: { email: input.email },
select: { id: true },
});
if (vorhanden) {
throw new TRPCError({
code: "CONFLICT",
message:
`Für ${input.email} gibt es bereits ein Konto. Wenn diese Person zu einer `
+ "weiteren Wehr gehören soll, wird ihm eine Mitgliedschaft hinzugefügt — "
+ "kein zweites Konto.",
});
}
const konto = await prismaSystem.benutzer.create({
data: {
email: input.email,
passwortHash: await hashen(input.passwort),
passwortGeaendertAm: new Date(),
istVerwalter: input.istVerwalter,
},
select: { id: true, email: true, istVerwalter: true },
});
return konto;
}),
/** Die Personenakten einer Wehr — zur Auswahl beim Zuordnen. */
personenIn: nurVerwalter
.input(z.object({ organisationId: z.string().uuid() }))
.query(async ({ input }) =>
prismaSystem.person.findMany({
where: {
organisationId: input.organisationId,
geloeschtAm: null,
// Wer schon ein Konto hat, steht nicht zur Auswahl: eine Akte hängt
// an höchstens einem.
mitgliedschaft: null,
},
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true },
}),
),
/**
* Ein Konto einer Wehr zuordnen.
*
* MITGLIED verlangt eine Akte in dieser Wehr, AUFSICHT verlangt, dass keine
* angegeben wird — beides setzt die Datenbank zusätzlich durch. Hier steht
* die Prüfung trotzdem, damit die Meldung erklärt statt nur abzulehnen.
*/
mitgliedschaftAnlegen: nurVerwalter
.input(
z.object({
benutzerId: z.string().uuid(),
organisationId: z.string().uuid(),
art: z.nativeEnum(MitgliedschaftArt),
personId: z.string().uuid().nullish(),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.art === MitgliedschaftArt.MITGLIED && !input.personId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Eine Mitgliedschaft braucht die Personenakte in dieser Wehr. Ohne sie wäre "
+ "nicht entscheidbar, wer unter diesem Konto gehandelt hat.",
});
}
if (input.art === MitgliedschaftArt.AUFSICHT && input.personId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Die Kreisaufsicht gehört der Wehr nicht an und bekommt deshalb keine "
+ "Personenakte — sie soll in keiner Mitgliederliste auftauchen.",
});
}
try {
const mitgliedschaft = await prismaSystem.mitgliedschaft.create({
data: {
organisationId: input.organisationId,
benutzerId: input.benutzerId,
personId: input.personId ?? null,
art: input.art,
},
include: {
benutzer: { select: { email: true } },
organisation: { select: { name: true } },
},
});
await fuerOrganisation(input.organisationId, (db) =>
protokolliereIn(db, {
organisationId: input.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Mitgliedschaft",
entitaetId: mitgliedschaft.id,
beschreibung:
`${mitgliedschaft.benutzer.email} als ${input.art === "AUFSICHT" ? "Kreisaufsicht" : "Mitglied"} `
+ `zu ${mitgliedschaft.organisation.name} hinzugefügt`,
nachher: { art: input.art, personId: input.personId ?? null },
}),
);
return mitgliedschaft;
} catch (fehler) {
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
throw new TRPCError({
code: "CONFLICT",
message: "Dieses Konto gehört dieser Wehr bereits an.",
});
}
throw fehler;
}
}),
/**
* Eine Mitgliedschaft beenden.
*
* Gelöscht, nicht stillgelegt: sie ist keine Nachweisunterlage, sondern eine
* Zugangsregel. Was unter ihr getan wurde, steht ohnehin im Audit-Log und
* hängt an der Personenakte — die bleibt.
*/
mitgliedschaftEntfernen: nurVerwalter
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const vorher = await prismaSystem.mitgliedschaft.findUnique({
where: { id: input.id },
include: {
benutzer: { select: { email: true } },
organisation: { select: { name: true } },
},
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Zuordnung gibt es nicht." });
}
await prismaSystem.mitgliedschaft.delete({ where: { id: input.id } });
await fuerOrganisation(vorher.organisationId, (db) =>
protokolliereIn(db, {
organisationId: vorher.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GELOESCHT,
entitaet: "Mitgliedschaft",
entitaetId: input.id,
beschreibung: `${vorher.benutzer.email} aus ${vorher.organisation.name} entfernt`,
vorher: { art: vorher.art, personId: vorher.personId },
}),
);
return { erfolgreich: true };
}),
});
+3 -3
View File
@@ -10,7 +10,7 @@ import {
SignaturRolle,
WartungsterminStatus,
} from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { router, mitRecht, eigeneAkte } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
@@ -282,7 +282,7 @@ export const pruefungRouter = router({
if (laufender) return { pruefung: laufender, fortgesetzt: true };
const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId },
where: { id: eigeneAkte(ctx.sitzung) },
select: { vorname: true, nachname: true },
});
@@ -724,7 +724,7 @@ export const pruefungRouter = router({
}
const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId },
where: { id: eigeneAkte(ctx.sitzung) },
select: { vorname: true, nachname: true },
});
+49
View File
@@ -41,6 +41,30 @@ export const createCallerFactory = t.createCallerFactory;
/** Ohne Anmeldung erreichbar. */
export const oeffentlich = t.procedure;
/**
* Die eigene Personenakte in der aktiven Wehr.
*
* Fuer alles, was „meine Termine“, „meine Benachrichtigungen“ oder „mein
* Ausweis“ bedeutet. Seit Phase 10 kann sie fehlen: eine Aufsichtssitzung
* der Kreisebene gehört der Wehr nicht an und hat dort keine Akte.
*
* Dass diese Funktion wirft statt null zurückzugeben, ist Absicht. Ein
* stilles null würde in einer Abfrage landen, die dann alle Datensätze der
* Wehr liefert statt keinen — aus „meine Termine“ würde „alle Termine“,
* und niemand würde es bemerken.
*/
export function eigeneAkte(sitzung: Sitzung): string {
if (!sitzung.personId) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Diese Ansicht zeigt eigene Daten. Die Kreisaufsicht hat in dieser Wehr keine "
+ "Mitgliedschaft und damit auch keine eigenen Daten.",
});
}
return sitzung.personId;
}
/** Abfrage im Mandantenkontext der Sitzung. */
export type MandantenAbfrage = <T>(fn: (db: MandantenClient) => Promise<T>) => Promise<T>;
@@ -95,3 +119,28 @@ export function mitRecht(recht: Recht) {
return next();
});
}
/**
* Prozedur, die nur der Installationsverwalter aufrufen darf.
*
* Das ist die einzige Stelle, an der über Mandantengrenzen hinweg gearbeitet
* wird: Wehren anlegen, Konten führen, Mitgliedschaften vergeben. Sie hängt
* bewusst nicht an einem Recht aus `lib/authz.ts`, denn Rechte gelten je Wehr
* — und wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben
* wird und in Wehr B wirkt, wäre genau die Hintertür, die diese Phase
* vermeiden soll.
*
* Die Prozeduren dahinter arbeiten deshalb auch nicht über `ctx.db`, sondern
* über den Systemzugang: es gibt bei ihnen keine „aktuelle Organisation".
*/
export const nurVerwalter = angemeldet.use(async ({ ctx, next }) => {
if (!ctx.sitzung.istVerwalter) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Diesen Bereich verwaltet, wer die Installation betreibt. Innerhalb einer Wehr "
+ "lässt er sich nicht freischalten.",
});
}
return next();
});
+23 -1
View File
@@ -2,7 +2,11 @@ import type { Rolle, Sitzung } from "@/lib/authz";
/**
* Erweitert die Typen von Auth.js um das, was FLORIAN zusätzlich in der
* Sitzung führt: Person, Wehr, Rollen und die Art der Anmeldung.
* Sitzung führt: Konto, Mitgliedschaft, Wehr, Rollen und die Art der
* Anmeldung.
*
* Seit Phase 10 sind Konto und Wehr getrennt. Das Konto bleibt über einen
* Wehrwechsel hinweg dasselbe; alles andere wird beim Wechsel neu gesetzt.
*/
declare module "next-auth" {
interface Session {
@@ -14,6 +18,14 @@ declare module "next-auth" {
organisationId: string;
rollen: Rolle[];
anmeldeArt: Sitzung["anmeldeArt"];
/** Das Anmeldekonto. Überdauert den Wehrwechsel. */
benutzerId?: string;
/** Die aktive Mitgliedschaft — sie bestimmt Wehr und Rollen. */
mitgliedschaftId?: string;
/** Aufsichtssitzung der Kreisebene: sieht, ändert nichts. */
nurLesend?: boolean;
/** Verwaltet die Installation: Wehren anlegen, in jede hineinschalten. */
istVerwalter?: boolean;
};
}
@@ -21,6 +33,11 @@ declare module "next-auth" {
organisationId?: string;
rollen?: Rolle[];
anmeldeArt?: Sitzung["anmeldeArt"];
benutzerId?: string;
mitgliedschaftId?: string;
personId?: string | null;
nurLesend?: boolean;
istVerwalter?: boolean;
}
}
@@ -32,6 +49,11 @@ declare module "next-auth/jwt" {
anmeldeArt?: Sitzung["anmeldeArt"];
/** Zeitpunkt, zu dem die Rollen zuletzt aus der Datenbank kamen. */
geladenAm?: number;
benutzerId?: string;
mitgliedschaftId?: string;
nurLesend?: boolean;
istVerwalter?: boolean;
}
}