Restrict manager-wide authentication configuration mutations (OIDC, SMTP, IP allowlist) to Admin role. Operators now receive 403 forbidden_role. - New admin_required helper in settings.rs - 4 gate changes: update_settings, discover_oidc, test_oidc, update_ip_whitelist - 5 new AuditAction variants + migration 019 - SPA friendly error message on 403 - 3 admin_required unit tests pass (43/43) - Full integration tests deferred to issue #15 Closes #5
499 lines
21 KiB
TypeScript
499 lines
21 KiB
TypeScript
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<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',
|
|
})
|
|
const [polling, setPolling] = useState<PollingConfig>({
|
|
health_poll_interval_secs: 300, patch_poll_interval_secs: 1800,
|
|
})
|
|
const [ipWhitelist, setIpWhitelist] = useState<string[]>([])
|
|
const [webTlsStrategy, setWebTlsStrategy] = useState('internal_ca')
|
|
const [notification, setNotification] = useState<NotificationConfig>({
|
|
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<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)
|
|
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 (
|
|
<Container maxWidth="lg" sx={{ mt: 3, textAlign: 'center' }}>
|
|
<CircularProgress />
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
|
<Toolbar disableGutters sx={{ mb: 3, justifyContent: 'space-between' }}>
|
|
<Typography variant="h5" fontWeight={700}>Settings</Typography>
|
|
{canWrite && <Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={20} /> : <SaveIcon />}>
|
|
Save Settings
|
|
</Button>}
|
|
</Toolbar>
|
|
|
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
|
|
|
{/* Section 1: OIDC Provider Configuration */}
|
|
<Accordion defaultExpanded>
|
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
<Typography fontWeight={600}>OIDC Provider Configuration</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Grid container spacing={2}>
|
|
<Grid size={12}>
|
|
<FormControlLabel
|
|
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}>
|
|
<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={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={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={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={oidc.scopes}
|
|
onChange={(e) => setOidc({ ...oidc, scopes: e.target.value })}
|
|
disabled={!oidc.enabled}
|
|
/>
|
|
</Grid>
|
|
<Grid size={6}>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={handleTestOidc}
|
|
disabled={testingOidc || !oidc.discovery_url}
|
|
startIcon={testingOidc ? <CircularProgress size={20} /> : (oidc.provider_type === 'keycloak' ? <VpnKeyIcon /> : <CloudIcon />)}
|
|
>
|
|
Test Connection
|
|
</Button>
|
|
{oidcTestResult && (
|
|
<Alert severity={oidcTestResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>{oidcTestResult.message}</Alert>
|
|
)}
|
|
</Grid>
|
|
</Grid>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
|
|
{/* Section 2: SMTP Configuration & Email Notifications */}
|
|
<Accordion>
|
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
<Typography fontWeight={600}>SMTP Configuration & Email Notifications</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Grid container spacing={2}>
|
|
<Grid size={12}>
|
|
<FormControlLabel
|
|
control={<Switch checked={smtp.enabled} onChange={(e) => setSmtp({ ...smtp, enabled: e.target.checked })} />}
|
|
label="Enable SMTP Server"
|
|
/>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
Enable the SMTP server connection for sending emails
|
|
</Typography>
|
|
</Grid>
|
|
<Grid size={6}>
|
|
<TextField fullWidth label="SMTP Host" value={smtp.host} onChange={(e) => setSmtp({ ...smtp, host: e.target.value })} disabled={!smtp.enabled} />
|
|
</Grid>
|
|
<Grid size={3}>
|
|
<TextField fullWidth label="Port" type="number" value={smtp.port} onChange={(e) => setSmtp({ ...smtp, port: Number(e.target.value) })} disabled={!smtp.enabled} />
|
|
</Grid>
|
|
<Grid size={3}>
|
|
<FormControl fullWidth>
|
|
<InputLabel>TLS Mode</InputLabel>
|
|
<Select value={smtp.tls_mode} label="TLS Mode" onChange={(e) => setSmtp({ ...smtp, tls_mode: e.target.value })} disabled={!smtp.enabled}>
|
|
<MenuItem value="none">None</MenuItem>
|
|
<MenuItem value="starttls">STARTTLS</MenuItem>
|
|
<MenuItem value="tls">TLS (Implicit)</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Grid>
|
|
<Grid size={6}>
|
|
<TextField fullWidth label="Username" value={smtp.username} onChange={(e) => setSmtp({ ...smtp, username: e.target.value })} disabled={!smtp.enabled} />
|
|
</Grid>
|
|
<Grid size={6}>
|
|
<TextField fullWidth label="Password" type="password" value={smtp.password ?? ''} onChange={(e) => setSmtp({ ...smtp, password: e.target.value })} placeholder="Enter new password or leave masked" disabled={!smtp.enabled} />
|
|
</Grid>
|
|
<Grid size={6}>
|
|
<TextField fullWidth label="From Address" value={smtp.from} onChange={(e) => setSmtp({ ...smtp, from: e.target.value })} helperText="Sender address for both SMTP and notifications (e.g. noreply@example.com)" disabled={!smtp.enabled} />
|
|
</Grid>
|
|
<Grid size={12}>
|
|
<FormControlLabel
|
|
control={<Switch checked={notification.email_enabled} onChange={(e) => setNotification({ ...notification, email_enabled: e.target.checked })} />}
|
|
label="Enable Email Notifications"
|
|
disabled={!smtp.enabled}
|
|
/>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Requires SMTP server to be enabled
|
|
</Typography>
|
|
</Grid>
|
|
<Grid size={12}>
|
|
<Typography variant="subtitle2" sx={{ mt: 1, mb: 1 }}>Notification Recipients</Typography>
|
|
{notification.recipients.map((email, idx) => (
|
|
<Box key={idx} sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
|
<TextField size="small" value={email} onChange={(e) => {
|
|
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} />
|
|
<IconButton onClick={() => {
|
|
setNotification({ ...notification, recipients: notification.recipients.filter((_, i) => i !== idx) })
|
|
}}><DeleteIcon /></IconButton>
|
|
</Box>
|
|
))}
|
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => {
|
|
setNotification({ ...notification, recipients: [...notification.recipients, ''] })
|
|
}} disabled={!smtp.enabled || !notification.email_enabled}>Add Recipient</Button>
|
|
</Grid>
|
|
<Grid size={6}>
|
|
<Button variant="outlined" onClick={handleTestSmtp} disabled={testingSmtp || !smtp.host} startIcon={testingSmtp ? <CircularProgress size={20} /> : <EmailIcon />}>
|
|
Send Test Email
|
|
</Button>
|
|
{smtpTestResult && (
|
|
<Alert severity={smtpTestResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>{smtpTestResult.message}</Alert>
|
|
)}
|
|
</Grid>
|
|
</Grid>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
|
|
{/* Section 3: Polling Intervals */}
|
|
<Accordion>
|
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
<Typography fontWeight={600}>Polling Intervals</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Grid container spacing={2}>
|
|
<Grid size={6}>
|
|
<TextField fullWidth label="Health Poll Interval (seconds)" type="number" value={polling.health_poll_interval_secs} onChange={(e) => setPolling({ ...polling, health_poll_interval_secs: Number(e.target.value) })} helperText="How often to check agent health (default: 300)" />
|
|
</Grid>
|
|
<Grid size={6}>
|
|
<TextField fullWidth label="Patch Data Poll Interval (seconds)" type="number" value={polling.patch_poll_interval_secs} onChange={(e) => setPolling({ ...polling, patch_poll_interval_secs: Number(e.target.value) })} helperText="How often to check for patch updates (default: 1800)" />
|
|
</Grid>
|
|
</Grid>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
|
|
{/* Section 4: IP Whitelist */}
|
|
<Accordion>
|
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
<Typography fontWeight={600}>IP Whitelist</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Restrict access to specific IP addresses or CIDR ranges. Leave empty to allow all.
|
|
</Typography>
|
|
{ipWhitelist.map((entry, idx) => (
|
|
<Box key={idx} sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
|
<TextField size="small" value={entry} onChange={(e) => updateWhitelistEntry(idx, e.target.value)} placeholder="10.0.0.0/8 or 192.168.1.100" sx={{ flexGrow: 1 }} />
|
|
<IconButton onClick={() => removeWhitelistEntry(idx)}><DeleteIcon /></IconButton>
|
|
</Box>
|
|
))}
|
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={addWhitelistEntry}>Add Entry</Button>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
|
|
{/* Section 5: Web UI TLS Certificate Strategy */}
|
|
<Accordion>
|
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
<Typography fontWeight={600}>Web UI TLS Certificate</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<FormControl fullWidth>
|
|
<InputLabel>TLS Certificate Strategy</InputLabel>
|
|
<Select value={webTlsStrategy} label="TLS Certificate Strategy" onChange={(e) => setWebTlsStrategy(e.target.value)}>
|
|
<MenuItem value="internal_ca">Internal CA (auto-generated)</MenuItem>
|
|
<MenuItem value="operator_supplied">Operator-Supplied Certificate</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
|
{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.'}
|
|
</Typography>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
|
|
<Snackbar
|
|
open={!!success}
|
|
autoHideDuration={4000}
|
|
onClose={() => setSuccess(null)}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
>
|
|
<Alert severity="success" onClose={() => setSuccess(null)}>
|
|
{success}
|
|
</Alert>
|
|
</Snackbar>
|
|
</Container>
|
|
)
|
|
}
|