feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user