feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 05:03:11 +00:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit ecc3c579df
43 changed files with 2729 additions and 53 deletions
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { headers } from "next/headers";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite
* darunter: Next rendert ein Layout bei Navigation innerhalb desselben Layouts
* nicht neu, es ist also das zweite Netz und nicht das erste (siehe CLAUDE.md).
*/
const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await sitzungErforderlich("verwaltung", "/admin");
const pfad = (await headers()).get("x-pfad") ?? "/admin";
return (
<>
<nav aria-label="Verwaltung" className="mb-8 flex flex-wrap gap-2">
{REITER.map((reiter) => {
const aktiv = pfad === reiter.pfad;
return (
<Link
key={reiter.pfad}
href={reiter.pfad}
aria-current={aktiv ? "page" : undefined}
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
aktiv
? "bg-akzent-flaeche font-semibold text-akzent"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{reiter.beschriftung}
</Link>
);
})}
</nav>
{children}
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { EinstellungsFormular } from "@/components/einstellungen-formular";
import { ModusWechsel } from "@/components/modus-wechsel";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
const MODUS_NAME: Record<Betriebsmodus, string> = {
INTERN: "Intern",
DIENSTLEISTUNG: "Dienstleistung",
};
const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
INTERN:
"Einsatz in einem Unternehmen für die eigenen Mitarbeiter. Ein Verzeichnis per LDAP, keine Kundenebene.",
DIENSTLEISTUNG:
"Einsatz als IT-Dienstleister für mehrere Kunden. Geräte, Personen und Abteilungen hängen an einem Kunden; kein LDAP zu Kunden-Verzeichnissen.",
};
export default async function EinstellungenSeite() {
await sitzungErforderlich("verwaltung", "/admin");
const einstellungen = await einstellungenHolen();
const gegenteil =
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
const pruefung = await modusWechselPruefen(gegenteil);
return (
<>
<h1 className="text-2xl font-bold">Einstellungen</h1>
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<div className="flex flex-wrap items-baseline gap-3">
<h2 className="text-lg font-semibold">Betriebsmodus</h2>
<span className="rounded-pille bg-akzent-flaeche px-3 py-1 text-xs font-semibold text-akzent">
{MODUS_NAME[einstellungen.modus]}
</span>
</div>
<p className="mt-2 mb-5 max-w-prose text-sm text-nebentext">
{MODUS_ERKLAERUNG[einstellungen.modus]}
</p>
<ModusWechsel
auskunft={{
zielModus: gegenteil,
zielName: MODUS_NAME[gegenteil],
moeglich: pruefung.moeglich,
...(pruefung.grund === undefined ? {} : { grund: pruefung.grund }),
brauchtAngaben: pruefung.brauchtAngaben,
folgen: pruefung.folgen,
vorschlagName: einstellungen.eigeneOrganisation.name,
vorschlagKuerzel: einstellungen.eigeneOrganisation.kuerzel,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<EinstellungsFormular
werte={{
eigeneOrganisationName: einstellungen.eigeneOrganisation.name,
gesperrteOu: einstellungen.gesperrteOu ?? "",
regeln: {
upn: einstellungen.namensregeln.upn ?? "",
sam: einstellungen.namensregeln.sam ?? "",
mail: einstellungen.namensregeln.mail ?? "",
hostname: einstellungen.namensregeln.hostname ?? "",
},
kundenEbeneSichtbar: einstellungen.modus === Betriebsmodus.DIENSTLEISTUNG,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
Jeder gedruckte QR-Code enthält diese Adresse plus den Tag-Code. Sie kommt aus der Umgebung (
<code className="mono">APP_URL</code>) und lässt sich hier nicht ändern — gedruckte Aufkleber würden
sonst ins Leere zeigen.
</p>
<p className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-sm">
{einstellungen.appUrl ? (
`${einstellungen.appUrl}/t/LZ-7K3F9Q`
) : (
<span className="text-faellig">
APP_URL ist nicht gesetzt — Etiketten können noch nicht gedruckt werden.
</span>
)}
</p>
</section>
</>
);
}