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:
co-authored by
Claude Opus 5
parent
8d4954610b
commit
3d0ff54bf5
+11
-2
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 })}>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user