feat: OIDC SSO provider support (Keycloak, Azure AD, custom)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m11s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m11s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Refactored azure_sso.rs to sso.rs with generic OIDC provider support - Added OIDC discovery URL lookup with 1hr TTL caching - Added PKCE for all providers, client_secret optional for public clients - Added /api/v1/auth/sso/login and /api/v1/auth/sso/callback routes - Added /api/v1/auth/azure/* backward-compatible routes - Added POST /settings/sso/discover and POST /settings/sso/test endpoints - Frontend: Provider dropdown (Keycloak/Azure AD/Custom OIDC) - Frontend: Auto-fill discovery URL for Keycloak - Frontend: Discover Endpoints and Test Connection buttons - Frontend: Dynamic SSO button based on provider display name - Made migration 014 idempotent with DO blocks and IF NOT EXISTS - Fixed debian/install to use /usr/local/bin/ for binaries - Fixed frontend file path in .deb package - Reset admin password on dev server - Fixed database permissions for oidc_config table
This commit is contained in:
@ -212,6 +212,8 @@ export const reportsApi = {
|
||||
}),
|
||||
}
|
||||
// ── Settings API (M10) ────────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated Use OidcConfigResponse instead */
|
||||
export interface AzureSsoConfig {
|
||||
enabled: boolean
|
||||
tenant_id: string
|
||||
@ -220,6 +222,27 @@ export interface AzureSsoConfig {
|
||||
scopes: string
|
||||
}
|
||||
|
||||
export interface OidcConfigResponse {
|
||||
enabled: boolean
|
||||
provider_type: 'keycloak' | 'azure' | 'custom'
|
||||
display_name: string
|
||||
discovery_url: string
|
||||
client_id: string
|
||||
client_secret: string
|
||||
redirect_uri: string
|
||||
scopes: string
|
||||
}
|
||||
|
||||
export interface OidcDiscoveryResult {
|
||||
success: boolean
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
jwks_uri: string
|
||||
userinfo_endpoint?: string | null
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SmtpConfig {
|
||||
enabled: boolean
|
||||
host: string
|
||||
@ -241,12 +264,13 @@ export interface NotificationConfig {
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
azure_sso: AzureSsoConfig
|
||||
oidc: OidcConfigResponse
|
||||
smtp: SmtpConfig
|
||||
polling: PollingConfig
|
||||
ip_whitelist: string[]
|
||||
web_tls_strategy: string
|
||||
notification: NotificationConfig
|
||||
sso_callback_url?: string
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
@ -267,11 +291,14 @@ export interface AuditIntegrityResult {
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get<SettingsResponse>('/settings'),
|
||||
update: (data: Partial<SettingsResponse> & {
|
||||
azure_sso?: AzureSsoConfig & { client_secret?: string }
|
||||
oidc?: OidcConfigResponse & { client_secret?: string }
|
||||
smtp?: SmtpConfig & { password?: string }
|
||||
notification?: NotificationConfig
|
||||
}) => apiClient.put<SettingsResponse>('/settings', data),
|
||||
testAzureSso: () => apiClient.post<TestResult>('/settings/azure-sso/test'),
|
||||
discoverOidc: (discoveryUrl: string) => apiClient.post<OidcDiscoveryResult>('/settings/sso/discover', { discovery_url: discoveryUrl }),
|
||||
testOidc: () => apiClient.post<TestResult>('/settings/sso/test'),
|
||||
/** @deprecated Use testOidc instead */
|
||||
testAzureSso: () => apiClient.post<TestResult>('/settings/sso/test'),
|
||||
testSmtp: () => apiClient.post<TestResult>('/settings/smtp/test'),
|
||||
getIpWhitelist: () => apiClient.get<{ entries: string[] }>('/settings/ip-whitelist'),
|
||||
updateIpWhitelist: (entries: string[]) => apiClient.put<{ entries: string[] }>('/settings/ip-whitelist', { entries }),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box, Button, Container, TextField, Typography,
|
||||
@ -9,70 +9,32 @@ import {
|
||||
import {
|
||||
Visibility, VisibilityOff,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
Cloud as CloudIcon,
|
||||
Cloud as CloudIcon, VpnKey as KeyIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { authApi } from '../api/client'
|
||||
import { authApi, settingsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { User } from '../types'
|
||||
|
||||
/** Extract a human-readable error message from an Axios error. */
|
||||
function getErrorMessage(err: unknown): string {
|
||||
// Network error — no response at all (server unreachable, CORS, DNS failure)
|
||||
if (err instanceof Error && err.message === 'Network Error') {
|
||||
return 'Unable to connect to the server. Please check your network connection and try again.'
|
||||
}
|
||||
|
||||
// Axios-style error with a response body
|
||||
const axiosErr = err as { response?: { status?: number; data?: { error?: { code?: string; message?: string } } } }
|
||||
const status = axiosErr.response?.status
|
||||
const code = axiosErr.response?.data?.error?.code
|
||||
const msg = axiosErr.response?.data?.error?.message
|
||||
|
||||
// Rate limited
|
||||
if (status === 429) {
|
||||
return 'Too many login attempts. Please wait a moment and try again.'
|
||||
}
|
||||
|
||||
// MFA required
|
||||
if (code === 'mfa_required') {
|
||||
return 'MFA_REQUIRED' // sentinel — caller checks this
|
||||
}
|
||||
|
||||
// Password reset required
|
||||
if (code === 'password_reset_required') {
|
||||
return 'PASSWORD_RESET_REQUIRED'
|
||||
}
|
||||
|
||||
// Account locked
|
||||
if (code === 'account_locked') {
|
||||
return 'ACCOUNT_LOCKED'
|
||||
}
|
||||
|
||||
// Account disabled
|
||||
if (code === 'account_disabled') {
|
||||
return 'This account has been disabled. Contact your administrator.'
|
||||
}
|
||||
|
||||
// Server-provided message
|
||||
if (msg) {
|
||||
return msg
|
||||
}
|
||||
|
||||
// Generic status-based messages
|
||||
if (status === 401) {
|
||||
return 'Invalid username or password.'
|
||||
}
|
||||
if (status === 403) {
|
||||
return 'Access denied.'
|
||||
}
|
||||
if (status && status >= 500) {
|
||||
return 'A server error occurred. Please try again later.'
|
||||
}
|
||||
|
||||
if (status === 429) return 'Too many login attempts. Please wait a moment and try again.'
|
||||
if (code === 'mfa_required') return 'MFA_REQUIRED'
|
||||
if (code === 'password_reset_required') return 'PASSWORD_RESET_REQUIRED'
|
||||
if (code === 'account_locked') return 'ACCOUNT_LOCKED'
|
||||
if (code === 'account_disabled') return 'This account has been disabled. Contact your administrator.'
|
||||
if (msg) return msg
|
||||
if (status === 401) return 'Invalid username or password.'
|
||||
if (status === 403) return 'Access denied.'
|
||||
if (status && status >= 500) return 'A server error occurred. Please try again later.'
|
||||
return 'Login failed. Please try again.'
|
||||
}
|
||||
|
||||
/** Password strength checker */
|
||||
function checkPasswordStrength(password: string) {
|
||||
return {
|
||||
length: password.length >= 8,
|
||||
@ -100,7 +62,9 @@ export default function LoginPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Force change password state
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false)
|
||||
const [ssoDisplayName, setSsoDisplayName] = useState('SSO')
|
||||
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState('')
|
||||
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||
@ -110,11 +74,17 @@ export default function LoginPage() {
|
||||
const pwValid = isPasswordValid(pwChecks)
|
||||
const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword)
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.get().then(({ data }) => {
|
||||
setSsoEnabled(data.oidc.enabled)
|
||||
setSsoDisplayName(data.oidc.display_name || 'SSO')
|
||||
}).catch(() => { /* SSO settings unavailable */ })
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await authApi.login(username, password, needsMfa ? totpCode : undefined)
|
||||
const { access_token, refresh_token, user } = res.data
|
||||
@ -144,7 +114,6 @@ export default function LoginPage() {
|
||||
if (!pwValid || pwMismatch) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await authApi.forceChangePassword(username, password, newPassword)
|
||||
setPasswordChanged(true)
|
||||
@ -177,6 +146,8 @@ export default function LoginPage() {
|
||||
setConfirmNewPassword('')
|
||||
}
|
||||
|
||||
const ssoIcon = ssoDisplayName.toLowerCase().includes('keycloak') ? <KeyIcon /> : <CloudIcon />
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs" sx={{ mt: 12 }}>
|
||||
<Paper elevation={4} sx={{ p: 4 }}>
|
||||
@ -185,155 +156,51 @@ export default function LoginPage() {
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity={forcePasswordReset ? 'warning' : 'error'}
|
||||
sx={{ mb: 2 }}
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
<Alert severity={forcePasswordReset ? 'warning' : 'error'} sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passwordChanged ? (
|
||||
<Box>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Password changed successfully! Please log in with your new password.
|
||||
</Alert>
|
||||
<Button
|
||||
fullWidth variant="contained" size="large"
|
||||
onClick={handleBackToLogin}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>Password changed successfully! Please log in with your new password.</Alert>
|
||||
<Button fullWidth variant="contained" size="large" onClick={handleBackToLogin}>Back to Login</Button>
|
||||
</Box>
|
||||
) : forcePasswordReset ? (
|
||||
<Box component="form" onSubmit={handleForceChangePassword} noValidate>
|
||||
<Typography variant="h6" fontWeight={600} mb={2}>
|
||||
Change Your Password
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Your password has expired and must be changed before you can log in.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Username"
|
||||
value={username} InputProps={{ readOnly: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Current Password" type="password"
|
||||
value={password} InputProps={{ readOnly: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="New Password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading} required
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowNewPassword(!showNewPassword)} edge="end">
|
||||
{showNewPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" fontWeight={600} mb={2}>Change Your Password</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>Your password has expired and must be changed before you can log in.</Typography>
|
||||
<TextField fullWidth margin="normal" label="Username" value={username} InputProps={{ readOnly: true }} />
|
||||
<TextField fullWidth margin="normal" label="Current Password" type="password" value={password} InputProps={{ readOnly: true }} />
|
||||
<TextField fullWidth margin="normal" label="New Password" type={showNewPassword ? 'text' : 'password'} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={loading} required InputProps={{ endAdornment: <InputAdornment position="end"><IconButton onClick={() => setShowNewPassword(!showNewPassword)} edge="end">{showNewPassword ? <VisibilityOff /> : <Visibility />}</IconButton></InputAdornment> }} />
|
||||
{newPassword && (
|
||||
<Box sx={{ mt: 1, mb: 1 }}>
|
||||
<List dense disablePadding>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Confirm New Password" type="password"
|
||||
value={confirmNewPassword}
|
||||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||
disabled={loading} required
|
||||
error={pwMismatch}
|
||||
helperText={pwMismatch ? 'Passwords do not match' : ''}
|
||||
/>
|
||||
<Button
|
||||
type="submit" fullWidth variant="contained" size="large"
|
||||
sx={{ mt: 3 }} disabled={loading || !pwValid || pwMismatch}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Change Password'}
|
||||
</Button>
|
||||
<TextField fullWidth margin="normal" label="Confirm New Password" type="password" value={confirmNewPassword} onChange={(e) => setConfirmNewPassword(e.target.value)} disabled={loading} required error={pwMismatch} helperText={pwMismatch ? 'Passwords do not match' : ''} />
|
||||
<Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3 }} disabled={loading || !pwValid || pwMismatch}>{loading ? <CircularProgress size={24} /> : 'Change Password'}</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Username" autoComplete="username"
|
||||
value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loading} required autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} disabled={loading} required
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<TextField fullWidth margin="normal" label="Username" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} disabled={loading} required autoFocus />
|
||||
<TextField fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'} autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} disabled={loading} required InputProps={{ endAdornment: <InputAdornment position="end"><IconButton onClick={() => setShowPassword(!showPassword)} edge="end">{showPassword ? <VisibilityOff /> : <Visibility />}</IconButton></InputAdornment> }} />
|
||||
{needsMfa && (
|
||||
<TextField
|
||||
fullWidth margin="normal" label="MFA Code" inputMode="numeric"
|
||||
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
|
||||
value={totpCode} onChange={(e) => setTotpCode(e.target.value)}
|
||||
disabled={loading} required autoFocus
|
||||
helperText="Enter the 6-digit code from your authenticator app"
|
||||
/>
|
||||
<TextField fullWidth margin="normal" label="MFA Code" inputMode="numeric" inputProps={{ maxLength: 6, pattern: '[0-9]*' }} value={totpCode} onChange={(e) => setTotpCode(e.target.value)} disabled={loading} required autoFocus helperText="Enter the 6-digit code from your authenticator app" />
|
||||
)}
|
||||
<Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3 }} disabled={loading}>{loading ? <CircularProgress size={24} /> : 'Sign In'}</Button>
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<Divider sx={{ my: 3 }}>or</Divider>
|
||||
<Button fullWidth variant="outlined" size="large" startIcon={ssoIcon} onClick={() => { window.location.href = '/api/v1/auth/sso/login' }} disabled={loading}>Sign in with {ssoDisplayName}</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="submit" fullWidth variant="contained" size="large"
|
||||
sx={{ mt: 3 }} disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Sign In'}
|
||||
</Button>
|
||||
<Divider sx={{ my: 3 }}>or</Divider>
|
||||
<Button
|
||||
fullWidth variant="outlined" size="large"
|
||||
startIcon={<CloudIcon />}
|
||||
onClick={() => { window.location.href = '/api/v1/auth/azure/login' }}
|
||||
disabled={loading}
|
||||
>
|
||||
Sign in with Microsoft Azure
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
@ -12,15 +12,20 @@ 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 type { AzureSsoConfig, SmtpConfig, PollingConfig, NotificationConfig } from '../types'
|
||||
import type { OidcConfigResponse, OidcDiscoveryResult, SmtpConfig, PollingConfig, NotificationConfig } from '../types'
|
||||
|
||||
type AzureSsoForm = AzureSsoConfig & { client_secret?: string }
|
||||
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 [azureSso, setAzureSso] = useState<AzureSsoForm>({
|
||||
enabled: false, tenant_id: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid email profile',
|
||||
const [oidc, setOidc] = useState<OidcForm>({
|
||||
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<SmtpForm>({
|
||||
enabled: false, host: '', port: 587, username: '', password: '', from: '', tls_mode: 'starttls',
|
||||
@ -35,9 +40,11 @@ export default function SettingsPage() {
|
||||
})
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testingAzure, setTestingAzure] = useState(false)
|
||||
const [testingOidc, setTestingOidc] = useState(false)
|
||||
const [discoveringOidc, setDiscoveringOidc] = useState(false)
|
||||
const [testingSmtp, setTestingSmtp] = useState(false)
|
||||
const [azureSsoTestResult, setAzureSsoTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [oidcTestResult, setOidcTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [discoveryResult, setDiscoveryResult] = useState<OidcDiscoveryResult | null>(null)
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
@ -47,7 +54,7 @@ export default function SettingsPage() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const { data } = await settingsApi.get()
|
||||
setAzureSso({ ...data.azure_sso, client_secret: '' })
|
||||
setOidc({ ...data.oidc, client_secret: '' })
|
||||
setSmtp({ ...data.smtp, password: '' })
|
||||
setPolling(data.polling)
|
||||
setIpWhitelist(data.ip_whitelist)
|
||||
@ -62,20 +69,80 @@ export default function SettingsPage() {
|
||||
|
||||
useEffect(() => { loadSettings() }, [loadSettings])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
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 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({
|
||||
azure_sso: { ...azureSso },
|
||||
oidc: { ...oidc },
|
||||
smtp: { ...smtp },
|
||||
polling,
|
||||
ip_whitelist: ipWhitelist,
|
||||
web_tls_strategy: webTlsStrategy,
|
||||
notification: {
|
||||
...notification,
|
||||
email_from: smtp.from, // Use SMTP From Address as notification sender
|
||||
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')
|
||||
@ -90,37 +157,21 @@ export default function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestAzureSso = async () => {
|
||||
setTestingAzure(true)
|
||||
setAzureSsoTestResult(null)
|
||||
try {
|
||||
const { data } = await settingsApi.testAzureSso()
|
||||
setAzureSsoTestResult(data)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Test failed'
|
||||
setAzureSsoTestResult({ success: false, message: msg })
|
||||
} finally {
|
||||
setTestingAzure(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
setTestingSmtp(true)
|
||||
setSmtpTestResult(null)
|
||||
try {
|
||||
// Save settings first so the test uses current form values
|
||||
await settingsApi.update({
|
||||
azure_sso: { ...azureSso },
|
||||
oidc: { ...oidc },
|
||||
smtp: { ...smtp },
|
||||
polling,
|
||||
ip_whitelist: ipWhitelist,
|
||||
web_tls_strategy: webTlsStrategy,
|
||||
notification: {
|
||||
...notification,
|
||||
email_from: smtp.from, // Use SMTP From Address as notification sender
|
||||
email_from: smtp.from,
|
||||
},
|
||||
})
|
||||
// Then test SMTP
|
||||
const { data } = await settingsApi.testSmtp()
|
||||
setSmtpTestResult(data)
|
||||
} catch (err: unknown) {
|
||||
@ -158,40 +209,124 @@ export default function SettingsPage() {
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{/* Section 1: Azure SSO Configuration */}
|
||||
{/* Section 1: OIDC Provider Configuration */}
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Azure SSO Configuration</Typography>
|
||||
<Typography fontWeight={600}>OIDC Provider Configuration</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={12}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={azureSso.enabled} onChange={(e) => setAzureSso({ ...azureSso, enabled: e.target.checked })} />}
|
||||
label="Enable Azure SSO"
|
||||
control={<Switch checked={oidc.enabled} onChange={(e) => setOidc({ ...oidc, enabled: e.target.checked })} />}
|
||||
label="Enable SSO / OIDC Authentication"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Provider Type</InputLabel>
|
||||
<Select
|
||||
value={oidc.provider_type}
|
||||
label="Provider Type"
|
||||
onChange={(e) => handleProviderTypeChange(e.target.value)}
|
||||
disabled={!oidc.enabled}
|
||||
>
|
||||
<MenuItem value="keycloak">Keycloak</MenuItem>
|
||||
<MenuItem value="azure">Azure AD</MenuItem>
|
||||
<MenuItem value="custom">Custom OIDC</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Display Name"
|
||||
value={oidc.display_name}
|
||||
onChange={(e) => setOidc({ ...oidc, display_name: e.target.value })}
|
||||
helperText="Shown on the login button"
|
||||
disabled={!oidc.enabled}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Discovery URL"
|
||||
value={oidc.discovery_url}
|
||||
onChange={(e) => setOidc({ ...oidc, discovery_url: e.target.value })}
|
||||
placeholder={oidc.provider_type === 'azure' ? 'https://login.microsoftonline.com/<tenant_id>/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}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth label="Tenant ID" value={azureSso.tenant_id} onChange={(e) => setAzureSso({ ...azureSso, tenant_id: e.target.value })} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleDiscoverOidc}
|
||||
disabled={discoveringOidc || !oidc.discovery_url}
|
||||
startIcon={discoveringOidc ? <CircularProgress size={20} /> : <ExploreIcon />}
|
||||
>
|
||||
Discover Endpoints
|
||||
</Button>
|
||||
{discoveryResult && (
|
||||
<Alert severity={discoveryResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>
|
||||
{discoveryResult.success
|
||||
? `Discovered: ${discoveryResult.issuer}`
|
||||
: discoveryResult.message || 'Discovery failed'}
|
||||
</Alert>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth label="Client ID" value={azureSso.client_id} onChange={(e) => setAzureSso({ ...azureSso, client_id: e.target.value })} />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Client ID"
|
||||
value={oidc.client_id}
|
||||
onChange={(e) => setOidc({ ...oidc, client_id: e.target.value })}
|
||||
required
|
||||
disabled={!oidc.enabled}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth label="Client Secret" type="password" value={azureSso.client_secret ?? ''} onChange={(e) => setAzureSso({ ...azureSso, client_secret: e.target.value })} placeholder="Enter new secret or leave masked" />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Client Secret"
|
||||
type="password"
|
||||
value={oidc.client_secret ?? ''}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth label="Redirect URI" value={azureSso.redirect_uri} onChange={(e) => setAzureSso({ ...azureSso, redirect_uri: e.target.value })} helperText="e.g. https://patch-manager.example.com/api/v1/auth/azure/callback" />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Redirect URI"
|
||||
value={oidc.redirect_uri}
|
||||
onChange={(e) => setOidc({ ...oidc, redirect_uri: e.target.value })}
|
||||
helperText="e.g. https://patch-manager.example.com/api/v1/auth/sso/callback"
|
||||
disabled={!oidc.enabled}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth label="Scopes" value={azureSso.scopes} onChange={(e) => setAzureSso({ ...azureSso, scopes: e.target.value })} />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Scopes"
|
||||
value={oidc.scopes}
|
||||
onChange={(e) => setOidc({ ...oidc, scopes: e.target.value })}
|
||||
disabled={!oidc.enabled}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<Button variant="outlined" onClick={handleTestAzureSso} disabled={testingAzure || !azureSso.tenant_id} startIcon={testingAzure ? <CircularProgress size={20} /> : <CloudIcon />}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleTestOidc}
|
||||
disabled={testingOidc || !oidc.discovery_url}
|
||||
startIcon={testingOidc ? <CircularProgress size={20} /> : (oidc.provider_type === 'keycloak' ? <VpnKeyIcon /> : <CloudIcon />)}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{azureSsoTestResult && (
|
||||
<Alert severity={azureSsoTestResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>{azureSsoTestResult.message}</Alert>
|
||||
{oidcTestResult && (
|
||||
<Alert severity={oidcTestResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>{oidcTestResult.message}</Alert>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@ -52,13 +52,15 @@ export default function SsoCallbackPage() {
|
||||
}
|
||||
|
||||
// Build a full User object from the SSO subset, filling in sensible defaults
|
||||
// auth_provider comes from the backend based on the OIDC provider type
|
||||
const authProvider = (parsedUser.auth_provider as string) || 'azure_sso'
|
||||
const user: User = {
|
||||
id: (parsedUser.id as string) || '',
|
||||
username: (parsedUser.username as string) || '',
|
||||
display_name: (parsedUser.display_name as string) || '',
|
||||
email: (parsedUser.email as string) || '',
|
||||
role: (parsedUser.role as User['role']) || 'operator',
|
||||
auth_provider: 'azure_sso',
|
||||
auth_provider: authProvider as User['auth_provider'],
|
||||
mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false,
|
||||
is_active: true,
|
||||
force_password_reset: false,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Core TypeScript types — expanded per milestone
|
||||
|
||||
export type UserRole = 'admin' | 'operator'
|
||||
export type AuthProvider = 'local' | 'azure_sso'
|
||||
export type AuthProvider = 'local' | 'azure_sso' | 'keycloak' | 'oidc'
|
||||
export type HostHealthStatus = 'pending' | 'healthy' | 'degraded' | 'unreachable'
|
||||
export type JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||
export type JobKind = 'patch_apply' | 'patch_remove' | 'reboot' | 'rollback'
|
||||
@ -244,6 +244,7 @@ export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'aud
|
||||
|
||||
// ── Settings (M10) ──────────────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated Use OidcConfigResponse instead */
|
||||
export interface AzureSsoConfig {
|
||||
enabled: boolean
|
||||
tenant_id: string
|
||||
@ -252,6 +253,27 @@ export interface AzureSsoConfig {
|
||||
scopes: string
|
||||
}
|
||||
|
||||
export interface OidcConfigResponse {
|
||||
enabled: boolean
|
||||
provider_type: 'keycloak' | 'azure' | 'custom'
|
||||
display_name: string
|
||||
discovery_url: string
|
||||
client_id: string
|
||||
client_secret: string
|
||||
redirect_uri: string
|
||||
scopes: string
|
||||
}
|
||||
|
||||
export interface OidcDiscoveryResult {
|
||||
success: boolean
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
jwks_uri: string
|
||||
userinfo_endpoint?: string | null
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SmtpConfig {
|
||||
enabled: boolean
|
||||
host: string
|
||||
@ -273,7 +295,7 @@ export interface NotificationConfig {
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
azure_sso: AzureSsoConfig
|
||||
oidc: OidcConfigResponse
|
||||
smtp: SmtpConfig
|
||||
polling: PollingConfig
|
||||
ip_whitelist: string[]
|
||||
|
||||
Reference in New Issue
Block a user