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
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { einstellungenSpeichernAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export type EinstellungsWerte = {
readonly eigeneOrganisationName: string;
readonly gesperrteOu: string;
readonly regeln: {
readonly upn: string;
readonly sam: string;
readonly mail: string;
readonly hostname: string;
};
/** Im Modus Dienstleistung heisst die Organisation in der Oberflaeche "Kunde". */
readonly kundenEbeneSichtbar: boolean;
};
const REGELN = [
{
name: "regel_upn",
beschriftung: "UPN",
schluessel: "upn",
beispiel: "{vorname}.{nachname}@firma.de",
},
{ name: "regel_sam", beschriftung: "Anmeldename (sam)", schluessel: "sam", beispiel: "{v}{nachname}" },
{ name: "regel_mail", beschriftung: "Mail", schluessel: "mail", beispiel: "{vorname}.{nachname}@firma.de" },
{ name: "regel_hostname", beschriftung: "Hostname", schluessel: "hostname", beispiel: "PC-{nnn}" },
] as const;
export function EinstellungsFormular({ werte }: { werte: EinstellungsWerte }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
einstellungenSpeichernAktion,
{},
);
return (
<form action={aktion} className="flex flex-col gap-8">
<section>
<h2 className="text-lg font-semibold">
{werte.kundenEbeneSichtbar ? "Eigene Organisation" : "Organisation"}
</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
{werte.kundenEbeneSichtbar
? "Der eigene Betrieb, der im Modus Dienstleistung als erster Kunde geführt wird."
: "Im Modus Intern erscheint dieser Name nirgends in der Oberfläche. Er wird erst sichtbar, wenn du auf Dienstleistung umstellst."}
</p>
<label className="flex max-w-md flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input
name="eigeneOrganisationName"
defaultValue={werte.eigeneOrganisationName}
required
className={FELD}
/>
</label>
</section>
<section>
<h2 className="text-lg font-semibold">Namensregeln</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Vorlagen für UPN, Anmeldename, Mail und Hostname. Beim Start eines Laufs werden daraus Vorschläge
berechnet, die der Techniker überschreiben kann. Platzhalter:{" "}
<code className="mono">{"{vorname}"}</code>, <code className="mono">{"{nachname}"}</code>,{" "}
<code className="mono">{"{v}"}</code> für den ersten Buchstaben des Vornamens,{" "}
<code className="mono">{"{nnn}"}</code> für eine fortlaufende Nummer
{werte.kundenEbeneSichtbar ? (
<>
{" "}
und <code className="mono">{"{kundenkuerzel}"}</code>
</>
) : null}
.
</p>
<div className="grid gap-4 sm:grid-cols-2">
{REGELN.map((regel) => (
<label key={regel.name} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">{regel.beschriftung}</span>
<input
name={regel.name}
defaultValue={werte.regeln[regel.schluessel]}
placeholder={regel.beispiel}
className={`${FELD} mono`}
/>
</label>
))}
</div>
</section>
<section>
<h2 className="text-lg font-semibold">Sperr-OU</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Ziel-OU für deaktivierte Konten. Wird beim Offboarding vorbelegt.
</p>
<label className="flex max-w-lg flex-col gap-1.5 text-sm">
<span className="text-nebentext">Organisationseinheit</span>
<input
name="gesperrteOu"
defaultValue={werte.gesperrteOu}
placeholder="OU=Gesperrt,OU=Benutzer,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
</section>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : "Speichern"}
</Button>
</form>
);
}