Anmeldung wahlweise ueber OpenID Connect (Authentik)

Neben dem Passwort-Login gibt es jetzt einen zweiten, gleichberechtigten Weg:
OpenID Connect gegen einen selbst gehosteten Anmeldedienst. Ohne Konfiguration
aendert sich nichts - keine SSO-Knoepfe, Passwort-Login wie bisher.

Der Anmeldedienst ist NUR fuer die Anmeldung zustaendig. Danach stellt Vorrania
sein eigenes Token aus wie beim Passwort-Login, deshalb bleiben deps.py, die
Rollenpruefung und die langlebigen API-Tokens unveraendert.

Backend
- Anbieter kommen aus der Umgebung (OIDC_PROVIDERS + OIDC_<KUERZEL>_*). Ein
  zweiter Anbieter ist damit reine Konfiguration, ohne Codeaenderung.
- Browser-Weg (vertraulicher Client) und App-Weg (oeffentlicher Client, PKCE)
  enden beide im selben Token-Schema.
- state/nonce/PKCE-Verifier liegen in einer kurzlebigen Tabelle statt in einem
  Cookie: Starlettes CORS-Schicht spiegelt bei allow_origins=["*"] zusammen mit
  allow_credentials die Herkunft zurueck, sobald ein Request ein Cookie traegt.
  Ohne Cookie entsteht diese Wechselwirkung erst gar nicht - und der Ablauf
  bleibt mit der db-Fixture pruefbar.
- Zuordnung ueber die sub des Anbieters; die Erstverknuepfung laeuft ueber die
  BESTAETIGTE E-Mail-Adresse. Rollen kommen nie aus dem Anbieter, und ein
  gleichlautender preferred_username uebernimmt nie ein bestehendes Konto.
- users.password_hash darf jetzt leer sein (reine SSO-Konten). verify_password
  faengt das ab, sonst antwortete der Login mit 500 statt 401.

Verknuepfen bestehender Konten
- Neue Seite "Mein Konto" (fuer alle Benutzer, nicht nur Admins): verknuepfen
  und wieder loesen. Die Berechtigung stammt aus der laufenden Sitzung, es
  braucht also weder eine hinterlegte Adresse noch Admin-Rechte.
- Der Start laeuft ueber POST, weil eine Seitennavigation das Bearer-Token
  nicht mitschicken kann.
- Die letzte Anmeldemoeglichkeit eines Kontos laesst sich nicht loesen.

iOS
- AppAuth (erste Paketabhaengigkeit) mit PKCE im System-Browser, nie in einem
  eingebetteten WebView, und ohne Client-Secret.
- offline_access nur im App-Weg: der Browser-Weg braucht kein Refresh-Token und
  bekaeme sonst einen ueberfluessigen Zustimmungsdialog.
- Anmeldezustand je Serverprofil im Keychain; abgelaufene Token erneuert die App
  still im Hintergrund. Gleichzeitige 401er teilen sich eine Erneuerung.

Deployment
- docker-compose reicht die ganze .env durch. Einzeln aufzuzaehlen ginge nur mit
  fest verdrahteten Kuerzeln - genau das soll ein weiterer Anbieter nicht
  erfordern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 21:55:38 +02:00
