Drei Rollen: Administrator, Techniker, Kunde. Jede der 28 Aktionen nennt das Recht, das sie verlangt; die Rechte stehen an einer Stelle. Kundenkonten haengen an genau einem Kunden und sehen nur dessen Hosts und Rechnungen - ohne Einkaufspreise und Marge. Die Einschraenkung sitzt in den Abfragen selbst, damit keine Seite vergessen kann, sie weiterzugeben; sechs Tests pruefen das. Dabei gefunden: Neu ueber OIDC angelegte Konten haetten die Vorgabe des Datenmodells geerbt - ADMIN. Jetzt gibt es dafuer eine Einstellung mit der Vorgabe Techniker. Passkeys (#028) als gleichwertiger, passwortloser Anmeldeweg neben Passwort und OIDC. Mehrere je Konto, Aufgabe in einem kurzlebigen Cookie, rpID aus der oeffentlichen Adresse. Ohne HTTPS sagt die Seite es, statt still zu scheitern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Anmeldung per Passkey. Der Knopf erscheint immer; ob ein Passkey vorliegt, weiß erst der Browser –
|
||||
* und das darf die Seite auch nicht vorher verraten (sonst ließen sich Konten ausspähen).
|
||||
*/
|
||||
export function PasskeyAnmeldung({ ziel }: { ziel: string }) {
|
||||
const router = useRouter();
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
async function anmelden() {
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
|
||||
try {
|
||||
const optionen = await fetch("/api/auth/passkey/anmelden").then((antwort) => antwort.json());
|
||||
const antwort = await startAuthentication({ optionsJSON: optionen });
|
||||
|
||||
const ergebnis = await fetch("/api/auth/passkey/anmelden", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(antwort),
|
||||
}).then((a) => a.json());
|
||||
|
||||
if (ergebnis.erfolg) {
|
||||
router.push(ziel.startsWith("/") ? ziel : "/");
|
||||
router.refresh();
|
||||
} else {
|
||||
setFehler(ergebnis.fehler ?? "Anmeldung nicht möglich.");
|
||||
}
|
||||
} catch (fehler) {
|
||||
setFehler(
|
||||
fehler instanceof Error && fehler.name === "NotAllowedError"
|
||||
? "Abgebrochen."
|
||||
: "Mit diesem Gerät geht es nicht. Passkeys brauchen HTTPS.",
|
||||
);
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{fehler ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{fehler}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary w-100 pl-touch-target mb-3"
|
||||
onClick={anmelden}
|
||||
disabled={laeuft}
|
||||
>
|
||||
<i className="bi bi-fingerprint me-1" aria-hidden="true" />
|
||||
{laeuft ? "Warte auf den Browser …" : "Mit Passkey anmelden"}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { formatDatumZeit } from "@/lib/format";
|
||||
import { passkeyLoeschen } from "@/server/actions/passkeys";
|
||||
|
||||
export interface PasskeyZeile {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
erstelltAm: Date;
|
||||
zuletztAm: Date | null;
|
||||
}
|
||||
|
||||
export function PasskeyVerwaltung({
|
||||
passkeys,
|
||||
moeglich,
|
||||
adresse,
|
||||
}: {
|
||||
passkeys: PasskeyZeile[];
|
||||
moeglich: boolean;
|
||||
adresse: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [bezeichnung, setBezeichnung] = useState("");
|
||||
const [meldung, setMeldung] = useState<{ art: "fehler" | "erfolg"; text: string } | null>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
async function registrieren() {
|
||||
setLaeuft(true);
|
||||
setMeldung(null);
|
||||
|
||||
try {
|
||||
const optionen = await fetch("/api/auth/passkey/registrieren").then((antwort) => antwort.json());
|
||||
// Hier öffnet der Browser seine eigene Abfrage (Fingerabdruck, Gesicht, PIN oder Sicherheitsschlüssel).
|
||||
const antwort = await startRegistration({ optionsJSON: optionen });
|
||||
|
||||
const ergebnis = await fetch("/api/auth/passkey/registrieren", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ antwort, bezeichnung }),
|
||||
}).then((a) => a.json());
|
||||
|
||||
if (ergebnis.fehler) {
|
||||
setMeldung({ art: "fehler", text: ergebnis.fehler });
|
||||
} else {
|
||||
setMeldung({ art: "erfolg", text: `„${ergebnis.bezeichnung}“ ist hinterlegt.` });
|
||||
setBezeichnung("");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (fehler) {
|
||||
// Abbruch durch den Benutzer ist kein Fehler, sieht aber genauso aus – deshalb neutral formulieren.
|
||||
setMeldung({
|
||||
art: "fehler",
|
||||
text:
|
||||
fehler instanceof Error && fehler.name === "NotAllowedError"
|
||||
? "Abgebrochen oder abgelehnt."
|
||||
: "Der Passkey konnte nicht angelegt werden.",
|
||||
});
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card titel="Neuen Passkey anlegen" rahmen="primary">
|
||||
{!moeglich ? (
|
||||
<div className="alert alert-warning mb-3">
|
||||
<strong>Über diese Adresse geht es nicht.</strong> Passkeys verlangen HTTPS; nur{" "}
|
||||
<code>localhost</code> ist ausgenommen. Aufgerufen wurde <code>{adresse}</code>. Richten Sie
|
||||
einen Reverse Proxy mit Zertifikat ein und tragen Sie die Adresse unter{" "}
|
||||
<em>Einstellungen → System</em> ein.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{meldung ? (
|
||||
<div className={`alert ${meldung.art === "fehler" ? "alert-danger" : "alert-success"}`}>
|
||||
{meldung.text}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="row g-3 align-items-end">
|
||||
<div className="col-12 col-md-5">
|
||||
<label className="form-label" htmlFor="passkey-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="passkey-name"
|
||||
value={bezeichnung}
|
||||
onChange={(e) => setBezeichnung(e.target.value)}
|
||||
placeholder="z. B. Arbeitsrechner oder Telefon"
|
||||
disabled={!moeglich}
|
||||
/>
|
||||
<div className="form-text">Hilft später beim Wiedererkennen, wenn mehrere hinterlegt sind.</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-4">
|
||||
<button type="button" className="btn btn-primary" onClick={registrieren} disabled={!moeglich || laeuft}>
|
||||
<i className="bi bi-fingerprint me-2" aria-hidden="true" />
|
||||
{laeuft ? "Warte auf den Browser …" : "Passkey anlegen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card titel={`Hinterlegte Passkeys (${passkeys.length})`} rahmen="keiner">
|
||||
{passkeys.length === 0 ? (
|
||||
<p className="text-body-secondary mb-0">
|
||||
Noch keiner. Ohne Passkey bleibt die Anmeldung per Passwort oder OIDC – beides funktioniert
|
||||
weiterhin.
|
||||
</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Bezeichnung</th>
|
||||
<th scope="col">Angelegt</th>
|
||||
<th scope="col">Zuletzt benutzt</th>
|
||||
<th scope="col" className="text-end">
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{passkeys.map((passkey) => (
|
||||
<tr key={passkey.id}>
|
||||
<td className="fw-semibold">{passkey.bezeichnung}</td>
|
||||
<td className="small">{formatDatumZeit(passkey.erstelltAm)}</td>
|
||||
<td className="small">
|
||||
{passkey.zuletztAm ? formatDatumZeit(passkey.zuletztAm) : "noch nie"}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<form action={passkeyLoeschen}>
|
||||
<input type="hidden" name="id" value={passkey.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||
Entfernen
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { formatDatumZeit } from "@/lib/format";
|
||||
import { PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
|
||||
import {
|
||||
type AktionsErgebnis,
|
||||
benutzerAnlegen,
|
||||
benutzerLoeschen,
|
||||
benutzerSpeichern,
|
||||
benutzerSperreUmschalten,
|
||||
} from "@/server/actions/benutzer";
|
||||
import { ROLLEN_BESCHREIBUNG, ROLLEN_TEXT } from "@/server/auth/rollen";
|
||||
|
||||
export interface BenutzerZeile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
rolle: Rolle;
|
||||
kundeId: string | null;
|
||||
kundeName: string | null;
|
||||
aktiv: boolean;
|
||||
hatPasswort: boolean;
|
||||
hatOidc: boolean;
|
||||
passkeys: number;
|
||||
letzterLoginAm: Date | null;
|
||||
}
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
const ROLLEN: Rolle[] = ["ADMIN", "TECHNIKER", "KUNDE"];
|
||||
|
||||
function RollenFelder({
|
||||
eintrag,
|
||||
kunden,
|
||||
fehler,
|
||||
}: {
|
||||
eintrag?: BenutzerZeile;
|
||||
kunden: { id: string; name: string }[];
|
||||
fehler: Record<string, string>;
|
||||
}) {
|
||||
const [rolle, setRolle] = useState<Rolle>(eintrag?.rolle ?? "TECHNIKER");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`rolle-${eintrag?.id ?? "neu"}`}>
|
||||
Rolle
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id={`rolle-${eintrag?.id ?? "neu"}`}
|
||||
name="rolle"
|
||||
defaultValue={rolle}
|
||||
onChange={(e) => setRolle(e.target.value as Rolle)}
|
||||
>
|
||||
{ROLLEN.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{ROLLEN_TEXT[wert]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-text">{ROLLEN_BESCHREIBUNG[rolle]}</div>
|
||||
</div>
|
||||
|
||||
{/* Der Kunde zählt nur für ein Kundenkonto – deshalb erscheint das Feld auch nur dann. */}
|
||||
{rolle === "KUNDE" ? (
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`kunde-${eintrag?.id ?? "neu"}`}>
|
||||
Kunde
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id={`kunde-${eintrag?.id ?? "neu"}`}
|
||||
name="kundeId"
|
||||
defaultValue={eintrag?.kundeId ?? ""}
|
||||
>
|
||||
<option value="">– bitte wählen –</option>
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fehler.kundeId ? <div className="invalid-feedback d-block">{fehler.kundeId}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NeuFormular({ kunden }: { kunden: { id: string; name: string }[] }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(benutzerAnlegen, LEER);
|
||||
const fehler = ergebnis.felder ?? {};
|
||||
|
||||
return (
|
||||
<Card titel="Benutzer anlegen" rahmen="primary">
|
||||
<form action={aktion} className="row g-3 align-items-start">
|
||||
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
|
||||
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor="neu-name">
|
||||
Name
|
||||
</label>
|
||||
<input className="form-control" id="neu-name" name="name" required />
|
||||
{fehler.name ? <div className="invalid-feedback d-block">{fehler.name}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor="neu-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input type="email" className="form-control" id="neu-email" name="email" required />
|
||||
{fehler.email ? <div className="invalid-feedback d-block">{fehler.email}</div> : null}
|
||||
</div>
|
||||
|
||||
<RollenFelder kunden={kunden} fehler={fehler} />
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor="neu-passwort">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="neu-passwort"
|
||||
name="passwort"
|
||||
minLength={PASSWORT_MINDESTLAENGE}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="form-text">
|
||||
Leer lassen, wenn sich das Konto nur über OIDC oder einen Passkey anmelden soll.
|
||||
</div>
|
||||
{fehler.passwort ? <div className="invalid-feedback d-block">{fehler.passwort}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-primary" disabled={laeuft}>
|
||||
{laeuft ? "Wird angelegt …" : "Benutzer anlegen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Zeile({
|
||||
eintrag,
|
||||
kunden,
|
||||
ichSelbst,
|
||||
}: {
|
||||
eintrag: BenutzerZeile;
|
||||
kunden: { id: string; name: string }[];
|
||||
ichSelbst: boolean;
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(benutzerSpeichern, LEER);
|
||||
const [offen, setOffen] = useState(false);
|
||||
const fehler = ergebnis.felder ?? {};
|
||||
|
||||
const anmeldewege = [
|
||||
eintrag.hatPasswort ? "Passwort" : null,
|
||||
eintrag.hatOidc ? "OIDC" : null,
|
||||
eintrag.passkeys > 0 ? `${eintrag.passkeys} Passkey${eintrag.passkeys === 1 ? "" : "s"}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="button" className="btn btn-link p-0 text-start" onClick={() => setOffen(!offen)}>
|
||||
<span className="fw-semibold">{eintrag.name}</span>
|
||||
</button>
|
||||
{ichSelbst ? <span className="badge text-bg-secondary ms-2">Sie</span> : null}
|
||||
<div className="small text-body-secondary">{eintrag.email}</div>
|
||||
</td>
|
||||
<td>
|
||||
{ROLLEN_TEXT[eintrag.rolle]}
|
||||
{eintrag.kundeName ? <div className="small text-body-secondary">{eintrag.kundeName}</div> : null}
|
||||
</td>
|
||||
<td className="small">
|
||||
{anmeldewege.length > 0 ? anmeldewege.join(" · ") : <span className="text-danger">kein Zugang</span>}
|
||||
</td>
|
||||
<td className="small">
|
||||
{eintrag.letzterLoginAm ? formatDatumZeit(eintrag.letzterLoginAm) : "noch nie"}
|
||||
</td>
|
||||
<td>
|
||||
{eintrag.aktiv ? (
|
||||
<span className="badge text-bg-success">aktiv</span>
|
||||
) : (
|
||||
<span className="badge text-bg-danger">gesperrt</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="d-flex gap-2 justify-content-end">
|
||||
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={() => setOffen(!offen)}>
|
||||
{offen ? "Schließen" : "Bearbeiten"}
|
||||
</button>
|
||||
{!ichSelbst ? (
|
||||
<>
|
||||
<form action={benutzerSperreUmschalten}>
|
||||
<input type="hidden" name="id" value={eintrag.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline-warning">
|
||||
{eintrag.aktiv ? "Sperren" : "Freigeben"}
|
||||
</button>
|
||||
</form>
|
||||
<form action={benutzerLoeschen}>
|
||||
<input type="hidden" name="id" value={eintrag.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{offen ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-body-tertiary">
|
||||
<form action={aktion} className="row g-3 align-items-start">
|
||||
<input type="hidden" name="id" value={eintrag.id} />
|
||||
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
|
||||
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`name-${eintrag.id}`}>
|
||||
Name
|
||||
</label>
|
||||
<input className="form-control" id={`name-${eintrag.id}`} name="name" defaultValue={eintrag.name} />
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`email-${eintrag.id}`}>
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id={`email-${eintrag.id}`}
|
||||
name="email"
|
||||
defaultValue={eintrag.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RollenFelder eintrag={eintrag} kunden={kunden} fehler={fehler} />
|
||||
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor={`passwort-${eintrag.id}`}>
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id={`passwort-${eintrag.id}`}
|
||||
name="passwort"
|
||||
minLength={PASSWORT_MINDESTLAENGE}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="form-text">Leer lassen heißt: unverändert. Ein neues beendet alle Sitzungen.</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-primary" disabled={laeuft}>
|
||||
{laeuft ? "Wird gespeichert …" : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BenutzerVerwaltung({
|
||||
benutzer,
|
||||
kunden,
|
||||
ichId,
|
||||
}: {
|
||||
benutzer: BenutzerZeile[];
|
||||
kunden: { id: string; name: string }[];
|
||||
ichId: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<NeuFormular kunden={kunden} />
|
||||
|
||||
<Card titel={`Benutzer (${benutzer.length})`} rahmen="keiner">
|
||||
<div className="table-responsive">
|
||||
<table className="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Rolle</th>
|
||||
<th scope="col">Anmeldung</th>
|
||||
<th scope="col">Zuletzt angemeldet</th>
|
||||
<th scope="col">Zustand</th>
|
||||
<th scope="col" className="text-end">
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{benutzer.map((eintrag) => (
|
||||
<Zeile key={eintrag.id} eintrag={eintrag} kunden={kunden} ichSelbst={eintrag.id === ichId} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -240,6 +240,23 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
name="oidcStandardRolle"
|
||||
beschriftung="Rolle für neue OIDC-Konten"
|
||||
hinweis="Gilt nur für Konten, die bei der ersten Anmeldung automatisch entstehen."
|
||||
>
|
||||
<select
|
||||
className="form-select"
|
||||
id="oidcStandardRolle"
|
||||
name="oidcStandardRolle"
|
||||
defaultValue={werte.oidcStandardRolle}
|
||||
>
|
||||
<option value="TECHNIKER">Techniker</option>
|
||||
<option value="KUNDE">Kunde</option>
|
||||
<option value="ADMIN">Administrator</option>
|
||||
</select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
name="aufbewahrungReportsTage"
|
||||
beschriftung="Reports aufbewahren (Tage)"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
||||
|
||||
export interface KopfleistenAlert {
|
||||
id: string;
|
||||
@@ -33,10 +35,12 @@ export function Header({
|
||||
alerts,
|
||||
suchEintraege,
|
||||
benutzer,
|
||||
rolle,
|
||||
}: {
|
||||
alerts: KopfleistenAlert[];
|
||||
suchEintraege: SuchEintrag[];
|
||||
benutzer: string;
|
||||
rolle: Rolle;
|
||||
}) {
|
||||
const [sucheOffen, setSucheOffen] = useState(false);
|
||||
const [glockeOffen, setGlockeOffen] = useState(false);
|
||||
@@ -209,10 +213,12 @@ export function Header({
|
||||
<span className="d-none d-sm-inline">{benutzer}</span>
|
||||
</button>
|
||||
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}>
|
||||
<span className="dropdown-header">Angemeldet als {benutzer}</span>
|
||||
<span className="dropdown-header">
|
||||
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
||||
</span>
|
||||
<div className="dropdown-divider" />
|
||||
<Link href="/einstellungen/system" className="dropdown-item">
|
||||
Einstellungen
|
||||
<Link href="/einstellungen/passkeys" className="dropdown-item">
|
||||
Meine Passkeys
|
||||
</Link>
|
||||
<a className="dropdown-item" href="/api/auth/abmelden">
|
||||
Abmelden
|
||||
|
||||
@@ -4,6 +4,8 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { LogoMitSchriftzug } from "@/components/Logo";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
|
||||
interface MenuEintrag {
|
||||
href: string;
|
||||
@@ -17,11 +19,13 @@ export function Sidebar({
|
||||
offeneRechnungspunkte,
|
||||
offeneWarnungen,
|
||||
benutzer,
|
||||
rolle,
|
||||
}: {
|
||||
hostsAnzahl: number;
|
||||
offeneRechnungspunkte: number;
|
||||
offeneWarnungen: number;
|
||||
benutzer: string;
|
||||
rolle: Rolle;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -35,7 +39,7 @@ export function Sidebar({
|
||||
icon: "bi-hdd-stack",
|
||||
badge: { text: String(hostsAnzahl), klasse: "pl-bg-info" },
|
||||
},
|
||||
{ href: "/kunden", titel: "Kunden", icon: "bi-people" },
|
||||
...(rolle === "KUNDE" ? [] : [{ href: "/kunden", titel: "Kunden", icon: "bi-people" }]),
|
||||
{
|
||||
href: "/rechnungen",
|
||||
titel: "Rechnungen",
|
||||
@@ -54,12 +58,24 @@ export function Sidebar({
|
||||
},
|
||||
];
|
||||
|
||||
// Das Menü zeigt nur, was die Rolle auch darf. Der Schutz liegt trotzdem in den Seiten
|
||||
// und Aktionen selbst – ein ausgeblendeter Eintrag ist keine Zugriffskontrolle (#027).
|
||||
const einstellungen: MenuEintrag[] = [
|
||||
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
|
||||
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
|
||||
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
|
||||
{ href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
|
||||
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
|
||||
...(darf(rolle, "betrieb")
|
||||
? [
|
||||
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
|
||||
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
|
||||
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
|
||||
]
|
||||
: []),
|
||||
...(darf(rolle, "verwaltung")
|
||||
? [
|
||||
{ href: "/einstellungen/benutzer", titel: "Benutzer", icon: "bi-person-gear" },
|
||||
{ href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
|
||||
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
|
||||
]
|
||||
: []),
|
||||
{ href: "/einstellungen/passkeys", titel: "Meine Passkeys", icon: "bi-fingerprint" },
|
||||
];
|
||||
|
||||
function istAktiv(href: string) {
|
||||
|
||||
Reference in New Issue
Block a user