import { useState, useEffect, useCallback } from 'react' import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, CircularProgress, Container, FormControl, FormControlLabel, Grid, IconButton, InputLabel, MenuItem, Select, Snackbar, Switch, TextField, Toolbar, Typography, } from '@mui/material' import type { AxiosError } from 'axios' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import SaveIcon from '@mui/icons-material/Save' import DeleteIcon from '@mui/icons-material/Delete' import AddIcon from '@mui/icons-material/Add' import CloudIcon from '@mui/icons-material/Cloud' import EmailIcon from '@mui/icons-material/Email' import VpnKeyIcon from '@mui/icons-material/VpnKey' import ExploreIcon from '@mui/icons-material/Explore' import { settingsApi } from '../api/client' import { useAuthStore } from '../store/authStore' import type { OidcConfigResponse, OidcDiscoveryResult, SmtpConfig, PollingConfig, NotificationConfig } from '../types' type OidcForm = OidcConfigResponse & { client_secret?: string } type SmtpForm = SmtpConfig & { password?: string } const KEYCLOAK_DISCOVERY_URL = 'https://keycloak.moon-dragon.us/realms/moon-dragon.us/.well-known/openid-configuration' export default function SettingsPage() { const user = useAuthStore(state => state.user) const canWrite = user?.role === 'admin' || user?.role === 'operator' const [oidc, setOidc] = useState({ enabled: false, provider_type: 'azure', display_name: 'Azure AD', discovery_url: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid profile email', }) const [smtp, setSmtp] = useState({ enabled: false, host: '', port: 587, username: '', password: '', from: '', tls_mode: 'starttls', }) const [polling, setPolling] = useState({ health_poll_interval_secs: 300, patch_poll_interval_secs: 1800, }) const [ipWhitelist, setIpWhitelist] = useState([]) const [webTlsStrategy, setWebTlsStrategy] = useState('internal_ca') const [notification, setNotification] = useState({ email_enabled: false, email_from: 'patch-manager@localhost', recipients: [], }) const [saving, setSaving] = useState(false) const [testingOidc, setTestingOidc] = useState(false) const [discoveringOidc, setDiscoveringOidc] = useState(false) const [testingSmtp, setTestingSmtp] = useState(false) const [oidcTestResult, setOidcTestResult] = useState<{ success: boolean; message: string } | null>(null) const [discoveryResult, setDiscoveryResult] = useState(null) const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [loading, setLoading] = useState(true) const loadSettings = useCallback(async () => { try { setLoading(true) const { data } = await settingsApi.get() setOidc({ ...data.oidc, client_secret: '' }) setSmtp({ ...data.smtp, password: '' }) setPolling(data.polling) setIpWhitelist(data.ip_whitelist) setWebTlsStrategy(data.web_tls_strategy) setNotification(data.notification) } catch { setError('Failed to load settings') } finally { setLoading(false) } }, []) useEffect(() => { loadSettings() }, [loadSettings]) const handleProviderTypeChange = (providerType: string) => { let discoveryUrl = oidc.discovery_url let displayName = oidc.display_name if (providerType === 'keycloak') { discoveryUrl = KEYCLOAK_DISCOVERY_URL displayName = 'Keycloak' } else if (providerType === 'azure') { // Clear discovery URL for Azure — user must enter tenant ID pattern discoveryUrl = '' displayName = 'Azure AD' } else { // Custom — leave discovery URL as-is for user to enter displayName = 'OIDC Provider' } setOidc({ ...oidc, provider_type: providerType as OidcConfigResponse['provider_type'], display_name: displayName, discovery_url: discoveryUrl }) } const handleDiscoverOidc = async () => { if (!oidc.discovery_url) return setDiscoveringOidc(true) setDiscoveryResult(null) try { const { data } = await settingsApi.discoverOidc(oidc.discovery_url) setDiscoveryResult(data) } catch (err: unknown) { const axiosErr = err as AxiosError if (axiosErr.response?.status === 403) { setDiscoveryResult({ success: false, issuer: '', authorization_endpoint: '', token_endpoint: '', jwks_uri: '', message: 'Only Admins can modify authentication configuration. Contact an Admin to make this change.' }) return } const msg = err instanceof Error ? err.message : 'Discovery failed' setDiscoveryResult({ success: false, issuer: '', authorization_endpoint: '', token_endpoint: '', jwks_uri: '', message: msg }) } finally { setDiscoveringOidc(false) } } const handleTestOidc = async () => { setTestingOidc(true) setOidcTestResult(null) try { // Save settings first so the test uses current form values await settingsApi.update({ oidc: { ...oidc }, smtp: { ...smtp }, polling, ip_whitelist: ipWhitelist, web_tls_strategy: webTlsStrategy, notification: { ...notification, email_from: smtp.from, }, }) const { data } = await settingsApi.testOidc() setOidcTestResult(data) } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Test failed' setOidcTestResult({ success: false, message: msg }) } finally { setTestingOidc(false) } } const handleSave = async () => { setSaving(true) setError(null) setSuccess(null) try { await settingsApi.update({ oidc: { ...oidc }, smtp: { ...smtp }, polling, ip_whitelist: ipWhitelist, web_tls_strategy: webTlsStrategy, notification: { ...notification, email_from: smtp.from, }, }) setSuccess('Settings saved successfully') } catch (err: unknown) { const axiosErr = err as AxiosError<{ error?: { message?: string } }> if (axiosErr.response?.status === 403) { setError('Only Admins can modify authentication configuration. Contact an Admin to make this change.') return } const msg = axiosErr.response?.data?.error?.message ?? (err instanceof Error ? err.message : 'Failed to save settings') setError(msg) } finally { setSaving(false) } } const handleTestSmtp = async () => { setTestingSmtp(true) setSmtpTestResult(null) try { await settingsApi.update({ oidc: { ...oidc }, smtp: { ...smtp }, polling, ip_whitelist: ipWhitelist, web_tls_strategy: webTlsStrategy, notification: { ...notification, email_from: smtp.from, }, }) const { data } = await settingsApi.testSmtp() setSmtpTestResult(data) } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Test failed' setSmtpTestResult({ success: false, message: msg }) } finally { setTestingSmtp(false) } } const addWhitelistEntry = () => setIpWhitelist([...ipWhitelist, '']) const removeWhitelistEntry = (idx: number) => setIpWhitelist(ipWhitelist.filter((_, i) => i !== idx)) const updateWhitelistEntry = (idx: number, value: string) => { const updated = [...ipWhitelist] updated[idx] = value setIpWhitelist(updated) } if (loading) { return ( ) } return ( Settings {canWrite && } {error && setError(null)}>{error}} {/* Section 1: OIDC Provider Configuration */} }> OIDC Provider Configuration setOidc({ ...oidc, enabled: e.target.checked })} />} label="Enable SSO / OIDC Authentication" /> Provider Type setOidc({ ...oidc, display_name: e.target.value })} helperText="Shown on the login button" disabled={!oidc.enabled} /> setOidc({ ...oidc, discovery_url: e.target.value })} placeholder={oidc.provider_type === 'azure' ? 'https://login.microsoftonline.com//v2.0/.well-known/openid-configuration' : 'https://sso.example.com/.well-known/openid-configuration'} helperText={oidc.provider_type === 'keycloak' ? 'Auto-filled for Keycloak' : 'OIDC well-known endpoint URL'} disabled={!oidc.enabled} /> {discoveryResult && ( {discoveryResult.success ? `Discovered: ${discoveryResult.issuer}` : discoveryResult.message || 'Discovery failed'} )} setOidc({ ...oidc, client_id: e.target.value })} required disabled={!oidc.enabled} /> setOidc({ ...oidc, client_secret: e.target.value })} placeholder="Enter new secret or leave masked" helperText="Leave empty for public clients (e.g. Keycloak)" disabled={!oidc.enabled} /> setOidc({ ...oidc, redirect_uri: e.target.value })} helperText="e.g. https://patch-manager.example.com/api/v1/auth/sso/callback" disabled={!oidc.enabled} /> setOidc({ ...oidc, scopes: e.target.value })} disabled={!oidc.enabled} /> {oidcTestResult && ( {oidcTestResult.message} )} {/* Section 2: SMTP Configuration & Email Notifications */} }> SMTP Configuration & Email Notifications setSmtp({ ...smtp, enabled: e.target.checked })} />} label="Enable SMTP Server" /> Enable the SMTP server connection for sending emails setSmtp({ ...smtp, host: e.target.value })} disabled={!smtp.enabled} /> setSmtp({ ...smtp, port: Number(e.target.value) })} disabled={!smtp.enabled} /> TLS Mode setSmtp({ ...smtp, username: e.target.value })} disabled={!smtp.enabled} /> setSmtp({ ...smtp, password: e.target.value })} placeholder="Enter new password or leave masked" disabled={!smtp.enabled} /> setSmtp({ ...smtp, from: e.target.value })} helperText="Sender address for both SMTP and notifications (e.g. noreply@example.com)" disabled={!smtp.enabled} /> setNotification({ ...notification, email_enabled: e.target.checked })} />} label="Enable Email Notifications" disabled={!smtp.enabled} /> Requires SMTP server to be enabled Notification Recipients {notification.recipients.map((email, idx) => ( { const updated = [...notification.recipients] updated[idx] = e.target.value setNotification({ ...notification, recipients: updated }) }} placeholder="admin@example.com" sx={{ flexGrow: 1 }} disabled={!smtp.enabled || !notification.email_enabled} /> { setNotification({ ...notification, recipients: notification.recipients.filter((_, i) => i !== idx) }) }}> ))} {smtpTestResult && ( {smtpTestResult.message} )} {/* Section 3: Polling Intervals */} }> Polling Intervals setPolling({ ...polling, health_poll_interval_secs: Number(e.target.value) })} helperText="How often to check agent health (default: 300)" /> setPolling({ ...polling, patch_poll_interval_secs: Number(e.target.value) })} helperText="How often to check for patch updates (default: 1800)" /> {/* Section 4: IP Whitelist */} }> IP Whitelist Restrict access to specific IP addresses or CIDR ranges. Leave empty to allow all. {ipWhitelist.map((entry, idx) => ( updateWhitelistEntry(idx, e.target.value)} placeholder="10.0.0.0/8 or 192.168.1.100" sx={{ flexGrow: 1 }} /> removeWhitelistEntry(idx)}> ))} {/* Section 5: Web UI TLS Certificate Strategy */} }> Web UI TLS Certificate TLS Certificate Strategy {webTlsStrategy === 'internal_ca' ? 'The internal CA will automatically generate and renew the web UI TLS certificate.' : 'You must provide your own TLS certificate and key files at the configured paths.'} setSuccess(null)} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSuccess(null)}> {success} ) }