feat(auth): OIDC SSO for admin users — phase 1 (#798)
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.
Backend:
- migration 162: admin_users.auth_provider ('local' default) +
external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
cached discovery, sub-based identity binding — email linking of
existing admins only with email_verified=true; JIT behind
oidc_autoprovision with configurable default role and an unusable
random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
the callback reuses the local login's session establishment
(completeAdminLogin split into establishAdminSession + JSON wrapper)
so SSO sessions are identical downstream; every failure lands on
/admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
write-only, redacted to a set-flag; registered ABOVE the generic
/:type matcher which would shadow them); oidc_client_secret added to
the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
login page
Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
autoprovision + default role, button label, enable toggle, redirect
URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
param surfaced as translated toasts; EN+DE i18n
Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.
MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
This commit is contained in:
@@ -20,3 +20,4 @@ export { ApiTokensTab } from './tabs/ApiTokensTab';
|
||||
export { WebhooksTab } from './tabs/WebhooksTab';
|
||||
export { AccountingTab } from './tabs/AccountingTab';
|
||||
export { WhatsAppTab } from './tabs/WhatsAppTab';
|
||||
export { SsoTab } from './tabs/SsoTab';
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, KeyRound, PlugZap, Copy, Check } from 'lucide-react';
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||
import { ssoService, SsoSettings, UpdateSsoSettings } from '../../../services/sso.service';
|
||||
|
||||
// SSO (OIDC) settings (#798, phase 1). Deliberately lean: issuer + client
|
||||
// credentials, JIT toggle with default role, button label. Role-claim
|
||||
// mapping is a follow-up. The client secret is write-only — the field stays
|
||||
// blank and only overwrites when the admin types a new value.
|
||||
export const SsoTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [form, setForm] = useState<SsoSettings | null>(null);
|
||||
const [newSecret, setNewSecret] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { isLoading } = useQuery({
|
||||
queryKey: ['admin-sso-settings'],
|
||||
queryFn: async () => {
|
||||
const settings = await ssoService.getSettings();
|
||||
setForm((prev) => prev ?? settings);
|
||||
return settings;
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: UpdateSsoSettings) => ssoService.updateSettings(data),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.sso.saved', 'SSO settings saved'));
|
||||
setNewSecret('');
|
||||
setForm(null); // re-init from the fresh GET (secret_set flag updates)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-sso-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(error.response?.data?.error || t('settings.sso.saveError', 'Failed to save SSO settings'));
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => ssoService.testConnection(),
|
||||
onSuccess: (result) => {
|
||||
if (result.ok) {
|
||||
toast.success(t('settings.sso.testOk', 'Discovery succeeded — issuer is reachable: {{issuer}}', { issuer: result.issuer }));
|
||||
} else {
|
||||
toast.error(result.error || t('settings.sso.testFailed', 'Discovery failed'));
|
||||
}
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(error.response?.data?.error || t('settings.sso.testFailed', 'Discovery failed'));
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !form) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const set = <K extends keyof SsoSettings>(key: K, value: SsoSettings[K]) =>
|
||||
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: UpdateSsoSettings = {
|
||||
oidc_enabled: form.oidc_enabled,
|
||||
oidc_issuer_url: form.oidc_issuer_url.trim(),
|
||||
oidc_client_id: form.oidc_client_id.trim(),
|
||||
oidc_autoprovision: form.oidc_autoprovision,
|
||||
oidc_default_role: form.oidc_default_role,
|
||||
oidc_button_label: form.oidc_button_label.trim(),
|
||||
oidc_scopes: form.oidc_scopes.trim(),
|
||||
};
|
||||
if (newSecret.trim()) payload.oidc_client_secret = newSecret.trim();
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const copyRedirectUri = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(form.redirect_uri);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard unavailable — the URI is visible to copy by hand.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-neutral-500" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.sso.title', 'Single Sign-On (OIDC)')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('settings.sso.intro', 'Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.')}
|
||||
</p>
|
||||
|
||||
{/* Redirect URI for the IdP client registration */}
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 p-3">
|
||||
<p className="text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{t('settings.sso.redirectUri', 'Redirect URI (register this on your IdP client)')}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded bg-neutral-900 px-3 py-2 font-mono text-xs text-neutral-100">
|
||||
{form.redirect_uri}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyRedirectUri}
|
||||
className="flex-shrink-0 rounded-md border border-neutral-200 dark:border-neutral-600 bg-white dark:bg-neutral-700 p-2 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 transition-colors"
|
||||
aria-label={t('common.copy', 'Copy')}
|
||||
title={t('common.copy', 'Copy')}
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label={t('settings.sso.issuerUrl', 'Issuer URL')}
|
||||
placeholder="https://id.example.com/realms/main"
|
||||
value={form.oidc_issuer_url}
|
||||
onChange={(e) => set('oidc_issuer_url', e.target.value)}
|
||||
/>
|
||||
<p className="-mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.sso.issuerHint', 'The base URL that serves /.well-known/openid-configuration.')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('settings.sso.clientId', 'Client ID')}
|
||||
value={form.oidc_client_id}
|
||||
onChange={(e) => set('oidc_client_id', e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label={t('settings.sso.clientSecret', 'Client Secret')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={form.oidc_client_secret_set
|
||||
? t('settings.sso.secretSetPlaceholder', '•••••• (saved — type to replace)')
|
||||
: t('settings.sso.secretUnsetPlaceholder', 'Paste the client secret')}
|
||||
value={newSecret}
|
||||
onChange={(e) => setNewSecret(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.sso.secretHint', 'Stored encrypted; never shown again. Leave blank to keep the current one.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label={t('settings.sso.scopes', 'Scopes')}
|
||||
value={form.oidc_scopes}
|
||||
onChange={(e) => set('oidc_scopes', e.target.value)}
|
||||
/>
|
||||
|
||||
<label className="flex items-start gap-3 pt-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||
checked={form.oidc_autoprovision}
|
||||
onChange={(e) => set('oidc_autoprovision', e.target.checked)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
{t('settings.sso.autoprovision', 'Auto-provision unknown users')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.sso.autoprovisionHint', 'Create an admin account on first SSO login. Off: only existing/linked admins can sign in.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{form.oidc_autoprovision && (
|
||||
<div className="max-w-xs">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.sso.defaultRole', 'Role for new users')}
|
||||
</label>
|
||||
<select
|
||||
value={form.oidc_default_role}
|
||||
onChange={(e) => set('oidc_default_role', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="viewer">{t('users.roles.viewer', 'Viewer')}</option>
|
||||
<option value="editor">{t('users.roles.editor', 'Editor')}</option>
|
||||
<option value="admin">{t('users.roles.admin', 'Admin')}</option>
|
||||
<option value="super_admin">{t('users.roles.super_admin', 'Super Admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t('settings.sso.buttonLabel', 'Login button label (optional)')}
|
||||
placeholder={t('settings.sso.buttonLabelPlaceholder', 'Sign in with SSO')}
|
||||
value={form.oidc_button_label}
|
||||
onChange={(e) => set('oidc_button_label', e.target.value)}
|
||||
/>
|
||||
|
||||
<label className="flex items-start gap-3 pt-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||
checked={form.oidc_enabled}
|
||||
onChange={(e) => set('oidc_enabled', e.target.checked)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
{t('settings.sso.enabled', 'Enable SSO login')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.sso.enabledHint', 'Shows the SSO button on the admin login page. Requires issuer, client ID and secret.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
isLoading={saveMutation.isPending}
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<PlugZap className="w-4 h-4" />}
|
||||
onClick={() => testMutation.mutate()}
|
||||
isLoading={testMutation.isPending}
|
||||
>
|
||||
{t('settings.sso.test', 'Test connection')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.sso.testHint', 'Test runs OIDC discovery against the saved configuration — save first.')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SsoTab.displayName = 'SsoTab';
|
||||
@@ -2090,6 +2090,32 @@
|
||||
"enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.",
|
||||
"disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.",
|
||||
"regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut."
|
||||
},
|
||||
"sso": {
|
||||
"title": "Single Sign-On (OIDC)",
|
||||
"intro": "Admins melden sich über Ihren Identity Provider an (Keycloak, Authentik, Pocket ID oder jeder OIDC-konforme IdP). Die lokale Anmeldung mit E-Mail/Passwort bleibt als Fallback verfügbar.",
|
||||
"redirectUri": "Redirect-URI (beim IdP-Client registrieren)",
|
||||
"issuerUrl": "Issuer-URL",
|
||||
"issuerHint": "Die Basis-URL, die /.well-known/openid-configuration ausliefert.",
|
||||
"clientId": "Client-ID",
|
||||
"clientSecret": "Client-Secret",
|
||||
"secretSetPlaceholder": "•••••• (gespeichert — zum Ersetzen tippen)",
|
||||
"secretUnsetPlaceholder": "Client-Secret einfügen",
|
||||
"secretHint": "Verschlüsselt gespeichert; wird nie wieder angezeigt. Leer lassen, um das aktuelle zu behalten.",
|
||||
"scopes": "Scopes",
|
||||
"autoprovision": "Unbekannte Benutzer automatisch anlegen",
|
||||
"autoprovisionHint": "Erstellt beim ersten SSO-Login ein Admin-Konto. Aus: Nur bestehende/verknüpfte Admins können sich anmelden.",
|
||||
"defaultRole": "Rolle für neue Benutzer",
|
||||
"buttonLabel": "Beschriftung des Login-Buttons (optional)",
|
||||
"buttonLabelPlaceholder": "Mit SSO anmelden",
|
||||
"enabled": "SSO-Login aktivieren",
|
||||
"enabledHint": "Zeigt den SSO-Button auf der Admin-Anmeldeseite. Erfordert Issuer, Client-ID und Secret.",
|
||||
"test": "Verbindung testen",
|
||||
"testHint": "Der Test führt OIDC-Discovery gegen die gespeicherte Konfiguration aus — zuerst speichern.",
|
||||
"testOk": "Discovery erfolgreich — Issuer ist erreichbar: {{issuer}}",
|
||||
"testFailed": "Discovery fehlgeschlagen",
|
||||
"saved": "SSO-Einstellungen gespeichert",
|
||||
"saveError": "SSO-Einstellungen konnten nicht gespeichert werden"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
@@ -3706,6 +3732,16 @@
|
||||
"sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.",
|
||||
"locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.",
|
||||
"lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut."
|
||||
},
|
||||
"ssoDivider": "oder",
|
||||
"ssoSignIn": "Mit SSO anmelden",
|
||||
"ssoErrors": {
|
||||
"config": "SSO ist falsch konfiguriert — prüfen Sie die SSO-Einstellungen oder melden Sie sich mit E-Mail und Passwort an.",
|
||||
"state": "Die SSO-Anmeldung ist abgelaufen oder wurde manipuliert. Bitte erneut versuchen.",
|
||||
"idp": "Der Identity Provider hat die Anmeldung abgelehnt. Bitte erneut versuchen oder E-Mail und Passwort verwenden.",
|
||||
"inactive": "Ihr Admin-Konto ist deaktiviert.",
|
||||
"not_provisioned": "Kein Admin-Konto passt zu Ihrer SSO-Identität. Bitten Sie einen Administrator um eine Einladung.",
|
||||
"no_email": "Ihr Identity Provider hat keine E-Mail-Adresse geliefert — es kann kein Konto erstellt werden."
|
||||
}
|
||||
},
|
||||
"cssTemplates": {
|
||||
|
||||
@@ -1637,6 +1637,32 @@
|
||||
"enableFailed": "Could not enable two-factor authentication. Check the code and try again.",
|
||||
"disableFailed": "Could not disable two-factor authentication. Check the code and try again.",
|
||||
"regenerateFailed": "Could not regenerate recovery codes. Check the code and try again."
|
||||
},
|
||||
"sso": {
|
||||
"title": "Single Sign-On (OIDC)",
|
||||
"intro": "Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.",
|
||||
"redirectUri": "Redirect URI (register this on your IdP client)",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"issuerHint": "The base URL that serves /.well-known/openid-configuration.",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretSetPlaceholder": "•••••• (saved — type to replace)",
|
||||
"secretUnsetPlaceholder": "Paste the client secret",
|
||||
"secretHint": "Stored encrypted; never shown again. Leave blank to keep the current one.",
|
||||
"scopes": "Scopes",
|
||||
"autoprovision": "Auto-provision unknown users",
|
||||
"autoprovisionHint": "Create an admin account on first SSO login. Off: only existing/linked admins can sign in.",
|
||||
"defaultRole": "Role for new users",
|
||||
"buttonLabel": "Login button label (optional)",
|
||||
"buttonLabelPlaceholder": "Sign in with SSO",
|
||||
"enabled": "Enable SSO login",
|
||||
"enabledHint": "Shows the SSO button on the admin login page. Requires issuer, client ID and secret.",
|
||||
"test": "Test connection",
|
||||
"testHint": "Test runs OIDC discovery against the saved configuration — save first.",
|
||||
"testOk": "Discovery succeeded — issuer is reachable: {{issuer}}",
|
||||
"testFailed": "Discovery failed",
|
||||
"saved": "SSO settings saved",
|
||||
"saveError": "Failed to save SSO settings"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -3595,6 +3621,16 @@
|
||||
"sessionExpired": "Your verification session expired. Please sign in again.",
|
||||
"locked": "Account temporarily locked due to too many attempts. Try again later.",
|
||||
"lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds."
|
||||
},
|
||||
"ssoDivider": "or",
|
||||
"ssoSignIn": "Sign in with SSO",
|
||||
"ssoErrors": {
|
||||
"config": "SSO is misconfigured — check the SSO settings or sign in with email and password.",
|
||||
"state": "The SSO sign-in expired or was tampered with. Please try again.",
|
||||
"idp": "The identity provider rejected the sign-in. Please try again or use email and password.",
|
||||
"inactive": "Your admin account is deactivated.",
|
||||
"not_provisioned": "No admin account matches your SSO identity. Ask an administrator to invite you.",
|
||||
"no_email": "Your identity provider supplied no email address — an account cannot be created."
|
||||
}
|
||||
},
|
||||
"slideshow": {
|
||||
|
||||
@@ -61,6 +61,16 @@ export const AdminLoginPage: React.FC = () => {
|
||||
}
|
||||
}, [searchParams, t]);
|
||||
|
||||
// SSO callback failures land here as ?sso_error=<key> (#798) — surface a
|
||||
// translated message instead of a silent bounce back to the form.
|
||||
useEffect(() => {
|
||||
const ssoError = searchParams.get('sso_error');
|
||||
if (!ssoError) return;
|
||||
const known = ['config', 'state', 'idp', 'inactive', 'not_provisioned', 'no_email'];
|
||||
const key = known.includes(ssoError) ? ssoError : 'idp';
|
||||
toast.error(t(`adminLogin.ssoErrors.${key}`));
|
||||
}, [searchParams, t]);
|
||||
|
||||
// Fresh instance with no admin yet → send to first-run setup.
|
||||
const { data: setupStatus } = useQuery({
|
||||
queryKey: ['setup-status'],
|
||||
@@ -337,6 +347,31 @@ export const AdminLoginPage: React.FC = () => {
|
||||
>
|
||||
{t('adminLogin.signIn')}
|
||||
</Button>
|
||||
|
||||
{/* SSO (#798): plain navigation — the backend route redirects to
|
||||
the IdP; the callback sets the same admin cookie as the local
|
||||
login and lands on the dashboard. */}
|
||||
{settingsData?.oidc_enabled === true && (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-neutral-200" />
|
||||
<span className="text-xs uppercase tracking-wide text-neutral-400">
|
||||
{t('adminLogin.ssoDivider', 'or')}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-neutral-200" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
leftIcon={<KeyRound className="w-4 h-4" />}
|
||||
onClick={() => { window.location.href = '/api/auth/admin/sso/login'; }}
|
||||
>
|
||||
{settingsData.oidc_button_label?.trim() || t('adminLogin.ssoSignIn', 'Sign in with SSO')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleMfaSubmit} className="space-y-6">
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
WebhooksTab,
|
||||
AccountingTab,
|
||||
WhatsAppTab,
|
||||
SsoTab,
|
||||
} from '../../features/settings';
|
||||
import { EmailConfigPage } from './EmailConfigPage';
|
||||
import { BrandingPage } from './BrandingPage';
|
||||
@@ -72,6 +73,7 @@ type TabType =
|
||||
| 'email'
|
||||
| 'moderation'
|
||||
| 'security'
|
||||
| 'sso'
|
||||
| 'imageSecurity'
|
||||
| 'seo'
|
||||
| 'apiTokens'
|
||||
@@ -104,7 +106,7 @@ const ALL_TAB_KEYS: TabType[] = [
|
||||
'features', 'general', 'events', 'eventTypes',
|
||||
'branding', 'categories', 'thumbnails', 'styling', 'cms',
|
||||
'email', 'moderation',
|
||||
'security', 'imageSecurity', 'seo',
|
||||
'security', 'sso', 'imageSecurity', 'seo',
|
||||
'apiTokens', 'webhooks',
|
||||
'status', 'analytics', 'backup',
|
||||
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp',
|
||||
@@ -260,6 +262,7 @@ export const SettingsPage: React.FC = () => {
|
||||
label: t('settings.groups.privacySecurity', 'Privacy & Security'),
|
||||
items: [
|
||||
{ key: 'security', label: t('settings.security.title'), icon: Lock },
|
||||
{ key: 'sso', label: t('settings.sso.title', 'Single Sign-On'), icon: KeyRound },
|
||||
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection'), icon: Shield },
|
||||
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots'), icon: Search },
|
||||
],
|
||||
@@ -472,6 +475,8 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'sso' && <SsoTab />}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<SecurityTab
|
||||
securitySettings={securitySettings}
|
||||
|
||||
@@ -96,6 +96,9 @@ export interface PublicSettings {
|
||||
seo_meta_noindex?: boolean;
|
||||
seo_meta_nofollow?: boolean;
|
||||
seo_meta_noai?: boolean;
|
||||
// OIDC SSO (#798) — drives the "Sign in with SSO" button on /admin/login.
|
||||
oidc_enabled?: boolean;
|
||||
oidc_button_label?: string;
|
||||
}
|
||||
|
||||
export const publicSettingsService = {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* SSO (OIDC) Settings Service (#798)
|
||||
* API client for the dedicated /admin/settings/sso endpoints — the client
|
||||
* secret is write-only (never returned; `oidc_client_secret_set` flags it).
|
||||
*/
|
||||
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface SsoSettings {
|
||||
oidc_enabled: boolean;
|
||||
oidc_issuer_url: string;
|
||||
oidc_client_id: string;
|
||||
oidc_client_secret_set: boolean;
|
||||
oidc_autoprovision: boolean;
|
||||
oidc_default_role: string;
|
||||
oidc_button_label: string;
|
||||
oidc_scopes: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export interface UpdateSsoSettings {
|
||||
oidc_enabled?: boolean;
|
||||
oidc_issuer_url?: string;
|
||||
oidc_client_id?: string;
|
||||
/** Only sent when the admin typed a new one; empty keeps the stored secret. */
|
||||
oidc_client_secret?: string;
|
||||
oidc_autoprovision?: boolean;
|
||||
oidc_default_role?: string;
|
||||
oidc_button_label?: string;
|
||||
oidc_scopes?: string;
|
||||
}
|
||||
|
||||
export interface SsoTestResult {
|
||||
ok: boolean;
|
||||
issuer?: string;
|
||||
authorization_endpoint?: string;
|
||||
token_endpoint?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const ssoService = {
|
||||
async getSettings(): Promise<SsoSettings> {
|
||||
const response = await api.get<SsoSettings>('/admin/settings/sso');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateSettings(data: UpdateSsoSettings): Promise<void> {
|
||||
await api.put('/admin/settings/sso', data);
|
||||
},
|
||||
|
||||
// Server-side discovery probe against the SAVED config.
|
||||
async testConnection(): Promise<SsoTestResult> {
|
||||
const response = await api.post<SsoTestResult>('/admin/settings/sso/test');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user