Files
orchard/frontend/src/pages/AdminOIDCPage.tsx

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;