co-authored by Claude Opus 5
parent 8d4954610b
commit 3d0ff54bf5
35 changed files with 2772 additions and 24 deletions
+11 -2
View File
@@ -6,6 +6,7 @@ import Icon from "./components/Icon";
import ErrorBoundary from "./components/ErrorBoundary";
import BrandMark from "./components/BrandMark";
import Login from "./pages/Login";
import OidcCallback from "./pages/OidcCallback";
import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products";
import ItemList from "./pages/ItemList";
@@ -27,6 +28,7 @@ import ShoppingList from "./pages/ShoppingList";
import History from "./pages/History";
import Transfer from "./pages/Transfer";
import Settings from "./pages/Settings";
import Konto from "./pages/Konto";
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
@@ -104,10 +106,11 @@ function Sidebar() {
)}
</nav>
<div className="sidebar-foot">
<div className="sidebar-user">
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */}
<NavLink to="/konto" className="sidebar-user" title="Mein Konto">
<span className="name">{user?.username}</span>
<span className="role">{isAdmin ? "Administrator" : "Benutzer"}</span>
</div>
</NavLink>
<button className="btn-icon" onClick={handleLogout} title="Abmelden">
<Icon name="logout" size={18} />
</button>
@@ -187,6 +190,8 @@ export default function App() {
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} />
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
<Route path="/konto" element={<Protected><Konto /></Protected>} />
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
@@ -194,6 +199,10 @@ export default function App() {
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
{/* Rueckweg vom Single-Sign-on: MUSS oeffentlich sein (der Nutzer ist in
diesem Moment noch nicht angemeldet) und MUSS vor dem Auffangmuster
unten stehen – sonst schluckt "*" die Weiterleitung samt Token. */}
<Route path="/auth/callback" element={<OidcCallback />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
+15 -1
View File
@@ -18,6 +18,10 @@ export function setToken(token) {
// Wird bei einer abgelaufenen/ungueltigen Anmeldung gerufen (HTTP 401).
// Ohne das bleibt die Oberflaeche „angemeldet", waehrend jede Abfrage scheitert –
// man sieht dann leere Seiten statt eines Hinweises.
// Pfade, bei denen ein 401 NICHT „Sitzung abgelaufen" heisst: beim Anmelden
// selbst heisst er „Passwort falsch", und die Anbieterliste ist oeffentlich.
const OHNE_ABMELDUNG = ["/auth/login", "/auth/oidc/providers"];
let beiAbmeldung = null;
export function setUnauthorizedHandler(fn) {
beiAbmeldung = fn;
@@ -51,7 +55,7 @@ async function request(path, { method = "GET", body, form, formData } = {}) {
// Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite.
// Beim Anmelden selbst nicht – dort heisst 401 schlicht „Passwort falsch".
if (resp.status === 401 && path !== "/auth/login") {
if (resp.status === 401 && !OHNE_ABMELDUNG.includes(path)) {
beiAbmeldung?.();
}
@@ -146,6 +150,16 @@ export const api = {
login: (username, password) =>
request("/auth/login", { method: "POST", form: { username, password } }),
me: () => request("/auth/me"),
// Welche Single-Sign-on-Anbieter eingerichtet sind. Kommt vom Server, weil die
// Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer
// und es taucht kein SSO-Knopf auf.
oidcProviders: () => request("/auth/oidc/providers"),
// Mit welchen Anmeldediensten das eigene Konto verknuepft ist.
oidcIdentities: () => request("/auth/oidc/identities"),
// Startet die Verknuepfung. Muss POST sein, damit das Bearer-Token der
// laufenden Sitzung mitgeht - bei einer Seitennavigation ginge das nicht.
oidcLinkStart: (slug) => request(`/auth/oidc/${slug}/link`, { method: "POST" }),
oidcUnlink: (slug) => request(`/auth/oidc/${slug}/link`, { method: "DELETE" }),
// Produkte
listProducts: (q, categoryId) => {
+21 -1
View File
@@ -63,6 +63,26 @@ export function AuthProvider({ children }) {
return me;
}
// Nach dem Single-Sign-on kommt das fertige Token von aussen zurueck (aus dem
// Adressfragment), nicht aus einem Formular. `login` passt dafuer nicht – das
// kennt nur Benutzername und Passwort.
async function tokenUebernehmen(token) {
setToken(token);
try {
const me = await api.me();
setUser(me);
setAbgelaufen(false);
setStartFehler(null);
return me;
} catch (err) {
// Ein Token, mit dem sich nichts abrufen laesst, darf nicht liegenbleiben –
// sonst zeigt die Oberflaeche „angemeldet" und lauter leere Seiten.
setToken(null);
setUser(null);
throw err;
}
}
function logout() {
setToken(null);
setUser(null);
@@ -78,7 +98,7 @@ export function AuthProvider({ children }) {
return (
<AuthContext.Provider value={{
user, isAdmin, loading, login, logout,
user, isAdmin, loading, login, logout, tokenUebernehmen,
abgelaufen, startFehler, erneutVersuchen,
}}>
{children}
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
// Kennungen aus dem Rueckweg des Anmeldedienstes (`/konto#sso_error=…`).
const SSO_FEHLERTEXTE = {
abgebrochen: "Die Verknüpfung wurde abgebrochen.",
sitzung_abgelaufen: "Der Vorgang hat zu lange gedauert. Bitte noch einmal versuchen.",
state_falsch: "Die Antwort des Anmeldedienstes passte nicht zur Anfrage.",
unvollstaendige_antwort: "Der Anmeldedienst hat unvollständig geantwortet.",
anbieter_nicht_erreichbar: "Der Anmeldedienst ist nicht erreichbar.",
kennung_fremd:
"Diese Anmeldung gehört bereits zu einem anderen Vorrania-Konto. " +
"Melde dich dort ab oder verwende eine andere Kennung.",
schon_verknuepft: "Dieses Konto ist mit dem Anmeldedienst bereits verknüpft.",
konto_verschwunden: "Das Konto gibt es nicht mehr.",
};
function datum(wert) {
if (!wert) return "–";
return new Date(wert).toLocaleDateString("de-CH", {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
/**
* „Mein Konto": zeigt, wie man sich an diesem Server anmelden kann, und
* verknüpft das Konto mit einem Anmeldedienst (Single Sign-on).
*
* Der Weg über diese Seite ist der einzige, der **ohne** hinterlegte
* E-Mail-Adresse funktioniert: Die Berechtigung stammt aus der laufenden
* Sitzung, nicht aus einem Claim des Anmeldedienstes.
*/
export default function Konto() {
const { user } = useAuth();
const confirm = useConfirm();
const toast = useToast();
const [anbieter, setAnbieter] = useState([]);
const [verknuepfungen, setVerknuepfungen] = useState([]);
const [fehler, setFehler] = useState(null);
const [laedt, setLaedt] = useState(true);
const [busy, setBusy] = useState(null);
async function laden() {
try {
const [liste, meine] = await Promise.all([
api.oidcProviders().catch(() => []),
api.oidcIdentities(),
]);
setAnbieter(liste || []);
setVerknuepfungen(meine || []);
} catch (err) {
setFehler(err.message);
} finally {
setLaedt(false);
}
}
useEffect(() => { laden(); }, []);
// Rueckmeldung aus dem Rueckweg einsammeln und die Adresszeile saeubern.
useEffect(() => {
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const verknuepft = fragment.get("sso_linked");
const kennung = fragment.get("sso_error");
if (!verknuepft && !kennung) return;
if (verknuepft) toast("Konto verknüpft.");
if (kennung) {
setFehler(SSO_FEHLERTEXTE[kennung] || "Die Verknüpfung ist fehlgeschlagen.");
}
window.history.replaceState(null, "", window.location.pathname);
}, [toast]);
async function verknuepfen(slug) {
setFehler(null);
setBusy(slug);
try {
// Erst die Adresse holen (mit Anmeldung im Header), dann dorthin
// navigieren – ein einfacher Link könnte das Token nicht mitschicken.
const { authorization_url } = await api.oidcLinkStart(slug);
window.location.assign(authorization_url);
} catch (err) {
setFehler(err.message);
setBusy(null);
}
}
async function loesen(slug, name) {
const ok = await confirm({
title: `Verknüpfung mit „${name}" lösen?`,
message: "Danach ist die Anmeldung über diesen Dienst nicht mehr möglich.",
confirmLabel: "Lösen",
danger: true,
});
if (!ok) return;
setFehler(null);
setBusy(slug);
try {
await api.oidcUnlink(slug);
toast("Verknüpfung gelöst.");
await laden();
} catch (err) {
setFehler(err.message);
} finally {
setBusy(null);
}
}
const verknuepftMit = (slug) => verknuepfungen.find((v) => v.provider === slug);
// Verknüpfungen zu Diensten, die es am Server nicht mehr gibt – lösen können
// muss man sie trotzdem, sonst hängen sie unsichtbar am Konto.
const verwaiste = verknuepfungen.filter(
(v) => !anbieter.some((a) => a.slug === v.provider)
);
return (
<div>
<div className="page-head">
<div>
<h1>Mein Konto</h1>
<div className="sub">Wie du dich an diesem Server anmeldest</div>
</div>
</div>
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
<div className="card">
<div className="card-head"><Icon name="users" /><h2>Konto</h2></div>
<dl className="kv">
<dt>Benutzername</dt><dd>{user?.username}</dd>
<dt>E-Mail</dt>
<dd>{user?.email || <span className="muted">nicht hinterlegt</span>}</dd>
<dt>Rolle</dt>
<dd>{user?.role === "admin" ? "Administrator" : "Benutzer"}</dd>
</dl>
</div>
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
<div className="card-head"><Icon name="settings" /><h2>Single Sign-on</h2></div>
{laedt ? (
<div className="muted">Lädt…</div>
) : anbieter.length === 0 && verwaiste.length === 0 ? (
<div className="muted small">
Auf diesem Server ist kein Anmeldedienst eingerichtet. Die Anmeldung
läuft über Benutzername und Passwort.
</div>
) : (
<>
<p className="muted small mt-0">
Verknüpfe dein Konto mit einem Anmeldedienst, um dich künftig
darüber anzumelden. Der Passwort-Login bleibt daneben bestehen.
</p>
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Dienst</th><th>Verknüpft seit</th><th>Zuletzt genutzt</th><th></th></tr>
</thead>
<tbody>
{anbieter.map((a) => {
const v = verknuepftMit(a.slug);
return (
<tr key={a.slug}>
<td data-label="Dienst" className="strong">{a.display_name}</td>
<td data-label="Verknüpft seit">
{v ? datum(v.created_at) : <span className="muted">–</span>}
</td>
<td data-label="Zuletzt genutzt">
{v ? datum(v.last_login_at) : <span className="muted">–</span>}
</td>
<td className="num">
{v ? (
<button className="btn" disabled={busy === a.slug}
onClick={() => loesen(a.slug, a.display_name)}>
Lösen
</button>
) : (
<button className="btn primary" disabled={busy === a.slug}
onClick={() => verknuepfen(a.slug)}>
{busy === a.slug ? "Weiterleiten…" : "Verknüpfen"}
</button>
)}
</td>
</tr>
);
})}
{verwaiste.map((v) => (
<tr key={v.provider}>
<td data-label="Dienst" className="strong">
{v.display_name}
<div className="muted small">am Server nicht mehr eingerichtet</div>
</td>
<td data-label="Verknüpft seit">{datum(v.created_at)}</td>
<td data-label="Zuletzt genutzt">{datum(v.last_login_at)}</td>
<td className="num">
<button className="btn" disabled={busy === v.provider}
onClick={() => loesen(v.provider, v.display_name)}>
Lösen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
);
}
+56 -1
View File
@@ -1,9 +1,25 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import BrandMark from "../components/BrandMark";
// Der Server schickt nur eine Kennung zurueck (`/login#sso_error=…`), keinen
// Klartext – interne Einzelheiten bleiben im Serverprotokoll.
const SSO_FEHLERTEXTE = {
abgebrochen: "Die Anmeldung wurde abgebrochen.",
sitzung_abgelaufen: "Die Anmeldung hat zu lange gedauert. Bitte noch einmal versuchen.",
state_falsch: "Die Antwort des Anmeldedienstes passte nicht zur Anfrage.",
unvollstaendige_antwort: "Der Anmeldedienst hat unvollständig geantwortet.",
anbieter_nicht_erreichbar: "Der Anmeldedienst ist nicht erreichbar.",
kein_konto:
"Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss es " +
"anlegen und die E-Mail-Adresse hinterlegen.",
konto_bereits_verknuepft:
"Dieses Konto ist bereits mit einer anderen Kennung des Anmeldedienstes verknüpft.",
};
export default function Login() {
const { login, abgelaufen } = useAuth();
const navigate = useNavigate();
@@ -11,6 +27,27 @@ export default function Login() {
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const [anbieter, setAnbieter] = useState([]);
// Fehlermeldung aus dem Single-Sign-on-Rueckweg einsammeln und die Adresszeile
// wieder saeubern.
useEffect(() => {
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const kennung = fragment.get("sso_error");
if (!kennung) return;
setError(SSO_FEHLERTEXTE[kennung] || "Die Anmeldung über den Anmeldedienst ist fehlgeschlagen.");
window.history.replaceState(null, "", window.location.pathname);
}, []);
// Ohne eingerichteten Anbieter bleibt die Liste leer und es erscheint kein
// zusaetzlicher Knopf. Ein Fehler hier darf den Passwort-Login nicht stoeren.
useEffect(() => {
let aktuell = true;
api.oidcProviders()
.then((liste) => { if (aktuell) setAnbieter(liste || []); })
.catch(() => {});
return () => { aktuell = false; };
}, []);
async function onSubmit(e) {
e.preventDefault();
@@ -52,6 +89,24 @@ export default function Login() {
<button className="btn primary" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
{busy ? "Anmelden…" : "Anmelden"}
</button>
{anbieter.length > 0 && (
<>
<div className="login-trenner">oder</div>
{anbieter.map((p) => (
// Bewusst ein echter Link und kein fetch: der Wechsel zum
// Anmeldedienst muss eine Seitennavigation des Browsers sein.
<a
key={p.slug}
className="btn"
style={{ width: "100%", justifyContent: "center" }}
href={`/api/auth/oidc/${encodeURIComponent(p.slug)}/login`}
>
Mit {p.display_name} anmelden
</a>
))}
</>
)}
</form>
</div>
);
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
/**
* Rueckweg vom Single-Sign-on: Der Server haengt das fertige Vorrania-Token als
* Fragment an (`/auth/callback#token=…`).
*
* Fragment statt Abfragezeichenkette, weil der Browser Fragmente NICHT an den
* Server schickt – das Token landet damit weder im nginx-Zugriffsprotokoll noch
* in einem Proxy-Log. Direkt nach dem Lesen wird es aus der Adresszeile geraeumt,
* damit es auch nicht im Verlauf stehen bleibt.
*/
export default function OidcCallback() {
const { tokenUebernehmen } = useAuth();
const navigate = useNavigate();
const [fehler, setFehler] = useState(null);
// React 18 ruft Effekte im Entwicklungsmodus doppelt auf – ohne diesen Riegel
// liefe die Uebernahme zweimal, das zweite Mal mit bereits geleertem Fragment.
const erledigt = useRef(false);
useEffect(() => {
if (erledigt.current) return;
erledigt.current = true;
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const token = fragment.get("token");
// Adresszeile sofort saeubern, egal wie es ausgeht.
window.history.replaceState(null, "", window.location.pathname);
if (!token) {
setFehler("Die Anmeldung kam ohne Token zurück.");
return;
}
tokenUebernehmen(token)
.then(() => navigate("/", { replace: true }))
.catch((err) => setFehler(err.message || "Anmeldung fehlgeschlagen."));
}, [tokenUebernehmen, navigate]);
if (fehler) {
return (
<div className="login-wrap">
<div className="card login-card">
<div className="alert error">
<Icon name="alert" size={16} />
{fehler}
</div>
<button className="btn primary" onClick={() => navigate("/login", { replace: true })}>
Zurück zur Anmeldung
</button>
</div>
</div>
);
}
return <div className="center muted" style={{ padding: 60 }}>Anmeldung wird abgeschlossen…</div>;
}
+34 -3
View File
@@ -10,7 +10,7 @@ export default function Users() {
const { user: me } = useAuth();
const toast = useToast();
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ username: "", password: "", role: "user" });
const [form, setForm] = useState({ username: "", password: "", email: "", role: "user" });
const [error, setError] = useState(null);
async function load() {
@@ -28,7 +28,7 @@ export default function Users() {
setError(null);
try {
await api.createUser(form);
setForm({ username: "", password: "", role: "user" });
setForm({ username: "", password: "", email: "", role: "user" });
toast("Benutzer angelegt.");
load();
} catch (err) {
@@ -36,6 +36,21 @@ export default function Users() {
}
}
// Die Adresse ist der Schluessel fuer die erste SSO-Anmeldung: nur ueber sie
// findet ein Anmeldedienst das passende Bestandskonto.
async function changeEmail(u, email) {
if ((u.email || "") === email.trim()) return;
setError(null);
try {
await api.updateUser(u.id, { email: email.trim() });
toast("E-Mail-Adresse gespeichert.");
load();
} catch (err) {
setError(err.message);
load();
}
}
async function changeRole(u, role) {
try {
await api.updateUser(u.id, { role });
@@ -75,13 +90,21 @@ export default function Users() {
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
<thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th></th></tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td data-label="Name" className="strong">
{u.username}{u.id === me.id && <span className="muted"> (du)</span>}
</td>
<td data-label="E-Mail">
<input
type="email"
defaultValue={u.email || ""}
placeholder="für SSO"
onBlur={(e) => changeEmail(u, e.target.value)}
/>
</td>
<td data-label="Rolle">
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
disabled={u.id === me.id}>
@@ -114,6 +137,14 @@ export default function Users() {
<input type="password" value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} />
</label>
<label>
E-Mail (optional)
<input type="email" value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })} />
<span className="muted small">
Nur nötig, damit sich dieses Konto per Single Sign-on anmelden kann.
</span>
</label>
<label>
Rolle
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
+23
View File
@@ -164,6 +164,9 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
}
.sidebar-user { display: flex; flex-direction: column; min-width: 0; }
/* Der Name ist ein Link zum eigenen Konto, soll aber weiter wie Text wirken. */
a.sidebar-user { color: inherit; text-decoration: none; }
a.sidebar-user:hover .name { text-decoration: underline; }
.sidebar-user .name { font-weight: 600; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-user .role { font-size: 0.72rem; color: var(--muted); }
@@ -971,6 +974,26 @@ select.zeitraum:hover { color: var(--text); }
.login-card { width: 100%; max-width: 360px; }
.login-card .sidebar-brand { padding: 0 0 var(--sp-2); font-size: 1.2rem; }
.login-card .lead { color: var(--muted); font-size: 0.9rem; margin: 0 0 var(--sp-5); }
/* Trennt Passwort-Anmeldung und Single-Sign-on: Strich links und rechts vom Wort. */
.login-trenner {
display: flex;
align-items: center;
gap: var(--sp-3);
margin: var(--sp-4) 0 var(--sp-3);
color: var(--muted);
font-size: 0.8rem;
}
.login-trenner::before,
.login-trenner::after {
content: "";
flex: 1;
border-top: 1px solid var(--border);
}
/* Beschreibungsliste (Feld -> Wert), z. B. auf der Kontoseite. */
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-5); margin: 0; }
.kv dt { color: var(--muted); font-size: 0.85rem; }
.kv dd { margin: 0; }
/* ---------- Utilities ---------- */
.muted { color: var(--muted); }