343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { getOIDCConfig, updateOIDCConfig } from '../api';
|
|
import { OIDCConfig } from '../types';
|
|
import './AdminOIDCPage.css';
|
|
|
|
function AdminOIDCPage() {
|
|
const { user, loading: authLoading } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const [config, setConfig] = useState<OIDCConfig | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
|
|
// Form state
|
|
const [enabled, setEnabled] = useState(false);
|
|
const [issuerUrl, setIssuerUrl] = useState('');
|
|
const [clientId, setClientId] = useState('');
|
|
const [clientSecret, setClientSecret] = useState('');
|
|
const [scopes, setScopes] = useState('openid profile email');
|
|
const [autoCreateUsers, setAutoCreateUsers] = useState(true);
|
|
const [adminGroup, setAdminGroup] = useState('');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
navigate('/login', { state: { from: '/admin/oidc' } });
|
|
}
|
|
}, [user, authLoading, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (user && user.is_admin) {
|
|
loadConfig();
|
|
}
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
if (successMessage) {
|
|
const timer = setTimeout(() => setSuccessMessage(null), 3000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [successMessage]);
|
|
|
|
async function loadConfig() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await getOIDCConfig();
|
|
setConfig(data);
|
|
setEnabled(data.enabled);
|
|
setIssuerUrl(data.issuer_url);
|
|
setClientId(data.client_id);
|
|
setScopes(data.scopes.join(' '));
|
|
setAutoCreateUsers(data.auto_create_users);
|
|
setAdminGroup(data.admin_group);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load OIDC configuration');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
|
|
if (enabled && !issuerUrl.trim()) {
|
|
setError('Issuer URL is required when OIDC is enabled');
|
|
return;
|
|
}
|
|
if (enabled && !clientId.trim()) {
|
|
setError('Client ID is required when OIDC is enabled');
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const scopesList = scopes.split(/\s+/).filter(s => s.length > 0);
|
|
const updateData: Record<string, unknown> = {
|
|
enabled,
|
|
issuer_url: issuerUrl.trim(),
|
|
client_id: clientId.trim(),
|
|
scopes: scopesList,
|
|
auto_create_users: autoCreateUsers,
|
|
admin_group: adminGroup.trim(),
|
|
};
|
|
|
|
if (clientSecret) {
|
|
updateData.client_secret = clientSecret;
|
|
}
|
|
|
|
await updateOIDCConfig(updateData);
|
|
setSuccessMessage('OIDC configuration saved successfully');
|
|
setClientSecret('');
|
|
await loadConfig();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save OIDC configuration');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="admin-oidc-page">
|
|
<div className="admin-oidc-loading">
|
|
<div className="admin-oidc-spinner"></div>
|
|
<span>Loading...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
if (!user.is_admin) {
|
|
return (
|
|
<div className="admin-oidc-page">
|
|
<div className="admin-oidc-access-denied">
|
|
<div className="admin-oidc-access-denied-icon">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
|
</svg>
|
|
</div>
|
|
<h2>Access Denied</h2>
|
|
<p>You do not have permission to access this page. Admin privileges are required.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="admin-oidc-page">
|
|
<div className="admin-oidc-header">
|
|
<div className="admin-oidc-header-content">
|
|
<h1>Single Sign-On (OIDC)</h1>
|
|
<p className="admin-oidc-subtitle">
|
|
Configure OpenID Connect for SSO authentication
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{successMessage && (
|
|
<div className="admin-oidc-success">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
|
</svg>
|
|
<span>{successMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="admin-oidc-error">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
</svg>
|
|
<span>{error}</span>
|
|
<button onClick={() => setError(null)} className="admin-oidc-error-dismiss">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="admin-oidc-card">
|
|
<div className="admin-oidc-loading">
|
|
<div className="admin-oidc-spinner"></div>
|
|
<span>Loading configuration...</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleSave} className="admin-oidc-card">
|
|
<div className="admin-oidc-section">
|
|
<h2>Status</h2>
|
|
<div className="admin-oidc-toggle-group">
|
|
<label className="admin-oidc-toggle-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
onChange={(e) => setEnabled(e.target.checked)}
|
|
disabled={isSaving}
|
|
/>
|
|
<span className="admin-oidc-toggle-custom"></span>
|
|
Enable OIDC Authentication
|
|
</label>
|
|
<p className="admin-oidc-field-help">
|
|
When enabled, users can sign in using your organization's identity provider.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-oidc-section">
|
|
<h2>Provider Configuration</h2>
|
|
|
|
<div className="admin-oidc-form-group">
|
|
<label htmlFor="issuer-url">Issuer URL</label>
|
|
<input
|
|
id="issuer-url"
|
|
type="url"
|
|
value={issuerUrl}
|
|
onChange={(e) => setIssuerUrl(e.target.value)}
|
|
placeholder="https://your-provider.com"
|
|
disabled={isSaving}
|
|
/>
|
|
<p className="admin-oidc-field-help">
|
|
The base URL of your OIDC provider. Discovery document will be fetched from <code>/.well-known/openid-configuration</code>.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="admin-oidc-form-row">
|
|
<div className="admin-oidc-form-group">
|
|
<label htmlFor="client-id">Client ID</label>
|
|
<input
|
|
id="client-id"
|
|
type="text"
|
|
value={clientId}
|
|
onChange={(e) => setClientId(e.target.value)}
|
|
placeholder="your-client-id"
|
|
disabled={isSaving}
|
|
/>
|
|
</div>
|
|
|
|
<div className="admin-oidc-form-group">
|
|
<label htmlFor="client-secret">
|
|
Client Secret
|
|
{config?.has_client_secret && (
|
|
<span className="admin-oidc-secret-status"> (configured)</span>
|
|
)}
|
|
</label>
|
|
<input
|
|
id="client-secret"
|
|
type="password"
|
|
value={clientSecret}
|
|
onChange={(e) => setClientSecret(e.target.value)}
|
|
placeholder={config?.has_client_secret ? 'Leave blank to keep current' : 'Enter client secret'}
|
|
disabled={isSaving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-oidc-form-group">
|
|
<label htmlFor="scopes">Scopes</label>
|
|
<input
|
|
id="scopes"
|
|
type="text"
|
|
value={scopes}
|
|
onChange={(e) => setScopes(e.target.value)}
|
|
placeholder="openid profile email"
|
|
disabled={isSaving}
|
|
/>
|
|
<p className="admin-oidc-field-help">
|
|
Space-separated list of OIDC scopes to request. Common scopes: openid, profile, email, groups.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-oidc-section">
|
|
<h2>User Provisioning</h2>
|
|
|
|
<div className="admin-oidc-toggle-group">
|
|
<label className="admin-oidc-toggle-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoCreateUsers}
|
|
onChange={(e) => setAutoCreateUsers(e.target.checked)}
|
|
disabled={isSaving}
|
|
/>
|
|
<span className="admin-oidc-toggle-custom"></span>
|
|
Auto-create users on first login
|
|
</label>
|
|
<p className="admin-oidc-field-help">
|
|
When enabled, new users will be created automatically when they sign in via OIDC for the first time.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="admin-oidc-form-group">
|
|
<label htmlFor="admin-group">Admin Group (optional)</label>
|
|
<input
|
|
id="admin-group"
|
|
type="text"
|
|
value={adminGroup}
|
|
onChange={(e) => setAdminGroup(e.target.value)}
|
|
placeholder="admin, orchard-admins"
|
|
disabled={isSaving}
|
|
/>
|
|
<p className="admin-oidc-field-help">
|
|
Users in this group (from the groups claim) will be granted admin privileges. Leave blank to disable automatic admin assignment.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-oidc-form-actions">
|
|
<button
|
|
type="button"
|
|
className="admin-oidc-cancel-button"
|
|
onClick={loadConfig}
|
|
disabled={isSaving}
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="admin-oidc-submit-button"
|
|
disabled={isSaving}
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<span className="admin-oidc-button-spinner"></span>
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Save Configuration'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
<div className="admin-oidc-info-card">
|
|
<h3>Callback URL</h3>
|
|
<p>Configure your identity provider with the following callback URL:</p>
|
|
<code className="admin-oidc-callback-url">
|
|
{window.location.origin}/api/v1/auth/oidc/callback
|
|
</code>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AdminOIDCPage;
|