INSIGHT-MVP/packages/frontend/src/admin/AdminSsoPage.tsx
Thomas Reitz bc156e1657 feat: dynamic SSO configuration via Admin UI
SSO config (Tenant ID, Client ID, Client Secret, Redirect URI) can now
be managed entirely from the Admin SSO page. Config is stored in Redis
(persistent) and the MSAL client is reinitialized on save — no server
restart or console access required.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:51:01 +01:00

817 lines
25 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
interface SsoStatus {
microsoft: boolean;
}
interface SsoConfigResponse {
configured: boolean;
config: {
tenantId: string;
clientId: string;
redirectUri: string;
clientSecretMasked: string;
} | null;
}
const cardStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-sm)',
border: '1px solid var(--color-border)',
padding: '1.5rem',
marginBottom: '1.5rem',
};
const inlineCodeStyle: React.CSSProperties = {
background: 'var(--color-bg)',
padding: '0.125rem 0.375rem',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
color: '#1e40af',
};
const stepStyle: React.CSSProperties = {
display: 'flex',
gap: '1rem',
marginBottom: '1.5rem',
};
const stepNumberStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--color-primary)',
color: 'white',
fontSize: '0.875rem',
fontWeight: 700,
flexShrink: 0,
};
const stepContentStyle: React.CSSProperties = {
flex: 1,
paddingTop: '0.25rem',
};
const h3Style: React.CSSProperties = {
fontSize: '1rem',
fontWeight: 600,
color: 'var(--color-text)',
marginBottom: '0.5rem',
};
const pStyle: React.CSSProperties = {
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
lineHeight: 1.6,
marginBottom: '0.5rem',
};
const tableStyle: React.CSSProperties = {
width: '100%',
borderCollapse: 'collapse',
marginTop: '0.75rem',
};
const thStyle: React.CSSProperties = {
padding: '0.625rem 0.75rem',
textAlign: 'left',
fontSize: '0.75rem',
textTransform: 'uppercase',
color: 'var(--color-text-muted)',
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-bg)',
};
const tdStyle: React.CSSProperties = {
padding: '0.625rem 0.75rem',
fontSize: '0.8125rem',
borderBottom: '1px solid var(--color-border)',
verticalAlign: 'top',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.625rem 0.75rem',
fontSize: '0.875rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg-card)',
color: 'var(--color-text)',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
outline: 'none',
boxSizing: 'border-box' as const,
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 600,
color: 'var(--color-text)',
marginBottom: '0.375rem',
};
const hintStyle: React.CSSProperties = {
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
marginTop: '0.25rem',
};
export function AdminSsoPage() {
const queryClient = useQueryClient();
const { data: ssoStatus, isLoading: statusLoading } = useQuery<SsoStatus>({
queryKey: ['sso', 'status'],
queryFn: async () => {
const res = await api.get<SsoStatus>('/auth/sso/status');
return res.data;
},
});
const { data: ssoConfig, isLoading: configLoading } =
useQuery<SsoConfigResponse>({
queryKey: ['sso', 'config'],
queryFn: async () => {
const res = await api.get<SsoConfigResponse>('/auth/sso/config');
return res.data;
},
});
// Formular-State
const [tenantId, setTenantId] = useState('');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [redirectUri, setRedirectUri] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
// Default Redirect-URI
const defaultRedirectUri = `${window.location.origin}/api/v1/auth/sso/microsoft/callback`;
// Formular mit bestehenden Werten befuellen
useEffect(() => {
if (ssoConfig?.config) {
setTenantId(ssoConfig.config.tenantId);
setClientId(ssoConfig.config.clientId);
setRedirectUri(ssoConfig.config.redirectUri);
// Secret bleibt leer — wird nur bei Aenderung gesendet
} else {
// Default Redirect-URI setzen wenn noch keine Config da
setRedirectUri(defaultRedirectUri);
}
}, [ssoConfig, defaultRedirectUri]);
const saveMutation = useMutation({
mutationFn: async (data: {
tenantId: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
}) => {
const res = await api.post('/auth/sso/config', data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sso', 'status'] });
queryClient.invalidateQueries({ queryKey: ['sso', 'config'] });
setClientSecret(''); // Secret-Feld leeren
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 4000);
},
});
const handleSave = () => {
const data: {
tenantId: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
} = {
tenantId,
clientId,
redirectUri,
};
// Secret nur mitsenden wenn es geaendert wurde
if (clientSecret) {
data.clientSecret = clientSecret;
}
saveMutation.mutate(data);
};
const isLoading = statusLoading || configLoading;
const hasExistingConfig = !!ssoConfig?.config;
const canSave =
tenantId.trim() &&
clientId.trim() &&
redirectUri.trim() &&
(clientSecret.trim() || hasExistingConfig); // Secret nur noetig bei Erstconfig
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>
SSO-Konfiguration
</h1>
</div>
{/* Status-Anzeige */}
<div style={cardStyle}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '0.5rem',
}}
>
<svg
width="24"
height="24"
viewBox="0 0 21 21"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
</svg>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600 }}>
Microsoft Entra ID (Azure AD)
</h2>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm)',
background: ssoStatus?.microsoft ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${ssoStatus?.microsoft ? '#bbf7d0' : '#fecaca'}`,
marginTop: '0.75rem',
}}
>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: ssoStatus?.microsoft
? 'var(--color-success)'
: 'var(--color-error)',
}}
/>
<span
style={{
fontSize: '0.875rem',
fontWeight: 500,
color: ssoStatus?.microsoft ? '#166534' : '#991b1b',
}}
>
{isLoading
? 'Status wird geladen...'
: ssoStatus?.microsoft
? 'SSO ist aktiv — Benutzer können sich mit Microsoft anmelden'
: 'SSO ist nicht konfiguriert — folge der Anleitung unten'}
</span>
</div>
</div>
{/* Einrichtungs-Anleitung */}
<div style={cardStyle}>
<h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1.25rem',
}}
>
Einrichtungsanleitung
</h2>
{/* Schritt 1 */}
<div style={stepStyle}>
<div style={stepNumberStyle}>1</div>
<div style={stepContentStyle}>
<h3 style={h3Style}>App Registration im Azure Portal anlegen</h3>
<p style={pStyle}>
Gehe zum{' '}
<a
href="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-primary)',
textDecoration: 'underline',
}}
>
Azure Portal &rarr; App registrations
</a>{' '}
und klicke auf <strong>New registration</strong>.
</p>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}>Feld</th>
<th style={thStyle}>Wert</th>
</tr>
</thead>
<tbody>
<tr>
<td style={tdStyle}>
<strong>Name</strong>
</td>
<td style={tdStyle}>INSIGHT Platform</td>
</tr>
<tr>
<td style={tdStyle}>
<strong>Supported account types</strong>
</td>
<td style={tdStyle}>
Accounts in this organizational directory only
<br />
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
}}
>
(Single tenant nur euer Azure AD)
</span>
</td>
</tr>
<tr>
<td style={tdStyle}>
<strong>Redirect URI</strong>
</td>
<td style={tdStyle}>
Platform: <strong>Web</strong>
<br />
URI:{' '}
<code style={inlineCodeStyle}>{defaultRedirectUri}</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Schritt 2 */}
<div style={stepStyle}>
<div style={stepNumberStyle}>2</div>
<div style={stepContentStyle}>
<h3 style={h3Style}>Client Secret erstellen</h3>
<p style={pStyle}>
In der App Registration &rarr;{' '}
<strong>Certificates &amp; secrets</strong> &rarr;{' '}
<strong>New client secret</strong>.
</p>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}>Feld</th>
<th style={thStyle}>Wert</th>
</tr>
</thead>
<tbody>
<tr>
<td style={tdStyle}>
<strong>Description</strong>
</td>
<td style={tdStyle}>INSIGHT Platform SSO</td>
</tr>
<tr>
<td style={tdStyle}>
<strong>Expires</strong>
</td>
<td style={tdStyle}>
24 months (empfohlen)
<br />
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
}}
>
Danach muss das Secret erneuert werden
</span>
</td>
</tr>
</tbody>
</table>
<div
style={{
marginTop: '0.75rem',
padding: '0.625rem 0.75rem',
background: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
color: '#92400e',
}}
>
<strong>Wichtig:</strong> Den <strong>Value</strong> des Secrets
sofort kopieren er wird nur einmalig angezeigt!
</div>
</div>
</div>
{/* Schritt 3 */}
<div style={stepStyle}>
<div style={stepNumberStyle}>3</div>
<div style={stepContentStyle}>
<h3 style={h3Style}>API Permissions konfigurieren</h3>
<p style={pStyle}>
In der App Registration &rarr;{' '}
<strong>API permissions</strong> &rarr;{' '}
<strong>Add a permission</strong> &rarr;{' '}
<strong>Microsoft Graph</strong> &rarr;{' '}
<strong>Delegated permissions</strong>.
</p>
<p style={pStyle}>Folgende Berechtigungen hinzufügen:</p>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}>Permission</th>
<th style={thStyle}>Typ</th>
<th style={thStyle}>Beschreibung</th>
</tr>
</thead>
<tbody>
<tr>
<td style={tdStyle}>
<code style={inlineCodeStyle}>openid</code>
</td>
<td style={tdStyle}>Delegated</td>
<td style={tdStyle}>Sign users in</td>
</tr>
<tr>
<td style={tdStyle}>
<code style={inlineCodeStyle}>profile</code>
</td>
<td style={tdStyle}>Delegated</td>
<td style={tdStyle}>View users' basic profile</td>
</tr>
<tr>
<td style={tdStyle}>
<code style={inlineCodeStyle}>email</code>
</td>
<td style={tdStyle}>Delegated</td>
<td style={tdStyle}>View users' email address</td>
</tr>
<tr>
<td style={tdStyle}>
<code style={inlineCodeStyle}>User.Read</code>
</td>
<td style={tdStyle}>Delegated</td>
<td style={tdStyle}>Sign in and read user profile</td>
</tr>
</tbody>
</table>
<p style={{ ...pStyle, marginTop: '0.5rem' }}>
Dann <strong>Grant admin consent</strong> klicken, um die
Berechtigungen für alle Benutzer freizugeben.
</p>
</div>
</div>
{/* Schritt 4 — Konfiguration eingeben und speichern */}
<div style={stepStyle}>
<div style={stepNumberStyle}>4</div>
<div style={stepContentStyle}>
<h3 style={h3Style}>
Werte aus Azure Portal hier eintragen und speichern
</h3>
<p style={pStyle}>
Kopiere die Werte aus der App Registration &rarr;{' '}
<strong>Overview</strong> und trage sie in die Felder ein. Beim
Speichern wird die SSO-Verbindung automatisch aktiviert.
</p>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
marginTop: '1rem',
}}
>
{/* Tenant ID */}
<div>
<label style={labelStyle}>
Directory (Tenant) ID{' '}
<span style={{ color: 'var(--color-error)' }}>*</span>
</label>
<input
type="text"
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
style={inputStyle}
/>
<div style={hintStyle}>
Azure Portal &rarr; App Registration &rarr; Overview &rarr;
Directory (tenant) ID
</div>
</div>
{/* Client ID */}
<div>
<label style={labelStyle}>
Application (Client) ID{' '}
<span style={{ color: 'var(--color-error)' }}>*</span>
</label>
<input
type="text"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
style={inputStyle}
/>
<div style={hintStyle}>
Azure Portal &rarr; App Registration &rarr; Overview &rarr;
Application (client) ID
</div>
</div>
{/* Client Secret */}
<div>
<label style={labelStyle}>
Client Secret{' '}
{!hasExistingConfig && (
<span style={{ color: 'var(--color-error)' }}>*</span>
)}
</label>
<input
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
hasExistingConfig
? `Gespeichert (${ssoConfig?.config?.clientSecretMasked}) — leer lassen um beizubehalten`
: 'Client Secret Value eingeben'
}
style={inputStyle}
/>
<div style={hintStyle}>
Azure Portal &rarr; App Registration &rarr; Certificates &amp;
secrets &rarr; Client secret Value
{hasExistingConfig && (
<span style={{ color: 'var(--color-text-secondary)' }}>
{' '}
Leer lassen, um das bestehende Secret zu behalten
</span>
)}
</div>
</div>
{/* Redirect URI */}
<div>
<label style={labelStyle}>
Redirect URI{' '}
<span style={{ color: 'var(--color-error)' }}>*</span>
</label>
<input
type="text"
value={redirectUri}
onChange={(e) => setRedirectUri(e.target.value)}
placeholder={defaultRedirectUri}
style={inputStyle}
/>
<div style={hintStyle}>
Muss exakt mit der Redirect URI in der Azure App Registration
übereinstimmen
</div>
</div>
{/* Speichern-Button + Status */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginTop: '0.5rem',
}}
>
<button
onClick={handleSave}
disabled={!canSave || saveMutation.isPending}
style={{
padding: '0.625rem 1.5rem',
background: canSave ? 'var(--color-primary)' : '#94a3b8',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: canSave ? 'pointer' : 'not-allowed',
opacity: saveMutation.isPending ? 0.7 : 1,
}}
>
{saveMutation.isPending
? 'Wird gespeichert...'
: 'Speichern und aktivieren'}
</button>
{saveSuccess && (
<span
style={{
fontSize: '0.875rem',
color: '#166534',
fontWeight: 500,
}}
>
SSO-Konfiguration gespeichert und aktiviert!
</span>
)}
{saveMutation.isError && (
<span
style={{
fontSize: '0.875rem',
color: 'var(--color-error)',
fontWeight: 500,
}}
>
Fehler:{' '}
{(saveMutation.error as Error)?.message ||
'Konfiguration konnte nicht gespeichert werden'}
</span>
)}
</div>
</div>
</div>
</div>
</div>
{/* Funktionsweise */}
<div style={cardStyle}>
<h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1rem',
}}
>
Funktionsweise
</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1rem',
}}
>
<div
style={{
padding: '1rem',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
}}
>
<h3 style={{ ...h3Style, fontSize: '0.875rem' }}>Erstanmeldung</h3>
<p
style={{ ...pStyle, fontSize: '0.8125rem', marginBottom: 0 }}
>
Wenn ein Benutzer sich zum ersten Mal mit Microsoft anmeldet,
wird automatisch ein Konto angelegt (Rolle: Benutzer). Existiert
bereits ein Konto mit derselben E-Mail-Adresse, wird das
Microsoft-Konto automatisch verknüpft.
</p>
</div>
<div
style={{
padding: '1rem',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
}}
>
<h3 style={{ ...h3Style, fontSize: '0.875rem' }}>Sicherheit</h3>
<p
style={{ ...pStyle, fontSize: '0.8125rem', marginBottom: 0 }}
>
Der SSO-Flow nutzt den OAuth2 Authorization Code Flow. Das Client
Secret verlässt nie den Server. CSRF-Schutz via State-Parameter.
Die Multi-Faktor-Authentifizierung (MFA) von Microsoft wird
unterstützt.
</p>
</div>
</div>
</div>
{/* Technische Details */}
<div style={cardStyle}>
<h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1rem',
}}
>
Technische Referenz
</h2>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}>Endpoint</th>
<th style={thStyle}>Beschreibung</th>
</tr>
</thead>
<tbody>
<tr>
<td style={tdStyle}>
<code style={inlineCodeStyle}>
GET /api/v1/auth/sso/microsoft
</code>
</td>
<td style={tdStyle}>
Startet den SSO-Flow (Redirect zu Microsoft)
</td>
</tr>
<tr>
<td style={tdStyle}>
<code style={inlineCodeStyle}>
GET /api/v1/auth/sso/microsoft/callback
</code>
</td>
<td style={tdStyle}>
Callback von Microsoft (Token-Exchange + User-Provisioning)
</td>
</tr>
<tr>
<td style={tdStyle}>
<code style={inlineCodeStyle}>
GET /api/v1/auth/sso/status
</code>
</td>
<td style={tdStyle}>
Prüft ob SSO konfiguriert ist (für Login-Seite)
</td>
</tr>
</tbody>
</table>
<h3
style={{ ...h3Style, marginTop: '1.25rem', fontSize: '0.9375rem' }}
>
Redirect URI
</h3>
<p style={pStyle}>
Diese URI muss exakt so in der Azure App Registration eingetragen
sein:
</p>
<div
style={{
background: '#1e293b',
color: '#e2e8f0',
borderRadius: 'var(--radius-sm)',
padding: '1rem 1.25rem',
fontSize: '0.8125rem',
fontFamily:
"'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
lineHeight: 1.7,
overflowX: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
marginTop: '0.75rem',
}}
>
<span>{defaultRedirectUri}</span>
<button
onClick={() => navigator.clipboard.writeText(defaultRedirectUri)}
style={{
padding: '0.25rem 0.5rem',
background: '#334155',
color: '#e2e8f0',
border: '1px solid #475569',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
Kopieren
</button>
</div>
</div>
</div>
);
}