feat: add bump-version.sh script for version management
Automates version bumps across all version source files: - Cargo.toml (PRIMARY - workspace.package.version) - debian/changelog (prepend new entry) - debian/control (update Version field) - scripts/build-package.sh (update VERSION variable) - frontend/package.json (update version field) - Stale references check after bump Usage: ./scripts/bump-version.sh <new_version> <old_version>
This commit is contained in:
56
frontend/eslint.config.js
Normal file
56
frontend/eslint.config.js
Normal file
@ -0,0 +1,56 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsparser from '@typescript-eslint/parser';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
export default [
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: {
|
||||
// Error rules
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
'@typescript-eslint/no-empty-interface': 'warn',
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
|
||||
// Security rules
|
||||
'no-eval': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-new-func': 'error',
|
||||
'no-unsafe-optional-chaining': 'error',
|
||||
|
||||
// Code quality
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-unreachable': 'error',
|
||||
'no-constant-condition': 'warn',
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
'eqeqeq': ['error', 'always'],
|
||||
'curly': ['error', 'multi-line'],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self' wss:;" />
|
||||
<title>Linux Patch Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4370
frontend/package-lock.json
generated
Normal file
4370
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "patch-manager-ui",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/ --ext .ts,.tsx --max-warnings 0",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.0.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"axios": "^1.9.0",
|
||||
"jszip": "^3.10.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.5.3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.30.0",
|
||||
"@typescript-eslint/parser": "^8.30.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3"
|
||||
}
|
||||
}
|
||||
120
frontend/src/App.tsx
Normal file
120
frontend/src/App.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { CssBaseline, ThemeProvider, CircularProgress, Box } from '@mui/material'
|
||||
import { darkTheme } from './theme/theme'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import AppLayout from './components/AppLayout'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import SsoCallbackPage from './pages/SsoCallbackPage'
|
||||
import MfaSetupPage from './pages/MfaSetupPage'
|
||||
import HostsPage from './pages/HostsPage'
|
||||
import HostDetailPage from './pages/HostDetailPage'
|
||||
import GroupsPage from './pages/GroupsPage'
|
||||
import UsersPage from './pages/UsersPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import PatchDeploymentPage from './pages/PatchDeploymentPage'
|
||||
import JobsPage from './pages/JobsPage'
|
||||
import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage'
|
||||
import CertificatesPage from './pages/CertificatesPage'
|
||||
import ReportsPage from './pages/ReportsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const isRestoring = useAuthStore((s) => s.isRestoring)
|
||||
|
||||
if (isRestoring) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for Zustand persist to finish rehydrating from localStorage,
|
||||
* then calls restoreSession() so it can see the persisted refreshToken.
|
||||
* Includes a safety timeout in case anything hangs.
|
||||
*/
|
||||
function AuthRestorer({ children }: { children: React.ReactNode }) {
|
||||
const restoreSession = useAuthStore((s) => s.restoreSession)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
// Safety timeout: force isRestoring=false if restoration doesn't complete in 15s
|
||||
const timeout = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
console.warn('[auth] Restoration timeout — forcing isRestoring=false')
|
||||
useAuthStore.setState({ isRestoring: false })
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
const doRestore = () => {
|
||||
if (!cancelled) restoreSession()
|
||||
}
|
||||
|
||||
let unsub: (() => void) | undefined
|
||||
|
||||
// Only call restoreSession AFTER Zustand has rehydrated the persisted state
|
||||
if (useAuthStore.persist.hasHydrated()) {
|
||||
console.warn('[auth] Store already hydrated, restoring session')
|
||||
doRestore()
|
||||
} else {
|
||||
console.warn('[auth] Waiting for Zustand hydration...')
|
||||
unsub = useAuthStore.persist.onFinishHydration(() => {
|
||||
console.warn('[auth] Hydration complete, restoring session')
|
||||
doRestore()
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timeout)
|
||||
unsub?.()
|
||||
}
|
||||
}, [restoreSession])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
<AuthRestorer>
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/auth/sso/callback" element={<SsoCallbackPage />} />
|
||||
|
||||
{/* Protected — wrapped in AppLayout with sidebar navigation */}
|
||||
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/mfa/setup" element={<MfaSetupPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/hosts" element={<HostsPage />} />
|
||||
<Route path="/hosts/:id" element={<HostDetailPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/deployment" element={<PatchDeploymentPage />} />
|
||||
<Route path="/maintenance" element={<MaintenanceWindowsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/certificates" element={<CertificatesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</AuthRestorer>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
404
frontend/src/api/client.ts
Normal file
404
frontend/src/api/client.ts
Normal file
@ -0,0 +1,404 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type {
|
||||
FleetStatus,
|
||||
CreateHostRequest,
|
||||
CreateJobRequest,
|
||||
CreateMaintenanceWindowRequest,
|
||||
MaintenanceWindow,
|
||||
UpdateMaintenanceWindowRequest,
|
||||
Certificate,
|
||||
IssuedCert,
|
||||
HealthCheckWithResult,
|
||||
CreateHealthCheckRequest,
|
||||
UpdateHealthCheckRequest,
|
||||
HealthCheckListResponse,
|
||||
User,
|
||||
ChangePasswordRequest,
|
||||
AdminResetPasswordRequest,
|
||||
UpdateUserRequest,
|
||||
CreateUserRequest,
|
||||
} from '../types'
|
||||
|
||||
const BASE_URL = '/api/v1'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// ── Request interceptor: attach access token ────────────────────────────────
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// ── Response interceptor: refresh on 401 ────────────────────────────────────
|
||||
let isRefreshing = false
|
||||
let failedQueue: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = []
|
||||
|
||||
const processQueue = (error: unknown, token: string | null) => {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) reject(error)
|
||||
else resolve(token!) // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error: AxiosError) => {
|
||||
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error.response?.status !== 401 || original._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
}).then((token) => {
|
||||
original.headers.Authorization = `Bearer ${token}`
|
||||
return apiClient(original)
|
||||
})
|
||||
}
|
||||
|
||||
original._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
const { refreshToken, setTokens, logout } = useAuthStore.getState()
|
||||
if (!refreshToken) {
|
||||
logout()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
processQueue(null, data.access_token)
|
||||
original.headers.Authorization = `Bearer ${data.access_token}`
|
||||
return apiClient(original)
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null)
|
||||
logout()
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Auth API functions ───────────────────────────────────────────────────────
|
||||
|
||||
export interface SsoConfigResponse {
|
||||
enabled: boolean
|
||||
display_name: string
|
||||
auth_url: string
|
||||
}
|
||||
|
||||
export const ssoConfigApi = {
|
||||
/** Public endpoint — no JWT required. Returns minimal SSO config for the login page. */
|
||||
get: () => apiClient.get<SsoConfigResponse>('/auth/sso/config'),
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string, totpCode?: string) =>
|
||||
apiClient.post('/auth/login', { username, password, totp_code: totpCode }),
|
||||
|
||||
logout: (refreshToken: string) =>
|
||||
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
|
||||
|
||||
forceChangePassword: (username: string, currentPassword: string, newPassword: string) =>
|
||||
apiClient.post('/auth/force-change-password', { username, current_password: currentPassword, new_password: newPassword }),
|
||||
|
||||
getMfaSetup: () =>
|
||||
apiClient.get('/auth/mfa/setup'),
|
||||
|
||||
verifyMfa: (secretBase32: string, code: string) =>
|
||||
apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }),
|
||||
|
||||
// WebAuthn MFA stubs
|
||||
webauthnAuthenticateStart: () =>
|
||||
apiClient.post('/auth/mfa/webauthn/authenticate/start'),
|
||||
|
||||
webauthnAuthenticateComplete: (challengeKey: string, serializedAssertion: unknown) =>
|
||||
apiClient.post('/auth/mfa/webauthn/authenticate/complete', { challenge_key: challengeKey, serialized_assertion: serializedAssertion }),
|
||||
|
||||
webauthnListCredentials: () =>
|
||||
apiClient.get('/auth/mfa/webauthn/credentials'),
|
||||
|
||||
webauthnRegisterStart: (keyName?: string) =>
|
||||
apiClient.post('/auth/mfa/webauthn/register/start', { key_name: keyName }),
|
||||
|
||||
webauthnRegisterComplete: (challengeKey: string, serializedCredential: unknown, keyName?: string) =>
|
||||
apiClient.post('/auth/mfa/webauthn/register/complete', { challenge_key: challengeKey, serialized_credential: serializedCredential, key_name: keyName }),
|
||||
|
||||
webauthnDeleteCredential: (id: string) =>
|
||||
apiClient.delete(`/auth/mfa/webauthn/credentials/${id}`),
|
||||
}
|
||||
|
||||
// ── Fleet API functions ──────────────────────────────────────────────────────
|
||||
export const fleetApi = {
|
||||
getStatus: () => apiClient.get<FleetStatus>('/status/fleet'),
|
||||
}
|
||||
|
||||
// ── Hosts API functions ──────────────────────────────────────────────────────
|
||||
export const hostsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
|
||||
get: (id: string) => apiClient.get(`/hosts/${id}`),
|
||||
register: (body: CreateHostRequest) => apiClient.post('/hosts', body),
|
||||
update: (id: string, body: Record<string, string | undefined>) =>
|
||||
apiClient.put(`/hosts/${id}`, body),
|
||||
delete: (id: string) => apiClient.delete(`/hosts/${id}`),
|
||||
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
|
||||
}
|
||||
|
||||
// ── Jobs API ─────────────────────────────────────────────────────────────────
|
||||
export const jobsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/jobs', { params }),
|
||||
get: (id: string) => apiClient.get(`/jobs/${id}`),
|
||||
create: (body: CreateJobRequest) => apiClient.post('/jobs', body),
|
||||
cancel: (id: string) => apiClient.post(`/jobs/${id}/cancel`),
|
||||
rollback: (id: string) => apiClient.post(`/jobs/${id}/rollback`),
|
||||
}
|
||||
|
||||
// ── Patches API (per-host patch listing) ──────────────────────────────────────
|
||||
export const patchesApi = {
|
||||
// Returns patches available on a specific host via the manager's proxy
|
||||
// The backend reads from host_patch_data table (cached from agent poll)
|
||||
getHostPatches: (hostId: string) => apiClient.get(`/hosts/${hostId}/patches`),
|
||||
}
|
||||
|
||||
// ── Maintenance Windows API ───────────────────────────────────────────────────
|
||||
export const maintenanceWindowsApi = {
|
||||
/** Bulk: fetch ALL maintenance windows across every host in one request. */
|
||||
listAll: () =>
|
||||
apiClient.get<{ windows: MaintenanceWindow[] }>('/maintenance-windows'),
|
||||
/** Per-host: fetch windows for a single host. */
|
||||
list: (hostId: string) =>
|
||||
apiClient.get(`/hosts/${hostId}/maintenance-windows`),
|
||||
create: (hostId: string, body: CreateMaintenanceWindowRequest) =>
|
||||
apiClient.post(`/hosts/${hostId}/maintenance-windows`, body),
|
||||
update: (hostId: string, windowId: string, body: UpdateMaintenanceWindowRequest) =>
|
||||
apiClient.put(`/hosts/${hostId}/maintenance-windows/${windowId}`, body),
|
||||
remove: (hostId: string, windowId: string) =>
|
||||
apiClient.delete(`/hosts/${hostId}/maintenance-windows/${windowId}`),
|
||||
}
|
||||
|
||||
// ── WebSocket API (M7) ────────────────────────────────────────────────────────
|
||||
export const wsApi = {
|
||||
/** POST /api/v1/ws/ticket — obtain a single-use WS auth ticket (60 s expiry). */
|
||||
createTicket: (): Promise<{ ticket: string }> =>
|
||||
apiClient.post<{ ticket: string }>('/ws/ticket').then((r) => r.data),
|
||||
}
|
||||
|
||||
// ── Certificates API (M8) ────────────────────────────────────────────────────
|
||||
export const certsApi = {
|
||||
// List all certs, optional filters
|
||||
list: (params?: { host_id?: string; status?: string }) =>
|
||||
apiClient.get<Certificate[]>('/certificates', { params }),
|
||||
|
||||
// Download root CA cert as blob
|
||||
downloadRootCa: () =>
|
||||
apiClient.get('/ca/root.crt', { responseType: 'blob' }),
|
||||
|
||||
// Issue client cert for a host — returns IssuedCert (key_pem only shown once!)
|
||||
issue: (hostId: string, hostname: string) =>
|
||||
apiClient.post<IssuedCert>(`/hosts/${hostId}/certificates`, { hostname }),
|
||||
|
||||
// Renew a cert
|
||||
renew: (certId: string) =>
|
||||
apiClient.post<IssuedCert>(`/certificates/${certId}/renew`),
|
||||
|
||||
// Revoke a cert
|
||||
revoke: (certId: string) =>
|
||||
apiClient.delete(`/certificates/${certId}`),
|
||||
|
||||
// Download host client cert as blob
|
||||
downloadClientCert: (hostId: string) =>
|
||||
apiClient.get(`/hosts/${hostId}/client.crt`, { responseType: 'blob' }),
|
||||
|
||||
// Re-issue all certs for a host — revokes all active certs and issues a new one
|
||||
reissue: (hostId: string) =>
|
||||
apiClient.post<IssuedCert>(`/hosts/${hostId}/certificates/reissue`),
|
||||
}
|
||||
|
||||
// ── Reports API (M9) ─────────────────────────────────────────────────────────
|
||||
export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit'
|
||||
export type ReportFormat = 'csv' | 'pdf'
|
||||
|
||||
export const reportsApi = {
|
||||
download: (
|
||||
reportType: ReportType,
|
||||
format: ReportFormat,
|
||||
params?: {
|
||||
from?: string // ISO 8601
|
||||
to?: string // ISO 8601
|
||||
group_id?: string // UUID
|
||||
}
|
||||
) =>
|
||||
apiClient.get(`/reports/${reportType}`, {
|
||||
params: { format, ...params },
|
||||
responseType: 'blob',
|
||||
timeout: 120_000, // reports can take a while
|
||||
}),
|
||||
}
|
||||
// ── Settings API (M10) ────────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated Use OidcConfigResponse instead */
|
||||
export interface AzureSsoConfig {
|
||||
enabled: boolean
|
||||
tenant_id: string
|
||||
client_id: string
|
||||
redirect_uri: string
|
||||
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
|
||||
port: number
|
||||
username: string
|
||||
from: string
|
||||
tls_mode: string
|
||||
}
|
||||
|
||||
export interface PollingConfig {
|
||||
health_poll_interval_secs: number
|
||||
patch_poll_interval_secs: number
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
email_enabled: boolean
|
||||
email_from: string
|
||||
recipients: string[]
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
oidc: OidcConfigResponse
|
||||
smtp: SmtpConfig
|
||||
polling: PollingConfig
|
||||
ip_whitelist: string[]
|
||||
web_tls_strategy: string
|
||||
notification: NotificationConfig
|
||||
sso_callback_url?: string
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AuditIntegrityResult {
|
||||
intact: boolean
|
||||
rows_checked: number
|
||||
errors: Array<{
|
||||
row_id: number
|
||||
expected_hash: string
|
||||
actual_hash: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get<SettingsResponse>('/settings'),
|
||||
update: (data: Partial<SettingsResponse> & {
|
||||
oidc?: OidcConfigResponse & { client_secret?: string }
|
||||
smtp?: SmtpConfig & { password?: string }
|
||||
notification?: NotificationConfig
|
||||
}) => apiClient.put<SettingsResponse>('/settings', data),
|
||||
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 }),
|
||||
auditIntegrity: () => apiClient.post<AuditIntegrityResult>('/settings/audit-integrity'),
|
||||
}
|
||||
|
||||
// ── Health Checks API ─────────────────────────────────────────────────────────
|
||||
|
||||
export const healthChecksApi = {
|
||||
list: (hostId: string) =>
|
||||
apiClient.get<HealthCheckListResponse>(`/hosts/${hostId}/health-checks`),
|
||||
|
||||
get: (hostId: string, checkId: string) =>
|
||||
apiClient.get<HealthCheckWithResult>(`/hosts/${hostId}/health-checks/${checkId}`),
|
||||
|
||||
create: (hostId: string, body: CreateHealthCheckRequest) =>
|
||||
apiClient.post<HealthCheckWithResult>(`/hosts/${hostId}/health-checks`, body),
|
||||
|
||||
update: (hostId: string, checkId: string, body: UpdateHealthCheckRequest) =>
|
||||
apiClient.put<HealthCheckWithResult>(`/hosts/${hostId}/health-checks/${checkId}`, body),
|
||||
|
||||
delete: (hostId: string, checkId: string) =>
|
||||
apiClient.delete(`/hosts/${hostId}/health-checks/${checkId}`),
|
||||
|
||||
test: (hostId: string, checkId: string) =>
|
||||
apiClient.post<HealthCheckWithResult>(`/hosts/${hostId}/health-checks/${checkId}/test`),
|
||||
}
|
||||
|
||||
// ── Users API ──────────────────────────────────────────────────────────────
|
||||
export const usersApi = {
|
||||
list: () => apiClient.get<User[]>('/users'),
|
||||
get: (id: string) => apiClient.get<User>(`/users/${id}`),
|
||||
getMe: () => apiClient.get<User>('/users/me'),
|
||||
create: (data: CreateUserRequest) => apiClient.post('/users', data),
|
||||
update: (id: string, data: UpdateUserRequest) => apiClient.put(`/users/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/users/${id}`),
|
||||
revokeSessions: (id: string) => apiClient.post(`/users/${id}/revoke`),
|
||||
changePassword: (data: ChangePasswordRequest) => apiClient.put('/users/me/password', data),
|
||||
adminResetPassword: (id: string, data: AdminResetPasswordRequest) => apiClient.put(`/users/${id}/password`, data),
|
||||
adminDisableMfa: (id: string) => apiClient.delete(`/users/${id}/mfa`),
|
||||
disableMfa: (password: string) => apiClient.delete('/auth/mfa', { data: { password } }),
|
||||
}
|
||||
|
||||
// ── Enrollment API (Admin) ────────────────────────────────────────────────
|
||||
export interface EnrollmentRequest {
|
||||
id: string
|
||||
machine_id: string
|
||||
fqdn: string
|
||||
ip_address: string
|
||||
os_details: Record<string, unknown>
|
||||
polling_token: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export const enrollmentApi = {
|
||||
listPending: (): Promise<EnrollmentRequest[]> =>
|
||||
apiClient.get<EnrollmentRequest[]>('/admin/enrollments').then(r => r.data),
|
||||
|
||||
approve: (id: string): Promise<void> =>
|
||||
apiClient.post(`/admin/enrollments/${id}/approve`).then(() => {}),
|
||||
|
||||
deny: (id: string): Promise<void> =>
|
||||
apiClient.delete(`/admin/enrollments/${id}/deny`).then(() => {}),
|
||||
}
|
||||
238
frontend/src/components/AppLayout.tsx
Normal file
238
frontend/src/components/AppLayout.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
AppBar, Box, CssBaseline, Divider, Drawer, IconButton,
|
||||
List, ListItem, ListItemButton, ListItemIcon, ListItemText,
|
||||
Toolbar, Typography, Avatar, Menu, MenuItem, Tooltip,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Dashboard as DashboardIcon,
|
||||
Computer as HostsIcon,
|
||||
Group as GroupsIcon,
|
||||
Build as DeployIcon,
|
||||
Assignment as JobsIcon,
|
||||
Schedule as MaintenanceIcon,
|
||||
People as UsersIcon,
|
||||
VerifiedUser as CertsIcon,
|
||||
Assessment as ReportsIcon,
|
||||
Settings as SettingsIcon,
|
||||
Menu as MenuIcon,
|
||||
Logout as LogoutIcon,
|
||||
Person as PersonIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
const DRAWER_WIDTH = 240
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
path: string
|
||||
icon: React.ReactElement
|
||||
adminOnly?: boolean
|
||||
writeOnly?: boolean
|
||||
}
|
||||
|
||||
const navGroups: { heading: string; items: NavItem[] }[] = [
|
||||
{
|
||||
heading: 'Overview',
|
||||
items: [
|
||||
{ label: 'Dashboard', path: '/dashboard', icon: <DashboardIcon /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Fleet',
|
||||
items: [
|
||||
{ label: 'Hosts', path: '/hosts', icon: <HostsIcon /> },
|
||||
{ label: 'Groups', path: '/groups', icon: <GroupsIcon /> },
|
||||
{ label: 'Deploy', path: '/deployment', icon: <DeployIcon />, writeOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Operations',
|
||||
items: [
|
||||
{ label: 'Jobs', path: '/jobs', icon: <JobsIcon /> },
|
||||
{ label: 'Maintenance', path: '/maintenance', icon: <MaintenanceIcon />, writeOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Administration',
|
||||
items: [
|
||||
{ label: 'Users', path: '/users', icon: <UsersIcon />, adminOnly: true },
|
||||
{ label: 'Certificates', path: '/certificates', icon: <CertsIcon /> },
|
||||
{ label: 'Reports', path: '/reports', icon: <ReportsIcon /> },
|
||||
{ label: 'Settings', path: '/settings', icon: <SettingsIcon /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function AppLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||
const handleDrawerToggle = () => setMobileOpen(!mobileOpen)
|
||||
const handleMenuOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget)
|
||||
const handleMenuClose = () => setAnchorEl(null)
|
||||
|
||||
const handleLogout = () => {
|
||||
handleMenuClose()
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const drawer = (
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Toolbar sx={{ justifyContent: 'center', py: 1.5 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{
|
||||
background: 'linear-gradient(135deg, #42A5F5 30%, #26C6DA 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
🐉 Patch Manager
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
|
||||
{navGroups.map((group) => {
|
||||
const visibleItems = group.items.filter((item) => {
|
||||
if (item.adminOnly && !isAdmin) return false
|
||||
if (item.writeOnly && !canWrite) return false
|
||||
return true
|
||||
})
|
||||
if (visibleItems.length === 0) return null
|
||||
return (
|
||||
<Box key={group.heading} sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ px: 2.5, py: 0.5, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{group.heading}
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/')
|
||||
return (
|
||||
<ListItem key={item.path} disablePadding sx={{ px: 1 }}>
|
||||
<ListItemButton
|
||||
selected={isActive}
|
||||
onClick={() => navigate(item.path)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mx: 0.5,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
'& .MuiListItemIcon-root': { color: 'primary.contrastText' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: isActive ? 'inherit' : 'text.secondary' }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: isActive ? 600 : 400, fontSize: '0.875rem' }} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Linux Patch Manager v{__APP_VERSION__}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh' }}>
|
||||
<CssBaseline />
|
||||
|
||||
{/* App Bar */}
|
||||
<AppBar
|
||||
position="fixed"
|
||||
elevation={0}
|
||||
sx={{
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2, display: { md: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap sx={{ flexGrow: 1, fontWeight: 600 }}>
|
||||
{navGroups.flatMap((g) => g.items).find((i) => location.pathname === i.path || location.pathname.startsWith(i.path + '/'))?.label || 'Patch Manager'}
|
||||
</Typography>
|
||||
<Tooltip title={`${user?.display_name || user?.username} (${user?.role})`}>
|
||||
<IconButton onClick={handleMenuOpen} color="inherit" sx={{ ml: 1 }}>
|
||||
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main', fontSize: '0.875rem' }}>
|
||||
{(user?.display_name || user?.username || '?')[0].toUpperCase()}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
slotProps={{ paper: { sx: { mt: 1 } } }}
|
||||
>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon><PersonIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText primary={user?.display_name || user?.username} secondary={user?.role} />
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { handleMenuClose(); navigate('/profile') }}>
|
||||
<ListItemIcon><PersonIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText primary="My Profile" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon><LogoutIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText primary="Sign out" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Box component="nav" sx={{ width: { md: DRAWER_WIDTH }, flexShrink: { md: 0 } }}>
|
||||
{/* Mobile drawer */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{ display: { xs: 'block', md: 'none' }, '& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH } }}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
{/* Desktop drawer */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{ display: { xs: 'none', md: 'block' }, '& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH } }}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
|
||||
{/* Main content */}
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3, bgcolor: 'background.default', overflowY: 'auto' }}>
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
172
frontend/src/hooks/useJobWebSocket.ts
Normal file
172
frontend/src/hooks/useJobWebSocket.ts
Normal file
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* useJobWebSocket — M7
|
||||
*
|
||||
* Manages a browser WebSocket connection to the job-update relay.
|
||||
* Authentication uses single-use tickets obtained via POST /api/v1/ws/ticket.
|
||||
*
|
||||
* Features:
|
||||
* - Fetches a fresh ticket before every (re)connect
|
||||
* - Exponential backoff reconnect: 1 s → 2 s → 4 s → … → 30 s max
|
||||
* - Calls `onEvent` callback for every parsed JobWsEvent
|
||||
* - Returns { connected, lastEvent } for UI indicator use
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { wsApi } from '../api/client'
|
||||
import type { JobWsEvent } from '../types'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BACKOFF_INITIAL_MS = 1_000
|
||||
const BACKOFF_MAX_MS = 30_000
|
||||
const BACKOFF_FACTOR = 2
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface JobWsOptions {
|
||||
/** Called on each inbound JobWsEvent. */
|
||||
onEvent?: (event: JobWsEvent) => void
|
||||
/** Set to false to disable the connection entirely (e.g. when logged out). */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface JobWsState {
|
||||
connected: boolean
|
||||
lastEvent: JobWsEvent | null
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Derive the correct ws(s) URL from the current page origin. */
|
||||
function buildWsBase(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${proto}://${window.location.host}`
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useJobWebSocket(options: JobWsOptions = {}): JobWsState {
|
||||
const { onEvent, enabled = true } = options
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [lastEvent, setLastEvent] = useState<JobWsEvent | null>(null)
|
||||
|
||||
// Stable ref to the latest onEvent callback — avoids re-triggering the
|
||||
// effect every time the parent component re-renders.
|
||||
const onEventRef = useRef(onEvent)
|
||||
useEffect(() => { onEventRef.current = onEvent }, [onEvent])
|
||||
|
||||
// Internal bookkeeping refs (don't need to trigger re-renders).
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const backoffRef = useRef(BACKOFF_INITIAL_MS)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const clearRetryTimer = useCallback(() => {
|
||||
if (retryTimerRef.current !== null) {
|
||||
clearTimeout(retryTimerRef.current)
|
||||
retryTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeSocket = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
// Prevent the onclose handler from scheduling another reconnect.
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onerror = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (!mountedRef.current || !enabled) return
|
||||
|
||||
// Close any existing socket before opening a new one.
|
||||
closeSocket()
|
||||
|
||||
let ticket: string
|
||||
try {
|
||||
const resp = await wsApi.createTicket()
|
||||
ticket = resp.ticket
|
||||
} catch (err) {
|
||||
console.warn('[JobWS] Failed to obtain WS ticket:', err)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (!mountedRef.current) return
|
||||
|
||||
const url = `${buildWsBase()}/api/v1/ws/jobs?ticket=${encodeURIComponent(ticket)}`
|
||||
let ws: WebSocket
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (err) {
|
||||
console.error('[JobWS] WebSocket constructor threw:', err)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) { ws.close(); return }
|
||||
console.warn('[JobWS] Connected')
|
||||
backoffRef.current = BACKOFF_INITIAL_MS // reset backoff on successful connect
|
||||
setConnected(true)
|
||||
}
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!mountedRef.current) return
|
||||
try {
|
||||
const event: JobWsEvent = JSON.parse(ev.data as string)
|
||||
setLastEvent(event)
|
||||
onEventRef.current?.(event)
|
||||
} catch {
|
||||
console.warn('[JobWS] Unparseable message:', ev.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
console.warn('[JobWS] Socket error')
|
||||
// onclose will fire immediately after onerror — let it handle reconnect.
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return
|
||||
console.warn('[JobWS] Disconnected — scheduling reconnect')
|
||||
setConnected(false)
|
||||
wsRef.current = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, closeSocket])
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!mountedRef.current) return
|
||||
clearRetryTimer()
|
||||
const delay = backoffRef.current
|
||||
backoffRef.current = Math.min(delay * BACKOFF_FACTOR, BACKOFF_MAX_MS)
|
||||
console.warn(`[JobWS] Reconnecting in ${delay} ms`)
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (enabled) {
|
||||
connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearRetryTimer()
|
||||
closeSocket()
|
||||
setConnected(false)
|
||||
}
|
||||
}, [enabled, connect, clearRetryTimer, closeSocket])
|
||||
|
||||
return { connected, lastEvent }
|
||||
}
|
||||
2
frontend/src/index.css
Normal file
2
frontend/src/index.css
Normal file
@ -0,0 +1,2 @@
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Roboto', sans-serif; }
|
||||
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('Root element not found')
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
576
frontend/src/pages/CertificatesPage.tsx
Normal file
576
frontend/src/pages/CertificatesPage.tsx
Normal file
@ -0,0 +1,576 @@
|
||||
import JSZip from 'jszip'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
type SelectChangeEvent,
|
||||
Snackbar,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
ContentCopy as CopyIcon,
|
||||
Download as DownloadIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Security as SecurityIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { certsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { Certificate, CertStatus, IssuedCert } from '../types'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function isExpiringSoon(iso: string): boolean {
|
||||
return new Date(iso).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
function statusChip(status: CertStatus) {
|
||||
const map: Record<CertStatus, { label: string; color: 'success' | 'error' | 'warning' }> = {
|
||||
active: { label: 'Active', color: 'success' },
|
||||
revoked: { label: 'Revoked', color: 'error' },
|
||||
expired: { label: 'Expired', color: 'warning' },
|
||||
}
|
||||
const { label, color } = map[status]
|
||||
return <Chip label={label} color={color} size="small" />
|
||||
}
|
||||
|
||||
// ── Issue Dialog ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface IssueDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onIssued: (cert: IssuedCert) => void
|
||||
}
|
||||
|
||||
function IssueDialog({ open, onClose, onIssued }: IssueDialogProps) {
|
||||
const [hostId, setHostId] = useState('')
|
||||
const [hostname, setHostname] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setHostId(''); setHostname(''); setErr(null) }
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!hostId.trim()) { setErr('Host ID is required'); return }
|
||||
if (!hostname.trim()) { setErr('Hostname is required'); return }
|
||||
setSaving(true); setErr(null)
|
||||
try {
|
||||
const res = await certsApi.issue(hostId.trim(), hostname.trim())
|
||||
onIssued(res.data)
|
||||
onClose()
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message ?? 'Failed to issue certificate'
|
||||
setErr(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Issue Client Certificate</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
{err && <Alert severity="error">{err}</Alert>}
|
||||
<TextField
|
||||
label="Host ID (UUID)"
|
||||
value={hostId}
|
||||
onChange={(e) => setHostId(e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
placeholder="e.g. 3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||
/>
|
||||
<TextField
|
||||
label="Hostname"
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
placeholder="e.g. web-01.example.com"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? <CircularProgress size={20} /> : 'Issue'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One-Time Key Display Dialog ───────────────────────────────────────────────
|
||||
|
||||
interface KeyDisplayDialogProps {
|
||||
open: boolean
|
||||
cert: IssuedCert | null
|
||||
hostname?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogProps) {
|
||||
const [copiedField, setCopiedField] = useState<'ca' | 'cert' | 'key' | 'server-cert' | 'server-key' | null>(null)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
const handleCopy = async (text: string, field: 'ca' | 'cert' | 'key' | 'server-cert' | 'server-key') => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedField(field)
|
||||
setTimeout(() => setCopiedField(null), 2000)
|
||||
}
|
||||
|
||||
const handleDownloadBundle = async () => {
|
||||
if (!cert) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
zip.file('ca.crt', cert.ca_root_pem)
|
||||
zip.file('client.crt', cert.cert_pem)
|
||||
zip.file('client.key', cert.key_pem)
|
||||
zip.file('server.crt', cert.server_cert_pem)
|
||||
zip.file('server.key', cert.server_key_pem)
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
downloadBlob(blob, `${hostname || 'host'}-certs.zip`)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const preStyle = {
|
||||
p: 2,
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
maxHeight: 150,
|
||||
fontFamily: 'monospace' as const,
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
wordBreak: 'break-all' as const,
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Agent Certificate Bundle Issued — Save Your Private Keys</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
<Alert severity="warning">
|
||||
<strong>Private keys will NOT be shown again.</strong> Copy and store them securely
|
||||
before closing this dialog.
|
||||
</Alert>
|
||||
{cert && (
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Client Serial: {cert.serial_number} | Server Serial: {cert.server_serial_number} | Expires: {fmtDate(cert.expires_at)}
|
||||
</Typography>
|
||||
|
||||
{/* CA Root Certificate */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2">CA Root Certificate (ca.crt)</Typography>
|
||||
<Tooltip title={copiedField === 'ca' ? 'Copied!' : 'Copy CA root cert to clipboard'}>
|
||||
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.ca_root_pem, 'ca')} variant="outlined">
|
||||
{copiedField === 'ca' ? 'Copied!' : 'Copy CA Root'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box component="pre" sx={preStyle}>{cert.ca_root_pem}</Box>
|
||||
</Box>
|
||||
|
||||
{/* Client Certificate (mTLS) */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2">Client Certificate — mTLS (client.crt)</Typography>
|
||||
<Tooltip title={copiedField === 'cert' ? 'Copied!' : 'Copy client cert to clipboard'}>
|
||||
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.cert_pem, 'cert')} variant="outlined">
|
||||
{copiedField === 'cert' ? 'Copied!' : 'Copy Client Cert'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box component="pre" sx={preStyle}>{cert.cert_pem}</Box>
|
||||
</Box>
|
||||
|
||||
{/* Client Private Key */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" color="error">Client Private Key (client.key)</Typography>
|
||||
<Tooltip title={copiedField === 'key' ? 'Copied!' : 'Copy client key to clipboard'}>
|
||||
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.key_pem, 'key')} variant="outlined" color="error">
|
||||
{copiedField === 'key' ? 'Copied!' : 'Copy Client Key'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box component="pre" sx={preStyle}>{cert.key_pem}</Box>
|
||||
</Box>
|
||||
|
||||
{/* Server Certificate (Agent TLS) */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2">Server Certificate — Agent TLS (server.crt)</Typography>
|
||||
<Tooltip title={copiedField === 'server-cert' ? 'Copied!' : 'Copy server cert to clipboard'}>
|
||||
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.server_cert_pem, 'server-cert')} variant="outlined">
|
||||
{copiedField === 'server-cert' ? 'Copied!' : 'Copy Server Cert'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box component="pre" sx={preStyle}>{cert.server_cert_pem}</Box>
|
||||
</Box>
|
||||
|
||||
{/* Server Private Key */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" color="error">Server Private Key (server.key)</Typography>
|
||||
<Tooltip title={copiedField === 'server-key' ? 'Copied!' : 'Copy server key to clipboard'}>
|
||||
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.server_key_pem, 'server-key')} variant="outlined" color="error">
|
||||
{copiedField === 'server-key' ? 'Copied!' : 'Copy Server Key'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box component="pre" sx={preStyle}>{cert.server_key_pem}</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleDownloadBundle}
|
||||
disabled={downloading || !cert}
|
||||
>
|
||||
{downloading ? <CircularProgress size={20} /> : 'Download Bundle (.zip)'}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onClose}>I Have Saved the Keys</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CertificatesPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||
|
||||
const [certs, setCerts] = useState<Certificate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [hostFilter, setHostFilter] = useState<string>('')
|
||||
|
||||
// Dialogs
|
||||
const [issueOpen, setIssueOpen] = useState(false)
|
||||
const [issuedCert, setIssuedCert] = useState<IssuedCert | null>(null)
|
||||
const [keyDialogOpen, setKeyDialogOpen] = useState(false)
|
||||
|
||||
// Snackbar
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||
open: false, message: '', severity: 'success',
|
||||
})
|
||||
|
||||
const showSnack = (message: string, severity: 'success' | 'error') =>
|
||||
setSnackbar({ open: true, message, severity })
|
||||
|
||||
// ── Load certs ──────────────────────────────────────────────────────────────
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params: { status?: string; host_id?: string } = {}
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
if (hostFilter.trim()) params.host_id = hostFilter.trim()
|
||||
const res = await certsApi.list(params)
|
||||
setCerts(res.data)
|
||||
} catch {
|
||||
setError('Failed to load certificates')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter, hostFilter])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// ── Download Root CA ────────────────────────────────────────────────────────
|
||||
const handleDownloadRootCa = async () => {
|
||||
try {
|
||||
const res = await certsApi.downloadRootCa()
|
||||
downloadBlob(res.data as Blob, 'ca.crt')
|
||||
} catch {
|
||||
showSnack('Failed to download Root CA certificate', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue cert ──────────────────────────────────────────────────────────────
|
||||
const handleIssued = (cert: IssuedCert) => {
|
||||
setIssuedCert(cert)
|
||||
setKeyDialogOpen(true)
|
||||
void load()
|
||||
}
|
||||
|
||||
// ── Renew cert ──────────────────────────────────────────────────────────────
|
||||
const handleRenew = async (certId: string) => {
|
||||
try {
|
||||
const res = await certsApi.renew(certId)
|
||||
setIssuedCert(res.data)
|
||||
setKeyDialogOpen(true)
|
||||
void load()
|
||||
} catch {
|
||||
showSnack('Failed to renew certificate', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revoke cert ─────────────────────────────────────────────────────────────
|
||||
const handleRevoke = async (certId: string) => {
|
||||
if (!window.confirm('Revoke this certificate? This cannot be undone.')) return
|
||||
try {
|
||||
await certsApi.revoke(certId)
|
||||
showSnack('Certificate revoked', 'success')
|
||||
void load()
|
||||
} catch {
|
||||
showSnack('Failed to revoke certificate', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3, mb: 6 }}>
|
||||
{/* Header */}
|
||||
<Toolbar disableGutters sx={{ mb: 3 }}>
|
||||
<SecurityIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
||||
Certificate Management
|
||||
</Typography>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<SecurityIcon />}
|
||||
onClick={() => setIssueOpen(true)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Issue Client Certificate
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip title="Download Root CA">
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownloadRootCa}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Download Root CA
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Refresh">
|
||||
<span>
|
||||
<IconButton onClick={load} disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Toolbar>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Box display="flex" gap={2} sx={{ mb: 3 }} flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={(e: SelectChangeEvent) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="revoked">Revoked</MenuItem>
|
||||
<MenuItem value="expired">Expired</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Filter by Host ID"
|
||||
value={hostFilter}
|
||||
onChange={(e) => setHostFilter(e.target.value)}
|
||||
placeholder="UUID or partial…"
|
||||
sx={{ minWidth: 260 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Table */}
|
||||
<Paper variant="outlined">
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" py={6}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : certs.length === 0 ? (
|
||||
<Box p={4}>
|
||||
<Alert severity="info">No certificates found.</Alert>
|
||||
</Box>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Common Name</TableCell>
|
||||
<TableCell>Serial Number</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Issued At</TableCell>
|
||||
<TableCell>Expires At</TableCell>
|
||||
<TableCell>Host</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.values(
|
||||
certs.reduce((acc, cert) => {
|
||||
const groupKey = `${cert.host_id || 'unassigned'}-${cert.status}`
|
||||
if (!acc[groupKey]) acc[groupKey] = []
|
||||
acc[groupKey].push(cert)
|
||||
return acc
|
||||
}, {} as Record<string, Certificate[]>)
|
||||
).map((group) => {
|
||||
const primary = group[0]
|
||||
const isPair = group.length > 1
|
||||
const expiring = primary.status === 'active' && isExpiringSoon(primary.expires_at)
|
||||
return (
|
||||
<TableRow key={primary.id} hover>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{primary.common_name}
|
||||
</Typography>
|
||||
{isPair && <Chip label={`${group.length} items`} size="small" color="secondary" variant="outlined" />}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{primary.serial_number}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{statusChip(primary.status)}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{fmtDate(primary.issued_at)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: expiring ? 'error.main' : 'inherit', fontWeight: expiring ? 600 : 400 }}
|
||||
>
|
||||
{fmtDate(primary.expires_at)}
|
||||
{expiring && ' ⚠️'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{primary.host_id ?? <em>Root CA</em>}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{canWrite && (
|
||||
<>
|
||||
<Tooltip title={`Renew certificate ${isPair ? 'pair' : ''}`}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1 }}
|
||||
onClick={() => handleRenew(primary.id)}
|
||||
>
|
||||
Renew
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{primary.status === 'active' && (
|
||||
<Tooltip title={`Revoke certificate ${isPair ? 'pair' : ''}`}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => handleRevoke(primary.id)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Issue Dialog */}
|
||||
<IssueDialog
|
||||
open={issueOpen}
|
||||
onClose={() => setIssueOpen(false)}
|
||||
onIssued={handleIssued}
|
||||
/>
|
||||
|
||||
{/* One-time key display dialog */}
|
||||
<KeyDisplayDialog
|
||||
open={keyDialogOpen}
|
||||
cert={issuedCert}
|
||||
onClose={() => setKeyDialogOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Snackbar */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnackbar((p) => ({ ...p, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity={snackbar.severity}
|
||||
onClose={() => setSnackbar((p) => ({ ...p, open: false }))}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
244
frontend/src/pages/DashboardPage.tsx
Normal file
244
frontend/src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Grid,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Error as ErrorIcon,
|
||||
HourglassEmpty,
|
||||
BugReport,
|
||||
RestartAlt,
|
||||
Refresh as RefreshIcon,
|
||||
Security as SecurityIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { fleetApi, certsApi } from '../api/client'
|
||||
import type { FleetStatus } from '../types'
|
||||
|
||||
// ── StatCard ─────────────────────────────────────────────────────────────────
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
color,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
value: number
|
||||
color: string
|
||||
icon: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card variant="outlined" sx={{ borderLeft: `4px solid ${color}`, height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
|
||||
{icon}
|
||||
<Typography variant="h4" fontWeight={700} lineHeight={1}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{title}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DashboardPage ─────────────────────────────────────────────────────────────
|
||||
export default function DashboardPage() {
|
||||
const [status, setStatus] = useState<FleetStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fleetApi.getStatus()
|
||||
setStatus(res.data)
|
||||
} catch {
|
||||
setError('Failed to load fleet status')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Auto-refresh every 60 seconds
|
||||
useEffect(() => {
|
||||
const t = setInterval(load, 60_000)
|
||||
return () => clearInterval(t)
|
||||
}, [load])
|
||||
|
||||
// ── Download Root CA ──────────────────────────────────────────────────────
|
||||
const handleDownloadRootCa = async () => {
|
||||
try {
|
||||
const res = await certsApi.downloadRootCa()
|
||||
const url = URL.createObjectURL(res.data as Blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'ca.crt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// silently ignore — user will see no download; no state change needed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<Tooltip title="Download Root CA">
|
||||
<IconButton onClick={handleDownloadRootCa}>
|
||||
<SecurityIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Refresh">
|
||||
<span>
|
||||
<IconButton onClick={load} disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Toolbar>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !status && !error && (
|
||||
<Alert severity="info">No fleet data available.</Alert>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<Box>
|
||||
{/* ── Row 1: Status stat cards ── */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title="Healthy"
|
||||
value={status.healthy}
|
||||
color="#2e7d32"
|
||||
icon={<CheckCircle sx={{ color: '#2e7d32' }} />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title="Degraded"
|
||||
value={status.degraded}
|
||||
color="#ed6c02"
|
||||
icon={<Warning sx={{ color: '#ed6c02' }} />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title="Unreachable"
|
||||
value={status.unreachable}
|
||||
color="#d32f2f"
|
||||
icon={<ErrorIcon sx={{ color: '#d32f2f' }} />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title="Pending / Unknown"
|
||||
value={status.pending}
|
||||
color="#9e9e9e"
|
||||
icon={<HourglassEmpty sx={{ color: '#9e9e9e' }} />}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* ── Row 2: Compliance bar ── */}
|
||||
<Card variant="outlined" sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Compliance
|
||||
</Typography>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
{status.compliance_pct.toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(status.compliance_pct, 100)}
|
||||
sx={{
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#e0e0e0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 6,
|
||||
backgroundColor:
|
||||
status.compliance_pct >= 90
|
||||
? '#2e7d32'
|
||||
: status.compliance_pct >= 70
|
||||
? '#ed6c02'
|
||||
: '#d32f2f',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" mt={0.5} display="block">
|
||||
{status.total_hosts} total host{status.total_hosts !== 1 ? 's' : ''} in fleet
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Row 3: Patches + Reboot ── */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
|
||||
<BugReport color="action" />
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
{status.total_pending_patches.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Pending Patches
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
|
||||
<RestartAlt color="action" />
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
{status.hosts_requiring_reboot.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Hosts Requiring Reboot
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
81
frontend/src/pages/GroupsPage.tsx
Normal file
81
frontend/src/pages/GroupsPage.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Box, Button, CircularProgress, Container, Dialog, DialogActions,
|
||||
DialogContent, DialogTitle, IconButton, Paper, Table, TableBody,
|
||||
TableCell, TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography,
|
||||
} from '@mui/material'
|
||||
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { Group } from '../types'
|
||||
|
||||
export default function GroupsPage() {
|
||||
const user = useAuthStore(state => state.user)
|
||||
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [desc, setDesc] = useState('')
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await apiClient.get('/groups'); setGroups(r.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleCreate = async () => {
|
||||
await apiClient.post('/groups', { name, description: desc })
|
||||
setOpen(false); setName(''); setDesc('')
|
||||
load()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this group?')) return
|
||||
await apiClient.delete(`/groups/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Groups</Typography>
|
||||
{canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Create Group</Button>}
|
||||
</Toolbar>
|
||||
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead><TableRow>
|
||||
<TableCell>Name</TableCell><TableCell>Description</TableCell><TableCell>Created</TableCell>{canWrite && <TableCell>Actions</TableCell>}
|
||||
</TableRow></TableHead>
|
||||
<TableBody>
|
||||
{groups.map(g => (
|
||||
<TableRow key={g.id} hover>
|
||||
<TableCell sx={{ fontWeight: 600 }}>{g.name}</TableCell>
|
||||
<TableCell>{g.description || '—'}</TableCell>
|
||||
<TableCell>{new Date(g.created_at).toLocaleDateString()}</TableCell>
|
||||
{canWrite && <TableCell>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => handleDelete(g.id)}><DeleteIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Create Group</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="Name" value={name} onChange={e => setName(e.target.value)} margin="normal" required />
|
||||
<TextField fullWidth label="Description" value={desc} onChange={e => setDesc(e.target.value)} margin="normal" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={!name}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
1367
frontend/src/pages/HostDetailPage.tsx
Normal file
1367
frontend/src/pages/HostDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
340
frontend/src/pages/HostsPage.tsx
Normal file
340
frontend/src/pages/HostsPage.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Box, Button, Chip, CircularProgress, Container, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, IconButton, Paper, Snackbar, Alert,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
TablePagination, TextField, Toolbar, Tooltip, Typography,
|
||||
} from '@mui/material'
|
||||
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon } from '@mui/icons-material'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiClient, hostsApi, enrollmentApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { Host, HostHealthStatus, EnrollmentRequest, EnrollmentConflictResponse } from '../types'
|
||||
|
||||
const statusColor = (s: HostHealthStatus) =>
|
||||
s === 'healthy' ? 'success' : s === 'degraded' ? 'warning' : s === 'unreachable' ? 'error' : 'default'
|
||||
|
||||
export default function HostsPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore(state => state.user)
|
||||
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Host | null>(null)
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' })
|
||||
|
||||
// ── Enrollment state ────────────────────────────────────────────────────
|
||||
const [showPending, setShowPending] = useState(false)
|
||||
const [pendingEnrollments, setPendingEnrollments] = useState<EnrollmentRequest[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [denyTarget, setDenyTarget] = useState<EnrollmentRequest | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [conflictModal, setConflictModal] = useState<{ request: EnrollmentRequest; existingHost: Host } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const offset = page * rowsPerPage
|
||||
const res = await apiClient.get('/hosts', { params: { limit: rowsPerPage, offset } })
|
||||
setHosts(res.data.hosts)
|
||||
setTotal(res.data.total)
|
||||
} catch { /* handled by interceptor */ }
|
||||
finally { setLoading(false) }
|
||||
}, [page, rowsPerPage])
|
||||
|
||||
const loadPending = useCallback(async () => {
|
||||
try {
|
||||
const data = await enrollmentApi.listPending()
|
||||
setPendingEnrollments(data)
|
||||
setPendingCount(data.length)
|
||||
} catch { /* handled by interceptor */ }
|
||||
}, [])
|
||||
|
||||
const handleRefresh = async (e: React.MouseEvent, hostId: string) => {
|
||||
e.stopPropagation()
|
||||
setRefreshing(hostId)
|
||||
try {
|
||||
await hostsApi.refresh(hostId)
|
||||
setTimeout(() => { load(); setRefreshing(null) }, 2000)
|
||||
} catch {
|
||||
setRefreshing(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
await hostsApi.delete(deleteTarget.id)
|
||||
setSnackbar({ open: true, message: `Host "${deleteTarget.display_name || deleteTarget.fqdn}" deleted`, severity: 'success' })
|
||||
load()
|
||||
} catch {
|
||||
setSnackbar({ open: true, message: `Failed to delete host "${deleteTarget.display_name || deleteTarget.fqdn}"`, severity: 'error' })
|
||||
} finally {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enrollment action handlers ──────────────────────────────────────────
|
||||
const handleApprove = async (req: EnrollmentRequest) => {
|
||||
setActionLoading(req.id)
|
||||
try {
|
||||
await enrollmentApi.approve(req.id)
|
||||
setSnackbar({ open: true, message: `Host "${req.fqdn}" approved`, severity: 'success' })
|
||||
load(); loadPending()
|
||||
} catch (err: unknown) {
|
||||
const errObj = err as { response?: { status?: number; data?: EnrollmentConflictResponse }; message?: string }
|
||||
const status = errObj?.response?.status
|
||||
if (status === 409 && errObj.response?.data) {
|
||||
const conflictData = errObj.response.data as EnrollmentConflictResponse
|
||||
setConflictModal({ request: req, existingHost: conflictData.conflict.existing_host })
|
||||
} else {
|
||||
setSnackbar({ open: true, message: `Failed to approve "${req.fqdn}": ${errObj?.message || 'Unknown error'}`, severity: 'error' })
|
||||
}
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeny = async () => {
|
||||
if (!denyTarget) return
|
||||
setActionLoading(denyTarget.id)
|
||||
try {
|
||||
await enrollmentApi.deny(denyTarget.id)
|
||||
setSnackbar({ open: true, message: `Enrollment "${denyTarget.fqdn}" denied`, severity: 'success' })
|
||||
loadPending()
|
||||
} catch {
|
||||
setSnackbar({ open: true, message: `Failed to deny enrollment`, severity: 'error' })
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
setDenyTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConflictResolve = async (action: 'overwrite' | 'cancel') => {
|
||||
if (!conflictModal) return
|
||||
if (action === 'cancel') {
|
||||
setConflictModal(null)
|
||||
return
|
||||
}
|
||||
// For overwrite: delete the existing host first, then approve
|
||||
try {
|
||||
await hostsApi.delete(conflictModal.existingHost.id)
|
||||
await enrollmentApi.approve(conflictModal.request.id)
|
||||
setSnackbar({ open: true, message: `Overwrote existing host and approved "${conflictModal.request.fqdn}"`, severity: 'success' })
|
||||
load(); loadPending()
|
||||
} catch {
|
||||
setSnackbar({ open: true, message: `Failed to resolve conflict`, severity: 'error' })
|
||||
} finally {
|
||||
setConflictModal(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); loadPending() }, [load, loadPending])
|
||||
|
||||
const filtered = hosts.filter(h =>
|
||||
h.fqdn.toLowerCase().includes(search.toLowerCase()) ||
|
||||
h.display_name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const handleChangePage = (_event: React.MouseEvent<HTMLButtonElement> | null, newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10))
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Hosts</Typography>
|
||||
<Tooltip title="Show pending enrollments">
|
||||
<Button
|
||||
variant={showPending ? "contained" : "outlined"}
|
||||
color="warning"
|
||||
startIcon={<PendingIcon />}
|
||||
onClick={() => setShowPending(s => !s)}
|
||||
sx={{ mr: 1 }}
|
||||
endIcon={pendingCount > 0 ? <Chip label={pendingCount} size="small" color="warning" variant="filled" sx={{ ml: 0.5 }} /> : undefined}
|
||||
>
|
||||
Pending
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TextField size="small" placeholder="Search..." value={search}
|
||||
onChange={e => setSearch(e.target.value)} sx={{ mr: 2 }} />
|
||||
<Tooltip title="Refresh"><IconButton onClick={() => { load(); loadPending() }}><RefreshIcon /></IconButton></Tooltip>
|
||||
{canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>}
|
||||
</Toolbar>
|
||||
{loading ? <Box display="flex" justifyContent="center" mt="4"><CircularProgress /></Box> : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>FQDN</TableCell>
|
||||
<TableCell>Display Name</TableCell>
|
||||
<TableCell>IP Address</TableCell>
|
||||
<TableCell>OS</TableCell>
|
||||
<TableCell>Health</TableCell>
|
||||
<TableCell>Checks</TableCell>
|
||||
<TableCell>Agent</TableCell>
|
||||
{canWrite && <TableCell>Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{showPending ? (
|
||||
pendingEnrollments.map(req => (
|
||||
<TableRow key={req.id} hover sx={{ backgroundColor: '#fff8e1' }}>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<GppMaybeIcon color="warning" fontSize="small" />
|
||||
{req.fqdn}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{req.fqdn}</TableCell>
|
||||
<TableCell>{req.ip_address}</TableCell>
|
||||
<TableCell>{(req.os_details['name'] as string) ?? 'Unknown'}</TableCell>
|
||||
<TableCell><Chip size="small" label="pending" color="warning" /></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell>—</TableCell>
|
||||
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Approve">
|
||||
<IconButton size="small" color="success"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={(e) => { e.stopPropagation(); handleApprove(req) }}>
|
||||
{actionLoading === req.id ? <CircularProgress size={16} /> : <CheckCircleOutlineIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Deny">
|
||||
<IconButton size="small" color="error"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={(e) => { e.stopPropagation(); setDenyTarget(req) }}>
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
filtered.map(h => (
|
||||
<TableRow key={h.id} hover sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/hosts/${h.id}`)}>
|
||||
<TableCell>{h.fqdn}</TableCell>
|
||||
<TableCell>{h.display_name}</TableCell>
|
||||
<TableCell>{h.ip_address}</TableCell>
|
||||
<TableCell>{h.os_name ?? h.os_family ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={h.health_status} color={statusColor(h.health_status)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.health_check_status === 'all_healthy' ? (
|
||||
<Tooltip title="All checks healthy"><CheckCircleIcon color="success" fontSize="small" /></Tooltip>
|
||||
) : h.health_check_status === 'some_unhealthy' ? (
|
||||
<Tooltip title="Some checks unhealthy"><CancelIcon color="error" fontSize="small" /></Tooltip>
|
||||
) : (
|
||||
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{h.agent_version ?? '—'}</TableCell>
|
||||
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Request refresh">
|
||||
<IconButton size="small" color="primary"
|
||||
disabled={refreshing === h.id}
|
||||
onClick={(e) => handleRefresh(e, h.id)}>
|
||||
{refreshing === h.id
|
||||
? <CircularProgress size={16} />
|
||||
: <RefreshIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteTarget(h) }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton></Tooltip>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{!showPending && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Dialog open={deleteTarget !== null} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete host “{deleteTarget?.display_name || deleteTarget?.fqdn}”?
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error" variant="contained">Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Deny Confirmation Dialog ─────────────────────────────────── */}
|
||||
<Dialog open={denyTarget !== null} onClose={() => setDenyTarget(null)}>
|
||||
<DialogTitle>Confirm Deny</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to deny the enrollment for “{denyTarget?.fqdn}”? This action cannot be undone.
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDenyTarget(null)}>Cancel</Button>
|
||||
<Button onClick={handleDeny} color="error" variant="contained" disabled={actionLoading === denyTarget?.id}>
|
||||
{actionLoading === denyTarget?.id ? <CircularProgress size={20} /> : 'Deny'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Conflict Modal ───────────────────────────────────────────── */}
|
||||
<Dialog open={conflictModal !== null} onClose={() => setConflictModal(null)}>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningAmberIcon color="warning" /> Host Collision Detected
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Approving “{conflictModal?.request.fqdn}” conflicts with an existing host:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle2">Existing Host</Typography>
|
||||
<Typography>FQDN: {conflictModal?.existingHost.fqdn}</Typography>
|
||||
<Typography>IP: {conflictModal?.existingHost.ip_address}</Typography>
|
||||
<Typography>ID: {conflictModal?.existingHost.id}</Typography>
|
||||
</Paper>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Options:
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => handleConflictResolve('cancel')}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => handleConflictResolve('overwrite')}
|
||||
color="error"
|
||||
variant="contained"
|
||||
>
|
||||
Overwrite Existing Host
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar open={snackbar.open} autoHideDuration={4000} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<Alert severity={snackbar.severity} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
|
||||
sx={{ width: '100%' }}>{snackbar.message}</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
602
frontend/src/pages/JobsPage.tsx
Normal file
602
frontend/src/pages/JobsPage.tsx
Normal file
@ -0,0 +1,602 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Cancel as CancelIcon,
|
||||
ExpandLess,
|
||||
ExpandMore,
|
||||
Refresh as RefreshIcon,
|
||||
Replay as ReplayIcon,
|
||||
Wifi as WifiIcon,
|
||||
WifiOff as WifiOffIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { jobsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useJobWebSocket } from '../hooks/useJobWebSocket'
|
||||
import type { JobStatus, JobKind, PatchJobSummary, PatchJob, PatchJobHost, JobWsEvent } from '../types'
|
||||
|
||||
// ── Status chip ───────────────────────────────────────────────────────────────
|
||||
type ChipColor = 'default' | 'info' | 'warning' | 'success' | 'error'
|
||||
|
||||
function statusColor(status: JobStatus): ChipColor {
|
||||
const map: Record<JobStatus, ChipColor> = {
|
||||
queued: 'default',
|
||||
pending: 'info',
|
||||
running: 'warning',
|
||||
succeeded: 'success',
|
||||
failed: 'error',
|
||||
cancelled: 'default',
|
||||
}
|
||||
return map[status]
|
||||
}
|
||||
|
||||
function StatusChip({ status }: { status: JobStatus }) {
|
||||
return <Chip label={status} color={statusColor(status)} size="small" />
|
||||
}
|
||||
|
||||
// ── Kind label ────────────────────────────────────────────────────────────────
|
||||
function kindLabel(kind: JobKind): string {
|
||||
const map: Record<JobKind, string> = {
|
||||
patch_apply: 'Patch Apply',
|
||||
patch_remove: 'Patch Remove',
|
||||
reboot: 'Reboot',
|
||||
rollback: 'Rollback',
|
||||
}
|
||||
return map[kind]
|
||||
}
|
||||
|
||||
// ── Format date ───────────────────────────────────────────────────────────────
|
||||
function fmtDate(iso?: string): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
// ── Per-host detail table ─────────────────────────────────────────────────────
|
||||
function HostDetailTable({ hosts }: { hosts: PatchJobHost[] }) {
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<Box py={2} px={3}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No host entries for this job.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box sx={{ backgroundColor: 'action.hover', px: 2, pb: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Host</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Agent Job ID</TableCell>
|
||||
<TableCell>Retries</TableCell>
|
||||
<TableCell>Error</TableCell>
|
||||
<TableCell>Started</TableCell>
|
||||
<TableCell>Completed</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{hosts.map((h) => (
|
||||
<TableRow key={h.id}>
|
||||
<TableCell>{h.host_display_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusChip status={h.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" fontFamily="monospace">
|
||||
{h.agent_job_id ?? '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{h.retry_count}</TableCell>
|
||||
<TableCell>
|
||||
{h.error_message ? (
|
||||
<Tooltip title={h.error_message}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{
|
||||
maxWidth: 200,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{h.error_message}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{fmtDate(h.started_at)}</TableCell>
|
||||
<TableCell>{fmtDate(h.completed_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Expandable job row ────────────────────────────────────────────────────────
|
||||
interface JobRowProps {
|
||||
job: PatchJobSummary
|
||||
expanded: boolean
|
||||
onToggle: (id: string) => void
|
||||
onCancel: (id: string) => void
|
||||
onRollback: (id: string) => void
|
||||
cancelLoading: boolean
|
||||
rollbackLoading: boolean
|
||||
detail: PatchJob | null
|
||||
detailLoading: boolean
|
||||
detailError: string | null
|
||||
canWrite: boolean
|
||||
}
|
||||
|
||||
function JobRow({
|
||||
job,
|
||||
expanded,
|
||||
onToggle,
|
||||
onCancel,
|
||||
onRollback,
|
||||
cancelLoading,
|
||||
rollbackLoading,
|
||||
detail,
|
||||
detailLoading,
|
||||
detailError,
|
||||
canWrite,
|
||||
}: JobRowProps) {
|
||||
const canCancel = job.status === 'queued' || job.status === 'pending'
|
||||
const canRollback = job.status === 'succeeded'
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
hover
|
||||
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'none' : undefined } }}
|
||||
onClick={() => onToggle(job.id)}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onToggle(job.id) }}>
|
||||
{expanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" fontFamily="monospace">
|
||||
{fmtDate(job.created_at)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{kindLabel(job.kind)}</TableCell>
|
||||
<TableCell>
|
||||
<StatusChip status={job.status} />
|
||||
</TableCell>
|
||||
<TableCell align="right">{job.host_count}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography color="success.main" fontWeight={600}>
|
||||
{job.succeeded_count}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography color={job.failed_count > 0 ? 'error.main' : 'text.primary'} fontWeight={600}>
|
||||
{job.failed_count}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={job.immediate ? 'Immediate' : 'Scheduled'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
maxWidth: 180,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{job.notes || '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{canWrite ? <Box display="flex" gap={0.5}>
|
||||
{canCancel && (
|
||||
<Tooltip title="Cancel job">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={cancelLoading}
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
{cancelLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<CancelIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canRollback && (
|
||||
<Tooltip title="Rollback job">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="warning"
|
||||
disabled={rollbackLoading}
|
||||
onClick={() => onRollback(job.id)}
|
||||
>
|
||||
{rollbackLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<ReplayIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box> : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* ── Expandable detail row ── */}
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} sx={{ py: 0, border: expanded ? undefined : 'none' }}>
|
||||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||
{detailLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={2}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : detailError ? (
|
||||
<Alert severity="error" sx={{ m: 1 }}>
|
||||
{detailError}
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<HostDetailTable hosts={detail.hosts} />
|
||||
) : null}
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── JobsPage ──────────────────────────────────────────────────────────────────
|
||||
export default function JobsPage() {
|
||||
const user = useAuthStore(state => state.user)
|
||||
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||
const [jobs, setJobs] = useState<PatchJobSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
// Expanded row detail state
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [details, setDetails] = useState<Record<string, PatchJob>>({})
|
||||
const [detailLoading, setDetailLoading] = useState<Record<string, boolean>>({})
|
||||
const [detailError, setDetailError] = useState<Record<string, string>>({})
|
||||
|
||||
// Action state
|
||||
const [cancelLoadingId, setCancelLoadingId] = useState<string | null>(null)
|
||||
const [rollbackLoadingId, setRollbackLoadingId] = useState<string | null>(null)
|
||||
|
||||
// Rollback confirm dialog
|
||||
const [rollbackTargetId, setRollbackTargetId] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const LIMIT = 25
|
||||
|
||||
const loadJobs = useCallback(async (newOffset = 0) => {
|
||||
if (newOffset === 0) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
try {
|
||||
const res = await jobsApi.list({ limit: LIMIT, offset: newOffset })
|
||||
const data = res.data as { jobs?: PatchJobSummary[]; total?: number } | PatchJobSummary[]
|
||||
const items: PatchJobSummary[] = Array.isArray(data) ? data : (data.jobs ?? [])
|
||||
const total: number = Array.isArray(data) ? items.length : (data.total ?? items.length)
|
||||
if (newOffset === 0) {
|
||||
setJobs(items)
|
||||
} else {
|
||||
setJobs((prev) => [...prev, ...items])
|
||||
}
|
||||
setOffset(newOffset + items.length)
|
||||
setHasMore(newOffset + items.length < total)
|
||||
} catch {
|
||||
if (newOffset === 0) setError('Failed to load jobs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs(0)
|
||||
}, [loadJobs])
|
||||
|
||||
// ── WS event handler — surgical state updates ─────────────────────────────
|
||||
const handleWsEvent = useCallback((event: JobWsEvent) => {
|
||||
if (event.event_type === 'job') {
|
||||
// ── Job-level event: authoritative status + counts from backend ──
|
||||
setJobs((prev) =>
|
||||
prev.map((job) => {
|
||||
if (job.id !== event.job_id) return job
|
||||
return {
|
||||
...job,
|
||||
status: event.status,
|
||||
succeeded_count: event.succeeded_count ?? job.succeeded_count,
|
||||
failed_count: event.failed_count ?? job.failed_count,
|
||||
host_count: event.host_count ?? job.host_count,
|
||||
}
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// ── Host-level event: update detail row + optimistic counters only ──
|
||||
setJobs((prev) =>
|
||||
prev.map((job) => {
|
||||
if (job.id !== event.job_id) return job
|
||||
const updated = { ...job }
|
||||
// Optimistically increment counters when a host reaches a terminal state.
|
||||
// The authoritative rollup will arrive as a job-level event later.
|
||||
if (event.status === 'succeeded') {
|
||||
updated.succeeded_count = job.succeeded_count + 1
|
||||
} else if (event.status === 'failed') {
|
||||
updated.failed_count = job.failed_count + 1
|
||||
}
|
||||
// If any host is still running, ensure the job shows 'running'.
|
||||
// Do NOT promote host status to job status — only the job-level
|
||||
// event can set the parent job to a terminal state.
|
||||
if (event.status === 'running' && job.status === 'queued') {
|
||||
updated.status = 'running'
|
||||
}
|
||||
return updated
|
||||
})
|
||||
)
|
||||
|
||||
// Update the host row in the expanded detail panel if loaded.
|
||||
setDetails((prev) => {
|
||||
const detail = prev[event.job_id]
|
||||
if (!detail) return prev
|
||||
const updatedHosts = detail.hosts.map((h) => {
|
||||
if (h.host_id !== event.host_id) return h
|
||||
return {
|
||||
...h,
|
||||
status: event.status,
|
||||
...(event.error_message ? { error_message: event.error_message } : {}),
|
||||
...(event.agent_job_id ? { agent_job_id: event.agent_job_id } : {}),
|
||||
}
|
||||
})
|
||||
return { ...prev, [event.job_id]: { ...detail, hosts: updatedHosts } }
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── WebSocket connection ──────────────────────────────────────────────────
|
||||
const { connected } = useJobWebSocket({ onEvent: handleWsEvent })
|
||||
|
||||
// ── Action handlers ───────────────────────────────────────────────────────
|
||||
const handleToggleExpand = useCallback(async (id: string) => {
|
||||
if (expandedId === id) {
|
||||
setExpandedId(null)
|
||||
return
|
||||
}
|
||||
setExpandedId(id)
|
||||
if (details[id]) return
|
||||
setDetailLoading((prev) => ({ ...prev, [id]: true }))
|
||||
setDetailError((prev) => { const n = { ...prev }; delete n[id]; return n })
|
||||
try {
|
||||
const res = await jobsApi.get(id)
|
||||
setDetails((prev) => ({ ...prev, [id]: res.data as PatchJob }))
|
||||
} catch {
|
||||
setDetailError((prev) => ({ ...prev, [id]: 'Failed to load job detail' }))
|
||||
} finally {
|
||||
setDetailLoading((prev) => ({ ...prev, [id]: false }))
|
||||
}
|
||||
}, [expandedId, details])
|
||||
|
||||
const handleCancel = useCallback(async (id: string) => {
|
||||
setCancelLoadingId(id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await jobsApi.cancel(id)
|
||||
await loadJobs(0)
|
||||
} catch {
|
||||
setActionError(`Failed to cancel job ${id}`)
|
||||
} finally {
|
||||
setCancelLoadingId(null)
|
||||
}
|
||||
}, [loadJobs])
|
||||
|
||||
const handleRollbackConfirm = useCallback(async () => {
|
||||
if (!rollbackTargetId) return
|
||||
const id = rollbackTargetId
|
||||
setRollbackTargetId(null)
|
||||
setRollbackLoadingId(id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await jobsApi.rollback(id)
|
||||
await loadJobs(0)
|
||||
} catch {
|
||||
setActionError(`Failed to rollback job ${id}`)
|
||||
} finally {
|
||||
setRollbackLoadingId(null)
|
||||
}
|
||||
}, [rollbackTargetId, loadJobs])
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
||||
Jobs
|
||||
</Typography>
|
||||
|
||||
{/* WS connection status indicator */}
|
||||
<Tooltip title={connected ? 'Live updates connected' : 'Live updates disconnected'}>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={0.5}
|
||||
sx={{ mr: 1, color: connected ? 'success.main' : 'text.disabled' }}
|
||||
>
|
||||
{connected
|
||||
? <WifiIcon fontSize="small" />
|
||||
: <WifiOffIcon fontSize="small" />}
|
||||
<Typography variant="caption">
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Refresh">
|
||||
<span>
|
||||
<IconButton onClick={() => loadJobs(0)} disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Toolbar>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setActionError(null)}>
|
||||
{actionError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined">
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox" />
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell>Kind</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Hosts</TableCell>
|
||||
<TableCell align="right">Succeeded</TableCell>
|
||||
<TableCell align="right">Failed</TableCell>
|
||||
<TableCell>Schedule</TableCell>
|
||||
<TableCell>Notes</TableCell>
|
||||
{canWrite && <TableCell>Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading && jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" sx={{ py: 4 }}>
|
||||
<CircularProgress size={32} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" sx={{ py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No jobs found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
expanded={expandedId === job.id}
|
||||
onToggle={handleToggleExpand}
|
||||
onCancel={handleCancel}
|
||||
onRollback={(id) => setRollbackTargetId(id)}
|
||||
cancelLoading={cancelLoadingId === job.id}
|
||||
rollbackLoading={rollbackLoadingId === job.id}
|
||||
detail={details[job.id] ?? null}
|
||||
detailLoading={detailLoading[job.id] ?? false}
|
||||
detailError={detailError[job.id] ?? null}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{hasMore && (
|
||||
<Box display="flex" justifyContent="center" py={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => loadJobs(offset)}
|
||||
disabled={loadingMore}
|
||||
startIcon={loadingMore ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
{loadingMore ? 'Loading…' : 'Load More'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Rollback confirm dialog ── */}
|
||||
<Dialog
|
||||
open={rollbackTargetId !== null}
|
||||
onClose={() => setRollbackTargetId(null)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Confirm Rollback</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
Are you sure you want to rollback job{' '}
|
||||
<strong>{rollbackTargetId}</strong>? This will create a new rollback job
|
||||
that attempts to revert the applied patches.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRollbackTargetId(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={handleRollbackConfirm}
|
||||
>
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
317
frontend/src/pages/LoginPage.tsx
Normal file
317
frontend/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,317 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box, Button, Container, TextField, Typography,
|
||||
Alert, CircularProgress, Paper, InputAdornment, IconButton,
|
||||
List, ListItem, ListItemIcon, ListItemText,
|
||||
Divider,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Visibility, VisibilityOff,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
Cloud as CloudIcon, VpnKey as KeyIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { authApi, ssoConfigApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { User } from '../types'
|
||||
|
||||
// ── WebAuthn utility functions ──────────────────────────────────────────────
|
||||
|
||||
function arrayBufferToBase64url(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function base64urlToArrayBuffer(base64url: string): ArrayBuffer {
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const binary = atob(base64 + padding)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error && err.message === 'Network Error') {
|
||||
return 'Unable to connect to the server. Please check your network connection and try again.'
|
||||
}
|
||||
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
|
||||
if (status === 429) return 'Too many login attempts. Please wait a moment and try again.'
|
||||
if (code === 'mfa_required') return 'MFA_REQUIRED'
|
||||
if (code === 'mfa_required_webauthn') return 'MFA_REQUIRED_WEBAUTHN'
|
||||
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.'
|
||||
}
|
||||
|
||||
function checkPasswordStrength(password: string) {
|
||||
return {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
digit: /[0-9]/.test(password),
|
||||
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
|
||||
}
|
||||
}
|
||||
|
||||
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
|
||||
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setTokens, setUser } = useAuthStore()
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [needsMfa, setNeedsMfa] = useState(false)
|
||||
const [needsWebAuthn, setNeedsWebAuthn] = useState(false)
|
||||
const [webAuthnLoading, setWebAuthnLoading] = useState(false)
|
||||
const [forcePasswordReset, setForcePasswordReset] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false)
|
||||
const [ssoDisplayName, setSsoDisplayName] = useState('SSO')
|
||||
const [ssoAuthUrl, setSsoAuthUrl] = useState('/api/v1/auth/sso/login')
|
||||
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState('')
|
||||
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||
const [passwordChanged, setPasswordChanged] = useState(false)
|
||||
|
||||
const pwChecks = checkPasswordStrength(newPassword)
|
||||
const pwValid = isPasswordValid(pwChecks)
|
||||
const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword)
|
||||
|
||||
useEffect(() => {
|
||||
ssoConfigApi.get().then(({ data }) => {
|
||||
setSsoEnabled(data.enabled)
|
||||
setSsoDisplayName(data.display_name || 'SSO')
|
||||
if (data.auth_url) setSsoAuthUrl(data.auth_url)
|
||||
}).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
|
||||
setTokens(access_token, refresh_token)
|
||||
setUser(user as User)
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err)
|
||||
if (message === 'MFA_REQUIRED') {
|
||||
setNeedsMfa(true)
|
||||
setError('Please enter your MFA code.')
|
||||
} else if (message === 'MFA_REQUIRED_WEBAUTHN') {
|
||||
setNeedsWebAuthn(true)
|
||||
setError('Please authenticate with your security key.')
|
||||
} else if (message === 'PASSWORD_RESET_REQUIRED') {
|
||||
setForcePasswordReset(true)
|
||||
setError('You must change your password before logging in.')
|
||||
} else if (message === 'ACCOUNT_LOCKED') {
|
||||
setError('Account locked due to too many failed login attempts. Please try again in 30 minutes.')
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebAuthnLogin = async () => {
|
||||
setWebAuthnLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const startRes = await authApi.webauthnAuthenticateStart()
|
||||
const { challenge_key, assertion_options } = startRes.data
|
||||
|
||||
const publicKey = assertion_options.publicKey
|
||||
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
|
||||
...publicKey,
|
||||
challenge: base64urlToArrayBuffer(publicKey.challenge),
|
||||
allowCredentials: publicKey.allowCredentials?.map((c: { type: string; id: string }) => ({
|
||||
...c,
|
||||
id: base64urlToArrayBuffer(c.id),
|
||||
})),
|
||||
}
|
||||
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyCredentialRequestOptions,
|
||||
}) as PublicKeyCredential | null
|
||||
|
||||
if (!assertion) {
|
||||
setError('Security key authentication was cancelled.')
|
||||
return
|
||||
}
|
||||
|
||||
const response = assertion.response as AuthenticatorAssertionResponse
|
||||
const serializedAssertion = {
|
||||
id: assertion.id,
|
||||
rawId: arrayBufferToBase64url(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
authenticatorData: arrayBufferToBase64url(response.authenticatorData),
|
||||
clientDataJSON: arrayBufferToBase64url(response.clientDataJSON),
|
||||
signature: arrayBufferToBase64url(response.signature),
|
||||
userHandle: response.userHandle ? arrayBufferToBase64url(response.userHandle) : null,
|
||||
},
|
||||
}
|
||||
|
||||
const completeRes = await authApi.webauthnAuthenticateComplete(challenge_key, serializedAssertion)
|
||||
if (completeRes.data.access_token && completeRes.data.refresh_token) {
|
||||
const { access_token, refresh_token, user } = completeRes.data
|
||||
setTokens(access_token, refresh_token)
|
||||
setUser(user as User)
|
||||
navigate('/dashboard', { replace: true })
|
||||
} else {
|
||||
setError('WebAuthn authentication succeeded. Please try logging in again.')
|
||||
setNeedsWebAuthn(false)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { name?: string; response?: { data?: { error?: { message?: string } } }; message?: string };
|
||||
if (error.name === 'NotAllowedError') {
|
||||
setError('Security key authentication was cancelled or timed out.');
|
||||
} else {
|
||||
const msg = error.response?.data?.error?.message || error.message || 'Authentication failed.';
|
||||
setError(`Security key authentication failed: ${msg}`);
|
||||
}
|
||||
} finally {
|
||||
setWebAuthnLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForceChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pwValid || pwMismatch) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await authApi.forceChangePassword(username, password, newPassword)
|
||||
setPasswordChanged(true)
|
||||
setForcePasswordReset(false)
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
setPassword('')
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
|
||||
const code = axiosErr.response?.data?.error?.code
|
||||
const msg = axiosErr.response?.data?.error?.message
|
||||
if (code === 'weak_password') {
|
||||
setError(msg || 'Password does not meet strength requirements.')
|
||||
} else if (code === 'invalid_credentials') {
|
||||
setError('Invalid username or password.')
|
||||
} else {
|
||||
setError(msg || 'Failed to change password. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setForcePasswordReset(false)
|
||||
setPasswordChanged(false)
|
||||
setError(null)
|
||||
setPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
}
|
||||
|
||||
const ssoIcon = ssoDisplayName.toLowerCase().includes('keycloak') ? <KeyIcon /> : <CloudIcon />
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs" sx={{ mt: 12 }}>
|
||||
<Paper elevation={4} sx={{ p: 4 }}>
|
||||
<Typography variant="h5" fontWeight={700} mb={3} align="center">
|
||||
🐉 Linux Patch Manager
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<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>
|
||||
</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> }} />
|
||||
{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>
|
||||
</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>
|
||||
</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> }} />
|
||||
{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" />
|
||||
)}
|
||||
{needsWebAuthn && (
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<KeyIcon />}
|
||||
onClick={handleWebAuthnLogin}
|
||||
disabled={webAuthnLoading}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{webAuthnLoading ? <CircularProgress size={24} /> : 'Use Security Key'}
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary" display="block" textAlign="center">
|
||||
Touch your security key or use your device biometrics to authenticate.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<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={() => { const state = Array.from(crypto.getRandomValues(new Uint8Array(16))).map(b => b.toString(16).padStart(2, '0')).join(''); sessionStorage.setItem('sso_csrf_state', state); window.location.href = ssoAuthUrl }} disabled={loading}>Sign in with {ssoDisplayName}</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
680
frontend/src/pages/MaintenanceWindowsPage.tsx
Normal file
680
frontend/src/pages/MaintenanceWindowsPage.tsx
Normal file
@ -0,0 +1,680 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Snackbar,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { maintenanceWindowsApi, hostsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { Host, MaintenanceWindow, WindowRecurrence } from '../types'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function recurrenceLabel(r: WindowRecurrence): string {
|
||||
const map: Record<WindowRecurrence, string> = {
|
||||
once: 'One-Time',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
}
|
||||
return map[r]
|
||||
}
|
||||
|
||||
function recurrenceColor(r: WindowRecurrence): 'default' | 'primary' | 'secondary' | 'info' {
|
||||
const map: Record<WindowRecurrence, 'default' | 'primary' | 'secondary' | 'info'> = {
|
||||
once: 'default',
|
||||
daily: 'primary',
|
||||
weekly: 'secondary',
|
||||
monthly: 'info',
|
||||
}
|
||||
return map[r]
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
function fmtTimeOnly(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })
|
||||
}
|
||||
|
||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
function scheduleDescription(w: MaintenanceWindow): string {
|
||||
const dur = `${w.duration_minutes} min`
|
||||
const time = fmtTimeOnly(w.start_at)
|
||||
switch (w.recurrence) {
|
||||
case 'once':
|
||||
return `Once at ${fmtDate(w.start_at)} for ${dur}`
|
||||
case 'daily':
|
||||
return `Every day at ${time} for ${dur}`
|
||||
case 'weekly': {
|
||||
const day = w.recurrence_day != null ? DAY_NAMES[w.recurrence_day] ?? `Day ${w.recurrence_day}` : '?' // eslint-disable-line eqeqeq
|
||||
return `Every ${day} at ${time} for ${dur}`
|
||||
}
|
||||
case 'monthly': {
|
||||
const day = w.recurrence_day != null ? w.recurrence_day : '?' // eslint-disable-line eqeqeq
|
||||
return `Monthly on day ${day} at ${time} for ${dur}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default form values ───────────────────────────────────────────────────────
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM"
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
label: string
|
||||
recurrence: WindowRecurrence
|
||||
start_at: string
|
||||
duration_minutes: number
|
||||
recurrence_day: number | ''
|
||||
enabled: boolean
|
||||
auto_apply: boolean
|
||||
}
|
||||
|
||||
function defaultForm(): FormValues {
|
||||
return {
|
||||
label: '',
|
||||
recurrence: 'once',
|
||||
start_at: nowIso(),
|
||||
duration_minutes: 60,
|
||||
recurrence_day: '',
|
||||
enabled: true,
|
||||
auto_apply: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Window form dialog ────────────────────────────────────────────────────────
|
||||
|
||||
interface WindowFormDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
initial: FormValues
|
||||
onClose: () => void
|
||||
onSubmit: (values: FormValues) => Promise<void>
|
||||
}
|
||||
|
||||
function WindowFormDialog({ open, title, initial, onClose, onSubmit }: WindowFormDialogProps) {
|
||||
const [form, setForm] = useState<FormValues>(initial)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
// Reset form when dialog opens with new initial values
|
||||
useEffect(() => { setForm(initial); setErr(null) }, [open, initial])
|
||||
|
||||
const set = (field: keyof FormValues, value: FormValues[keyof FormValues]) =>
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const needsDay = form.recurrence === 'weekly' || form.recurrence === 'monthly'
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.label.trim()) { setErr('Label is required'); return }
|
||||
if (needsDay && form.recurrence_day === '') { setErr('Recurrence day is required'); return }
|
||||
setSaving(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onSubmit(form)
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message ?? 'Failed to save window'
|
||||
setErr(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
{err && <Alert severity="error">{err}</Alert>}
|
||||
|
||||
<TextField
|
||||
label="Label"
|
||||
value={form.label}
|
||||
onChange={e => set('label', e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Recurrence</InputLabel>
|
||||
<Select
|
||||
label="Recurrence"
|
||||
value={form.recurrence}
|
||||
onChange={e => set('recurrence', e.target.value as WindowRecurrence)}
|
||||
>
|
||||
<MenuItem value="once">One-Time</MenuItem>
|
||||
<MenuItem value="daily">Daily</MenuItem>
|
||||
<MenuItem value="weekly">Weekly</MenuItem>
|
||||
<MenuItem value="monthly">Monthly</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label={form.recurrence === 'once' ? 'Start Date & Time (UTC)' : 'Reference Time (UTC)'}
|
||||
type="datetime-local"
|
||||
value={form.start_at}
|
||||
onChange={e => set('start_at', e.target.value)}
|
||||
fullWidth
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
helperText={
|
||||
form.recurrence === 'once'
|
||||
? 'When the window begins'
|
||||
: 'Time of day for the recurring window (date part ignored)'
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={form.duration_minutes}
|
||||
onChange={e => set('duration_minutes', parseInt(e.target.value, 10) || 60)}
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { min: 1, max: 1440 } }}
|
||||
/>
|
||||
|
||||
{form.recurrence === 'weekly' && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Day of Week</InputLabel>
|
||||
<Select
|
||||
label="Day of Week"
|
||||
value={form.recurrence_day}
|
||||
onChange={e => set('recurrence_day', Number(e.target.value))}
|
||||
>
|
||||
{DAY_NAMES.map((name, i) => (
|
||||
<MenuItem key={i} value={i}>{name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{form.recurrence === 'monthly' && (
|
||||
<TextField
|
||||
label="Day of Month (1-31)"
|
||||
type="number"
|
||||
value={form.recurrence_day}
|
||||
onChange={e => set('recurrence_day', parseInt(e.target.value, 10) || 1)}
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { min: 1, max: 31 } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onChange={e => set('enabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.auto_apply}
|
||||
onChange={e => set('auto_apply', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Auto-Apply Patches"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
|
||||
When enabled, pending patches are automatically applied during this window.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? <CircularProgress size={20} /> : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirm delete dialog ──────────────────────────────────────────────────────
|
||||
|
||||
interface ConfirmDeleteProps {
|
||||
open: boolean
|
||||
windowLabel: string
|
||||
onClose: () => void
|
||||
onConfirm: () => Promise<void>
|
||||
}
|
||||
|
||||
function ConfirmDeleteDialog({ open, windowLabel, onClose, onConfirm }: ConfirmDeleteProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
await onConfirm()
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete Window</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Delete maintenance window <strong>{windowLabel}</strong>? This cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={handleConfirm} disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-host windows table ────────────────────────────────────────────────────
|
||||
|
||||
interface HostWindowsTableProps {
|
||||
host: Host
|
||||
windows: MaintenanceWindow[]
|
||||
onEdit: (w: MaintenanceWindow) => void
|
||||
onDelete: (w: MaintenanceWindow) => void
|
||||
onAdd: (hostId: string) => void
|
||||
canWrite: boolean
|
||||
}
|
||||
|
||||
function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd, canWrite }: HostWindowsTableProps) {
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
backgroundColor: 'action.hover',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ScheduleIcon fontSize="small" color="primary" />
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
{host.display_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({host.fqdn})
|
||||
</Typography>
|
||||
</Box>
|
||||
{canWrite && <Button
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => onAdd(host.id)}
|
||||
>
|
||||
Add Window
|
||||
</Button>}
|
||||
</Box>
|
||||
|
||||
{windows.length === 0 ? (
|
||||
<Box px={2} py={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No maintenance windows configured. Queued jobs will not execute until a window is added.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Label</TableCell>
|
||||
<TableCell>Schedule</TableCell>
|
||||
<TableCell>Recurrence</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Auto-Apply</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
{canWrite && <TableCell align="right">Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{windows.map(w => (
|
||||
<TableRow key={w.id} hover>
|
||||
<TableCell>{w.label}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{scheduleDescription(w)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={recurrenceLabel(w.recurrence)}
|
||||
color={recurrenceColor(w.recurrence)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={w.enabled ? 'Enabled' : 'Disabled'}
|
||||
color={w.enabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={w.auto_apply ? 'On' : 'Off'}
|
||||
color={w.auto_apply ? 'info' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{fmtDate(w.created_at)}</TableCell>
|
||||
{canWrite && <TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => onEdit(w)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(w)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MaintenanceWindowsPage() {
|
||||
const user = useAuthStore(state => state.user)
|
||||
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [windowsByHost, setWindowsByHost] = useState<Record<string, MaintenanceWindow[]>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||
open: false, message: '', severity: 'success',
|
||||
})
|
||||
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createHostId, setCreateHostId] = useState<string | null>(null)
|
||||
const [createForm, setCreateForm] = useState<FormValues>(defaultForm())
|
||||
|
||||
// Edit dialog state
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editWindow, setEditWindow] = useState<MaintenanceWindow | null>(null)
|
||||
const [editForm, setEditForm] = useState<FormValues>(defaultForm())
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteWindow, setDeleteWindow] = useState<MaintenanceWindow | null>(null)
|
||||
|
||||
// ── AbortController ref for cancelling stale fetches ──────────────────────
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// ── Fetch hosts + all maintenance windows in 2 parallel requests ─────────
|
||||
// Uses bulk /maintenance-windows endpoint instead of N+1 per-host calls.
|
||||
// State updates are batched atomically so React never renders hosts without
|
||||
// their windows (the root cause of the "randomly missing data" bug).
|
||||
const fetchData = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Fetch hosts and ALL windows in parallel — 2 requests, not N+1.
|
||||
const [hostsRes, windowsRes] = await Promise.all([
|
||||
hostsApi.list({ limit: 500 }),
|
||||
maintenanceWindowsApi.listAll(),
|
||||
])
|
||||
|
||||
// If the request was aborted (e.g. component unmounted or new fetch
|
||||
// started), discard the results silently.
|
||||
if (signal?.aborted) return
|
||||
|
||||
const fetchedHosts: Host[] = hostsRes.data?.hosts ?? hostsRes.data ?? []
|
||||
const allWindows: MaintenanceWindow[] = windowsRes.data?.windows ?? []
|
||||
|
||||
// Group windows by host_id for O(N) lookup.
|
||||
const windowMap: Record<string, MaintenanceWindow[]> = {}
|
||||
for (const w of allWindows) {
|
||||
if (!windowMap[w.host_id]) windowMap[w.host_id] = []
|
||||
windowMap[w.host_id].push(w)
|
||||
}
|
||||
|
||||
// Batch both state updates together — React 18+ auto-batches these
|
||||
// into a single render, eliminating the race condition where hosts
|
||||
// rendered with stale/empty windows.
|
||||
setHosts(fetchedHosts)
|
||||
setWindowsByHost(windowMap)
|
||||
} catch (err: unknown) {
|
||||
if (signal?.aborted) return // stale request — ignore silently
|
||||
// Only log real errors, not cancellations.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setError('Failed to load hosts or maintenance windows.')
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel any in-flight fetch from a previous render.
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
fetchData(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [fetchData])
|
||||
|
||||
// ── Refresh helper: cancels any in-flight fetch, starts a new one ────────
|
||||
const refreshData = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
fetchData(controller.signal)
|
||||
}, [fetchData])
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const showSnackbar = (message: string, severity: 'success' | 'error') =>
|
||||
setSnackbar({ open: true, message, severity })
|
||||
|
||||
// ── Create window ─────────────────────────────────────────────────────────
|
||||
const handleAddClick = (hostId: string) => {
|
||||
setCreateHostId(hostId)
|
||||
setCreateForm(defaultForm())
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
const handleCreateSubmit = async (values: FormValues) => {
|
||||
if (!createHostId) return
|
||||
await maintenanceWindowsApi.create(createHostId, {
|
||||
label: values.label,
|
||||
recurrence: values.recurrence,
|
||||
start_at: new Date(values.start_at).toISOString(),
|
||||
duration_minutes: values.duration_minutes,
|
||||
recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day,
|
||||
enabled: values.enabled,
|
||||
auto_apply: values.auto_apply,
|
||||
})
|
||||
setCreateOpen(false)
|
||||
showSnackbar('Maintenance window created', 'success')
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// ── Edit window ───────────────────────────────────────────────────────────
|
||||
const handleEditClick = (w: MaintenanceWindow) => {
|
||||
setEditWindow(w)
|
||||
setEditForm({
|
||||
label: w.label,
|
||||
recurrence: w.recurrence,
|
||||
start_at: new Date(w.start_at).toISOString().slice(0, 16),
|
||||
duration_minutes: w.duration_minutes,
|
||||
recurrence_day: w.recurrence_day ?? '',
|
||||
enabled: w.enabled,
|
||||
auto_apply: w.auto_apply,
|
||||
})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (values: FormValues) => {
|
||||
if (!editWindow) return
|
||||
await maintenanceWindowsApi.update(editWindow.host_id, editWindow.id, {
|
||||
label: values.label,
|
||||
recurrence: values.recurrence,
|
||||
start_at: new Date(values.start_at).toISOString(),
|
||||
duration_minutes: values.duration_minutes,
|
||||
recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day,
|
||||
enabled: values.enabled,
|
||||
auto_apply: values.auto_apply,
|
||||
})
|
||||
setEditOpen(false)
|
||||
showSnackbar('Maintenance window updated', 'success')
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// ── Delete window ─────────────────────────────────────────────────────────
|
||||
const handleDeleteClick = (w: MaintenanceWindow) => {
|
||||
setDeleteWindow(w)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteWindow) return
|
||||
try {
|
||||
await maintenanceWindowsApi.remove(deleteWindow.host_id, deleteWindow.id)
|
||||
setDeleteOpen(false)
|
||||
showSnackbar('Maintenance window deleted', 'success')
|
||||
refreshData()
|
||||
} catch {
|
||||
showSnackbar('Failed to delete maintenance window', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3, mb: 6 }}>
|
||||
{/* Page header */}
|
||||
<Toolbar disableGutters sx={{ mb: 2, gap: 1 }}>
|
||||
<ScheduleIcon color="primary" />
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
||||
Maintenance Windows
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={refreshData}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Toolbar>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Queued (non-immediate) patch jobs only execute during open maintenance windows.
|
||||
Configure one or more windows per host to control when patching occurs.
|
||||
</Typography>
|
||||
|
||||
{loading && (
|
||||
<Box display="flex" justifyContent="center" mt={8}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && hosts.length === 0 && (
|
||||
<Alert severity="info">
|
||||
No hosts found. Register hosts first before configuring maintenance windows.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && hosts.map(host => (
|
||||
<HostWindowsTable
|
||||
key={host.id}
|
||||
host={host}
|
||||
windows={windowsByHost[host.id] ?? []}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteClick}
|
||||
onAdd={handleAddClick}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
))}
|
||||
{/* Create dialog */}
|
||||
<WindowFormDialog
|
||||
open={createOpen}
|
||||
title="Add Maintenance Window"
|
||||
initial={createForm}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={handleCreateSubmit}
|
||||
/>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<WindowFormDialog
|
||||
open={editOpen}
|
||||
title="Edit Maintenance Window"
|
||||
initial={editForm}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
|
||||
{/* Delete confirm dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
windowLabel={deleteWindow?.label ?? ''}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
|
||||
{/* Success/error snackbar */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity={snackbar.severity}
|
||||
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
401
frontend/src/pages/MfaSetupPage.tsx
Normal file
401
frontend/src/pages/MfaSetupPage.tsx
Normal file
@ -0,0 +1,401 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Box, Button, Container, TextField, Typography,
|
||||
Alert, CircularProgress, Paper, Stepper, Step, StepLabel,
|
||||
IconButton, Tooltip, Snackbar, Tabs, Tab, List, ListItem,
|
||||
ListItemText, ListItemSecondaryAction, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
ContentCopy as CopyIcon,
|
||||
Delete as DeleteIcon,
|
||||
VpnKey as KeyIcon,
|
||||
Add as AddIcon,
|
||||
} from '@mui/icons-material'
|
||||
import QRCode from 'qrcode'
|
||||
import { authApi } from '../api/client'
|
||||
|
||||
const STEPS = ['Get your QR code', 'Verify code', 'Done']
|
||||
|
||||
// ── WebAuthn utility functions ──────────────────────────────────────────────
|
||||
|
||||
function arrayBufferToBase64url(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function base64urlToArrayBuffer(base64url: string): ArrayBuffer {
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const binary = atob(base64 + padding)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
// ── TOTP Setup Component ────────────────────────────────────────────────────
|
||||
|
||||
function TotpSetup() {
|
||||
const [step, setStep] = useState(0)
|
||||
const [setup, setSetup] = useState<{ secret_base32: string; otp_uri: string } | null>(null)
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getMfaSetup()
|
||||
.then((res) => {
|
||||
setSetup(res.data)
|
||||
if (res.data.otp_uri) {
|
||||
QRCode.toDataURL(res.data.otp_uri, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
})
|
||||
.then((url) => setQrDataUrl(url))
|
||||
.catch(() => setError('Failed to generate QR code.'))
|
||||
} else {
|
||||
setError('MFA setup returned invalid data. No OTP URI found.')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const status = err?.response?.status
|
||||
const message = err?.message
|
||||
if (status === 401) {
|
||||
setError('Authentication required. Please log in again.')
|
||||
} else if (status === 403) {
|
||||
setError('You do not have permission to set up MFA.')
|
||||
} else if (message === 'Network Error') {
|
||||
setError('Network error. Please check your connection and try again.')
|
||||
} else {
|
||||
setError(`Failed to load MFA setup: ${message || 'Unknown error'} (Status: ${status || 'N/A'})`)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCopySecret = () => {
|
||||
if (setup?.secret_base32) {
|
||||
navigator.clipboard.writeText(setup.secret_base32)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!setup) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await authApi.verifyMfa(setup.secret_base32, code)
|
||||
setStep(2)
|
||||
} catch {
|
||||
setError('Invalid code. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stepper activeStep={step} sx={{ mb: 4 }}>
|
||||
{STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
|
||||
</Stepper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{step === 0 && setup && (
|
||||
<Box>
|
||||
<Typography mb={2}>Scan this QR code in your authenticator app:</Typography>
|
||||
{qrDataUrl ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<img src={qrDataUrl} alt="MFA QR Code" width={256} height={256} style={{ imageRendering: 'pixelated' }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||
If you can't scan the QR code, enter the secret manually:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: 'monospace', wordBreak: 'break-all', p: 1, bgcolor: 'grey.100', borderRadius: 1, flexGrow: 1 }}
|
||||
>
|
||||
{setup.secret_base32}
|
||||
</Typography>
|
||||
<Tooltip title={copied ? 'Copied!' : 'Copy Secret'}>
|
||||
<IconButton onClick={handleCopySecret} color={copied ? 'success' : 'default'}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Box component="form" onSubmit={handleVerify}>
|
||||
<Typography mb={2}>Enter the 6-digit code from your authenticator app to confirm setup:</Typography>
|
||||
<TextField
|
||||
fullWidth label="Verification Code" inputMode="numeric"
|
||||
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
|
||||
value={code} onChange={(e) => setCode(e.target.value)}
|
||||
disabled={loading} required autoFocus
|
||||
/>
|
||||
<Button type="submit" variant="contained" sx={{ mt: 2 }} disabled={loading}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Verify & Enable MFA'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Alert severity="success">
|
||||
MFA has been enabled for your account. You will need your authenticator app at each login.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => setCopied(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="success" variant="filled">Secret copied to clipboard</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── WebAuthn Setup Component ────────────────────────────────────────────────
|
||||
|
||||
interface WebAuthnCredential {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function WebAuthnSetup() {
|
||||
const [credentials, setCredentials] = useState<WebAuthnCredential[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [registering, setRegistering] = useState(false)
|
||||
const [keyName, setKeyName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const loadCredentials = useCallback(() => {
|
||||
authApi.webauthnListCredentials()
|
||||
.then((res) => {
|
||||
setCredentials(res.data.credentials || [])
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[WebAuthn] Failed to load credentials:', err)
|
||||
setError('Failed to load security keys.')
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadCredentials()
|
||||
}, [loadCredentials])
|
||||
|
||||
const handleRegister = async () => {
|
||||
setRegistering(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
// Step 1: Start registration ceremony
|
||||
const startRes = await authApi.webauthnRegisterStart(keyName || undefined)
|
||||
const { challenge_key, creation_options } = startRes.data
|
||||
|
||||
// Step 2: Convert base64url strings to ArrayBuffers for navigator.credentials.create
|
||||
const publicKey = creation_options.publicKey
|
||||
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
|
||||
...publicKey,
|
||||
challenge: base64urlToArrayBuffer(publicKey.challenge),
|
||||
user: {
|
||||
...publicKey.user,
|
||||
id: base64urlToArrayBuffer(publicKey.user.id),
|
||||
},
|
||||
excludeCredentials: publicKey.excludeCredentials?.map((c: { type: string; id: string }) => ({
|
||||
...c,
|
||||
id: base64urlToArrayBuffer(c.id),
|
||||
})),
|
||||
}
|
||||
|
||||
// Step 3: Create credential via browser WebAuthn API
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreationOptions,
|
||||
}) as PublicKeyCredential | null
|
||||
|
||||
if (!credential) {
|
||||
setError('Security key registration was cancelled.')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: Serialize credential for server
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
const serializedCredential = {
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: arrayBufferToBase64url(response.attestationObject),
|
||||
clientDataJSON: arrayBufferToBase64url(response.clientDataJSON),
|
||||
},
|
||||
}
|
||||
|
||||
// Step 5: Complete registration
|
||||
await authApi.webauthnRegisterComplete(challenge_key, serializedCredential, keyName || undefined)
|
||||
setSuccess('Security key registered successfully!')
|
||||
setKeyName('')
|
||||
loadCredentials()
|
||||
} catch (err: unknown) {
|
||||
const errorObj = err as { name?: string; response?: { data?: { error?: { message?: string } } }; message?: string }
|
||||
if (errorObj.name === 'NotAllowedError') {
|
||||
setError('Security key registration was cancelled or timed out.')
|
||||
} else {
|
||||
const msg = errorObj.response?.data?.error?.message || errorObj.message || 'Registration failed.'
|
||||
setError(`Failed to register security key: ${msg}`)
|
||||
}
|
||||
} finally {
|
||||
setRegistering(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeleteConfirm(null)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await authApi.webauthnDeleteCredential(id)
|
||||
setSuccess('Security key removed successfully.')
|
||||
loadCredentials()
|
||||
} catch (err: unknown) {
|
||||
const errorObj = err as { response?: { data?: { error?: { message?: string } } }; message?: string }
|
||||
const msg = errorObj.response?.data?.error?.message || errorObj.message || 'Failed to delete key.'
|
||||
setError(`Failed to remove security key: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
<Typography variant="h6" fontWeight={600} mb={2}>
|
||||
Register a Security Key
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Add a FIDO2/WebAuthn security key (e.g., YubiKey) for passwordless authentication.
|
||||
You can register multiple keys as backups.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 3, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Key Name (optional)"
|
||||
placeholder="e.g., My YubiKey"
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
disabled={registering}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleRegister}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? <CircularProgress size={24} /> : 'Register Security Key'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" fontWeight={600} mb={1}>
|
||||
Registered Security Keys
|
||||
</Typography>
|
||||
|
||||
{credentials.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
No security keys registered yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{credentials.map((cred) => (
|
||||
<ListItem key={cred.id} sx={{ bgcolor: 'grey.50', borderRadius: 1, mb: 1 }}>
|
||||
<KeyIcon sx={{ mr: 2, color: 'action.active' }} />
|
||||
<ListItemText
|
||||
primary={cred.name || 'Unnamed Key'}
|
||||
secondary={`Added ${new Date(cred.created_at).toLocaleDateString()}`}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Remove Key">
|
||||
<IconButton
|
||||
edge="end"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm(cred.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteConfirm} onClose={() => setDeleteConfirm(null)}>
|
||||
<DialogTitle>Remove Security Key?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to remove this security key? You will no longer be able to use it to sign in.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main MFA Setup Page ─────────────────────────────────────────────────────
|
||||
|
||||
export default function MfaSetupPage() {
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ mt: 6 }}>
|
||||
<Paper elevation={3} sx={{ p: 4 }}>
|
||||
<Typography variant="h5" fontWeight={700} mb={3}>Set Up MFA</Typography>
|
||||
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} sx={{ mb: 3 }}>
|
||||
<Tab label="Authenticator App" icon={<CopyIcon />} iconPosition="start" />
|
||||
<Tab label="Security Key" icon={<KeyIcon />} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{activeTab === 0 && <TotpSetup />}
|
||||
{activeTab === 1 && <WebAuthnSetup />}
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
472
frontend/src/pages/PatchDeploymentPage.tsx
Normal file
472
frontend/src/pages/PatchDeploymentPage.tsx
Normal file
@ -0,0 +1,472 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
FormControlLabel,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Stepper,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
import { Search as SearchIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { hostsApi, jobsApi } from '../api/client'
|
||||
import type { Host, HostHealthStatus } from '../types'
|
||||
|
||||
const STEPS = ['Select Hosts', 'Review & Configure', 'Result']
|
||||
|
||||
// ── Health status chip ────────────────────────────────────────────────────────
|
||||
function HealthChip({ status }: { status: HostHealthStatus }) {
|
||||
const map: Record<HostHealthStatus, 'success' | 'warning' | 'error' | 'default'> = {
|
||||
healthy: 'success',
|
||||
degraded: 'warning',
|
||||
unreachable: 'error',
|
||||
pending: 'default',
|
||||
}
|
||||
return <Chip label={status} color={map[status]} size="small" />
|
||||
}
|
||||
|
||||
// ── PatchDeploymentPage ───────────────────────────────────────────────────────
|
||||
export default function PatchDeploymentPage() {
|
||||
const navigate = useNavigate()
|
||||
const [activeStep, setActiveStep] = useState(0)
|
||||
|
||||
// Step 0 state
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [hostsLoading, setHostsLoading] = useState(true)
|
||||
const [hostsError, setHostsError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [healthFilter, setHealthFilter] = useState<HostHealthStatus | ''>('')
|
||||
const [patchesFilter, setPatchesFilter] = useState<'all' | 'missing' | 'uptodate'>('all')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Step 1 state
|
||||
const [immediate, setImmediate] = useState(true)
|
||||
const [allowReboot, setAllowReboot] = useState(false)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [packages, setPackages] = useState('')
|
||||
|
||||
// Step 2 state
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [createdJobId, setCreatedJobId] = useState<string | null>(null)
|
||||
|
||||
const loadHosts = useCallback(async () => {
|
||||
setHostsLoading(true)
|
||||
setHostsError(null)
|
||||
try {
|
||||
const res = await hostsApi.list()
|
||||
const data = res.data as { hosts?: Host[] } | Host[]
|
||||
setHosts(Array.isArray(data) ? data : (data.hosts ?? []))
|
||||
} catch {
|
||||
setHostsError('Failed to load hosts')
|
||||
} finally {
|
||||
setHostsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadHosts()
|
||||
}, [loadHosts])
|
||||
|
||||
const filteredHosts = hosts.filter((h) => {
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
h.display_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
h.fqdn.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesHealth = healthFilter === '' || h.health_status === healthFilter
|
||||
const matchesPatches =
|
||||
patchesFilter === 'all' ||
|
||||
(patchesFilter === 'missing' && h.patches_missing > 0) ||
|
||||
(patchesFilter === 'uptodate' && h.patches_missing === 0)
|
||||
return matchesSearch && matchesHealth && matchesPatches
|
||||
})
|
||||
|
||||
const handleToggleHost = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleAll = () => {
|
||||
if (selectedIds.size === filteredHosts.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredHosts.map((h) => h.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeploy = async () => {
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const pkgList = packages
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const res = await jobsApi.create({
|
||||
host_ids: Array.from(selectedIds),
|
||||
packages: pkgList,
|
||||
immediate,
|
||||
allow_reboot: allowReboot,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
const job = res.data as { id: string }
|
||||
setCreatedJobId(job.id)
|
||||
setActiveStep(2)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : 'Deployment failed. Please try again.'
|
||||
setSubmitError(msg)
|
||||
setActiveStep(2)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setActiveStep(0)
|
||||
setSelectedIds(new Set())
|
||||
setImmediate(true)
|
||||
setAllowReboot(false)
|
||||
setNotes('')
|
||||
setPackages('')
|
||||
setSubmitError(null)
|
||||
setCreatedJobId(null)
|
||||
}
|
||||
|
||||
const selectedHosts = hosts.filter((h) => selectedIds.has(h.id))
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
||||
Patch Deployment
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{STEPS.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{/* ── Step 0: Select Hosts ── */}
|
||||
{activeStep === 0 && (
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight={600} mb={2}>
|
||||
Select Target Hosts
|
||||
</Typography>
|
||||
|
||||
{hostsError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{hostsError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box display="flex" gap={2} mb={2} flexWrap="wrap">
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search by name or FQDN…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ minWidth: 260 }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Health Filter"
|
||||
value={healthFilter}
|
||||
onChange={(e) => setHealthFilter(e.target.value as HostHealthStatus | '')}
|
||||
SelectProps={{ native: true }}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="healthy">Healthy</option>
|
||||
<option value="degraded">Degraded</option>
|
||||
<option value="unreachable">Unreachable</option>
|
||||
<option value="pending">Pending</option>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Patches Missing"
|
||||
value={patchesFilter}
|
||||
onChange={(e) => setPatchesFilter(e.target.value as 'all' | 'missing' | 'uptodate')}
|
||||
SelectProps={{ native: true }}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="missing">Missing (>0)</option>
|
||||
<option value="uptodate">Up to date (0)</option>
|
||||
</TextField>
|
||||
</Box>
|
||||
|
||||
{hostsLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredHosts.length > 0 &&
|
||||
filteredHosts.every((h) => selectedIds.has(h.id))
|
||||
}
|
||||
indeterminate={
|
||||
filteredHosts.some((h) => selectedIds.has(h.id)) &&
|
||||
!filteredHosts.every((h) => selectedIds.has(h.id))
|
||||
}
|
||||
onChange={handleToggleAll}
|
||||
disabled={filteredHosts.length === 0}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>Display Name</TableCell>
|
||||
<TableCell>FQDN</TableCell>
|
||||
<TableCell>IP Address</TableCell>
|
||||
<TableCell>Health</TableCell>
|
||||
<TableCell>Checks</TableCell>
|
||||
<TableCell>Patches</TableCell>
|
||||
<TableCell>OS</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredHosts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center">
|
||||
<Typography variant="body2" color="text.secondary" py={2}>
|
||||
No hosts found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredHosts.map((host) => (
|
||||
<TableRow
|
||||
key={host.id}
|
||||
hover
|
||||
selected={selectedIds.has(host.id)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => handleToggleHost(host.id)}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(host.id)}
|
||||
onChange={() => handleToggleHost(host.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{host.display_name}</TableCell>
|
||||
<TableCell>{host.fqdn}</TableCell>
|
||||
<TableCell>{host.ip_address}</TableCell>
|
||||
<TableCell>
|
||||
<HealthChip status={host.health_status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{host.health_check_status === 'all_healthy' ? (
|
||||
<Tooltip title="All checks healthy"><CheckCircleIcon color="success" fontSize="small" /></Tooltip>
|
||||
) : host.health_check_status === 'some_unhealthy' ? (
|
||||
<Tooltip title="Some checks unhealthy"><CancelIcon color="error" fontSize="small" /></Tooltip>
|
||||
) : (
|
||||
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={host.patches_missing}
|
||||
color={host.patches_missing > 0 ? 'error' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{host.os_name ?? host.os_family ?? '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mt={3}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedIds.size} host{selectedIds.size !== 1 ? 's' : ''} selected
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setActiveStep(1)}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Review & Configure ── */}
|
||||
{activeStep === 1 && (
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight={600} mb={2}>
|
||||
Review & Configure
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle2" color="text.secondary" mb={1}>
|
||||
Selected Hosts ({selectedHosts.length})
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1} mb={3}>
|
||||
{selectedHosts.map((h) => (
|
||||
<Chip
|
||||
key={h.id}
|
||||
label={h.display_name}
|
||||
onDelete={() => handleToggleHost(h.id)}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={2.5} maxWidth={560}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={immediate}
|
||||
onChange={(e) => setImmediate(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{immediate ? 'Apply Now' : 'Queue for Maintenance Window'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{immediate
|
||||
? 'Job will run immediately on the selected hosts'
|
||||
: 'Job will run during the next scheduled maintenance window'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={allowReboot}
|
||||
onChange={(e) => setAllowReboot(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Allow reboot after patching"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Packages (optional)"
|
||||
placeholder="Leave empty to apply all available patches, or enter comma-separated package names"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={packages}
|
||||
onChange={(e) => setPackages(e.target.value)}
|
||||
fullWidth
|
||||
helperText="e.g. openssl, curl, libssl1.1"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Notes (optional)"
|
||||
placeholder="Describe the purpose of this deployment…"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" gap={2} mt={4}>
|
||||
<Button variant="outlined" onClick={() => setActiveStep(0)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleDeploy}
|
||||
disabled={submitting}
|
||||
startIcon={submitting ? <CircularProgress size={16} color="inherit" /> : undefined}
|
||||
>
|
||||
{submitting ? 'Deploying…' : 'Deploy'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Result ── */}
|
||||
{activeStep === 2 && (
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight={600} mb={3}>
|
||||
Deployment Result
|
||||
</Typography>
|
||||
|
||||
{createdJobId ? (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
<Typography fontWeight={600}>Deployment job created successfully!</Typography>
|
||||
<Typography variant="body2" mt={0.5}>
|
||||
Job ID: <strong>{createdJobId}</strong>
|
||||
</Typography>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
<Typography fontWeight={600}>Deployment failed</Typography>
|
||||
{submitError && (
|
||||
<Typography variant="body2" mt={0.5}>
|
||||
{submitError}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box display="flex" gap={2}>
|
||||
<Button variant="outlined" onClick={handleReset}>
|
||||
Deploy Another
|
||||
</Button>
|
||||
{createdJobId && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/jobs')}
|
||||
>
|
||||
View Jobs
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
389
frontend/src/pages/ProfilePage.tsx
Normal file
389
frontend/src/pages/ProfilePage.tsx
Normal file
@ -0,0 +1,389 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box, Button, Card, CardContent, Chip, Container, Dialog,
|
||||
DialogActions, DialogContent, DialogTitle, Snackbar,
|
||||
Alert, TextField, Typography, InputAdornment, IconButton,
|
||||
List, ListItem, ListItemIcon, ListItemText,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Person as PersonIcon,
|
||||
Lock as LockIcon,
|
||||
Visibility, VisibilityOff,
|
||||
VpnKey as MfaIcon,
|
||||
Save as SaveIcon,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { usersApi } from '../api/client'
|
||||
import type { User } from '../types'
|
||||
|
||||
/** Password strength checker */
|
||||
function checkPasswordStrength(password: string) {
|
||||
return {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
digit: /[0-9]/.test(password),
|
||||
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
|
||||
}
|
||||
}
|
||||
|
||||
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
|
||||
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const navigate = useNavigate()
|
||||
const { user, setUser } = useAuthStore()
|
||||
|
||||
// ── Profile state ────────────────────────────────────────────────────────
|
||||
const [me, setMe] = useState<User | null>(null)
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [loadingProfile, setLoadingProfile] = useState(true)
|
||||
const [savingProfile, setSavingProfile] = useState(false)
|
||||
|
||||
// ── Password state ──────────────────────────────────────────────────────
|
||||
const [currentPw, setCurrentPw] = useState('')
|
||||
const [newPw, setNewPw] = useState('')
|
||||
const [confirmPw, setConfirmPw] = useState('')
|
||||
const [showCurrentPw, setShowCurrentPw] = useState(false)
|
||||
const [showNewPw, setShowNewPw] = useState(false)
|
||||
const [showConfirmPw, setShowConfirmPw] = useState(false)
|
||||
const [changingPw, setChangingPw] = useState(false)
|
||||
|
||||
// ── MFA state ────────────────────────────────────────────────────────────
|
||||
const [mfaDisableOpen, setMfaDisableOpen] = useState(false)
|
||||
const [mfaDisablePw, setMfaDisablePw] = useState('')
|
||||
const [disablingMfa, setDisablingMfa] = useState(false)
|
||||
|
||||
// ── Snackbar state ──────────────────────────────────────────────────────
|
||||
const [snack, setSnack] = useState<{ open: boolean; severity: 'success' | 'error'; message: string }>({
|
||||
open: false, severity: 'success', message: '',
|
||||
})
|
||||
|
||||
const showSnack = (severity: 'success' | 'error', message: string) =>
|
||||
setSnack({ open: true, severity, message })
|
||||
|
||||
const pwChecks = checkPasswordStrength(newPw)
|
||||
const pwValid = isPasswordValid(pwChecks)
|
||||
const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw)
|
||||
|
||||
// ── Load current user on mount ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const { data } = await usersApi.getMe()
|
||||
setMe(data)
|
||||
setDisplayName(data.display_name || '')
|
||||
setEmail(data.email || '')
|
||||
} catch {
|
||||
showSnack('error', 'Failed to load profile')
|
||||
} finally {
|
||||
setLoadingProfile(false)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
// ── Save profile ────────────────────────────────────────────────────────
|
||||
const handleSaveProfile = async () => {
|
||||
if (!me) return
|
||||
setSavingProfile(true)
|
||||
try {
|
||||
const { data } = await usersApi.update(me.id, { display_name: displayName, email })
|
||||
setMe(data)
|
||||
setUser(data)
|
||||
showSnack('success', 'Profile updated')
|
||||
} catch {
|
||||
showSnack('error', 'Failed to update profile')
|
||||
} finally {
|
||||
setSavingProfile(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Change password ────────────────────────────────────────────────────
|
||||
const handleChangePassword = async () => {
|
||||
if (newPw !== confirmPw) {
|
||||
showSnack('error', 'New passwords do not match')
|
||||
return
|
||||
}
|
||||
if (!pwValid) {
|
||||
showSnack('error', 'Password does not meet strength requirements')
|
||||
return
|
||||
}
|
||||
setChangingPw(true)
|
||||
try {
|
||||
await usersApi.changePassword({ current_password: currentPw, new_password: newPw })
|
||||
setCurrentPw('')
|
||||
setNewPw('')
|
||||
setConfirmPw('')
|
||||
showSnack('success', 'Password changed successfully')
|
||||
} catch {
|
||||
showSnack('error', 'Failed to change password')
|
||||
} finally {
|
||||
setChangingPw(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Disable MFA ─────────────────────────────────────────────────────────
|
||||
const handleDisableMfa = async () => {
|
||||
setDisablingMfa(true)
|
||||
try {
|
||||
await usersApi.disableMfa(mfaDisablePw)
|
||||
if (me) setMe({ ...me, mfa_enabled: false })
|
||||
// Also update authStore user
|
||||
if (user) setUser({ ...user, mfa_enabled: false })
|
||||
setMfaDisablePw('')
|
||||
setMfaDisableOpen(false)
|
||||
showSnack('success', 'MFA disabled')
|
||||
} catch {
|
||||
showSnack('error', 'Failed to disable MFA')
|
||||
} finally {
|
||||
setDisablingMfa(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingProfile) {
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ mt: 3 }}>
|
||||
<Box display="flex" justifyContent="center" mt={4}>Loading profile…</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ mt: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ mb: 3 }}>My Profile</Typography>
|
||||
|
||||
{/* ── Profile Section ──────────────────────────────────────────────── */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={600} sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PersonIcon fontSize="small" /> Profile Information
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Username"
|
||||
value={me?.username || ''}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ '& .MuiInputBase-input.Mui-readOnly': { color: 'text.disabled' } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Display Name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">Role:</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={me?.role || 'unknown'}
|
||||
color={me?.role === 'admin' ? 'primary' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveProfile}
|
||||
disabled={savingProfile}
|
||||
>
|
||||
{savingProfile ? 'Saving…' : 'Save Profile'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Password Section ─────────────────────────────────────────────── */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={600} sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LockIcon fontSize="small" /> Change Password
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Current Password"
|
||||
type={showCurrentPw ? 'text' : 'password'}
|
||||
value={currentPw}
|
||||
onChange={(e) => setCurrentPw(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={() => setShowCurrentPw(!showCurrentPw)} edge="end">
|
||||
{showCurrentPw ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="New Password"
|
||||
type={showNewPw ? 'text' : 'password'}
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
error={pwMismatch}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={() => setShowNewPw(!showNewPw)} edge="end">
|
||||
{showNewPw ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{newPw && (
|
||||
<Box sx={{ mt: -1, mb: 0 }}>
|
||||
<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>
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
<TextField
|
||||
label="Confirm New Password"
|
||||
type={showConfirmPw ? 'text' : 'password'}
|
||||
value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
error={pwMismatch}
|
||||
helperText={pwMismatch ? 'Passwords do not match' : ''}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={() => setShowConfirmPw(!showConfirmPw)} edge="end">
|
||||
{showConfirmPw ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleChangePassword}
|
||||
disabled={changingPw || !currentPw || !newPw || !confirmPw || !!pwMismatch || !pwValid}
|
||||
>
|
||||
{changingPw ? 'Changing…' : 'Change Password'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── MFA Section ──────────────────────────────────────────────────── */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={600} sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<MfaIcon fontSize="small" /> Multi-Factor Authentication
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">Status:</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={me?.mfa_enabled ? 'Enabled' : 'Disabled'}
|
||||
color={me?.mfa_enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</Box>
|
||||
{me?.mfa_enabled ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setMfaDisableOpen(true)}
|
||||
>
|
||||
Disable MFA
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate('/mfa/setup')}
|
||||
>
|
||||
Enable MFA
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Disable MFA Confirmation Dialog ─────────────────────────────── */}
|
||||
<Dialog open={mfaDisableOpen} onClose={() => setMfaDisableOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Disable MFA</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Are you sure you want to disable multi-factor authentication? This will make your account less secure.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Enter your password to confirm"
|
||||
type="password"
|
||||
value={mfaDisablePw}
|
||||
onChange={(e) => setMfaDisablePw(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setMfaDisableOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={handleDisableMfa}
|
||||
disabled={disablingMfa || !mfaDisablePw}
|
||||
>
|
||||
{disablingMfa ? 'Disabling…' : 'Disable MFA'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Snackbar ─────────────────────────────────────────────────────── */}
|
||||
<Snackbar
|
||||
open={snack.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnack((s) => ({ ...s, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity={snack.severity}
|
||||
onClose={() => setSnack((s) => ({ ...s, open: false }))}
|
||||
variant="filled"
|
||||
>
|
||||
{snack.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
345
frontend/src/pages/ReportsPage.tsx
Normal file
345
frontend/src/pages/ReportsPage.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Snackbar,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import DescriptionIcon from '@mui/icons-material/Description'
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'
|
||||
import { reportsApi, settingsApi, apiClient } from '../api/client'
|
||||
import type { ReportType, ReportFormat, AuditIntegrityResult, Group } from '../types'
|
||||
|
||||
// ── Report metadata ───────────────────────────────────────────────────────────
|
||||
|
||||
const REPORT_INFO: Record<ReportType, { title: string; description: string; columns: string[] }> = {
|
||||
compliance: {
|
||||
title: 'Compliance Report',
|
||||
description:
|
||||
'Shows patch compliance percentage per host and group. Includes total packages, pending patches, and last patch timestamp.',
|
||||
columns: [
|
||||
'Host',
|
||||
'FQDN',
|
||||
'Groups',
|
||||
'Total Packages',
|
||||
'Pending Patches',
|
||||
'Compliance %',
|
||||
'Last Patched',
|
||||
'Health Status',
|
||||
],
|
||||
},
|
||||
'patch-history': {
|
||||
title: 'Patch History',
|
||||
description:
|
||||
'Full history of patch job operations across all hosts. Filter by date range to narrow results.',
|
||||
columns: [
|
||||
'Job ID',
|
||||
'Kind',
|
||||
'Status',
|
||||
'Host',
|
||||
'FQDN',
|
||||
'Package Count',
|
||||
'Started At',
|
||||
'Completed At',
|
||||
'Duration',
|
||||
'Operator',
|
||||
],
|
||||
},
|
||||
vulnerability: {
|
||||
title: 'Vulnerability Exposure',
|
||||
description:
|
||||
'Lists all known CVEs affecting managed hosts based on cached patch data from agents.',
|
||||
columns: ['Host', 'FQDN', 'CVE ID', 'Package', 'Severity', 'Available Version', 'Last Seen'],
|
||||
},
|
||||
audit: {
|
||||
title: 'Audit Trail',
|
||||
description:
|
||||
'Complete tamper-evident audit log of all system actions. Limited to 10,000 most recent events.',
|
||||
columns: [
|
||||
'ID',
|
||||
'Timestamp',
|
||||
'Action',
|
||||
'Actor',
|
||||
'Target Type',
|
||||
'Target ID',
|
||||
'IP Address',
|
||||
'Request ID',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── Default date helpers ──────────────────────────────────────────────────────
|
||||
|
||||
const defaultFromDate = () =>
|
||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
const defaultToDate = () => new Date().toISOString().split('T')[0]
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [reportType, setReportType] = useState<ReportType>('compliance')
|
||||
const [fromDate, setFromDate] = useState<string>(defaultFromDate())
|
||||
const [toDate, setToDate] = useState<string>(defaultToDate())
|
||||
const [groupId, setGroupId] = useState<string>('')
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [verifyingIntegrity, setVerifyingIntegrity] = useState(false)
|
||||
const [integrityResult, setIntegrityResult] = useState<AuditIntegrityResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get<Group[]>('/groups').then((res) => {
|
||||
setGroups(res.data)
|
||||
}).catch(() => {
|
||||
// Groups fetch is optional; silently ignore errors
|
||||
})
|
||||
}, [])
|
||||
|
||||
const info = REPORT_INFO[reportType]
|
||||
|
||||
const handleDownload = async (format: ReportFormat) => {
|
||||
setDownloading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (fromDate) params.from = new Date(fromDate).toISOString()
|
||||
if (toDate) params.to = new Date(toDate + 'T23:59:59Z').toISOString()
|
||||
if (reportType === 'compliance' && groupId) params.group_id = groupId
|
||||
|
||||
const res = await reportsApi.download(reportType, format, params)
|
||||
|
||||
// Trigger browser download
|
||||
const url = window.URL.createObjectURL(new Blob([res.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const ext = format === 'pdf' ? 'pdf' : 'csv'
|
||||
const dateStr = new Date().toISOString().split('T')[0]
|
||||
link.setAttribute('download', `${reportType}-report-${dateStr}.${ext}`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
setError('Failed to generate report. Please try again.')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyIntegrity = async () => {
|
||||
setVerifyingIntegrity(true)
|
||||
setIntegrityResult(null)
|
||||
try {
|
||||
const { data } = await settingsApi.auditIntegrity()
|
||||
setIntegrityResult(data)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Verification failed'
|
||||
setIntegrityResult({ intact: false, rows_checked: 0, errors: [{ row_id: 0, expected_hash: '', actual_hash: msg }] })
|
||||
} finally {
|
||||
setVerifyingIntegrity(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
{/* ── Page header ── */}
|
||||
<Toolbar disableGutters sx={{ mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
Reports
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* ── Controls card ── */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
|
||||
Report Options
|
||||
</Typography>
|
||||
|
||||
{/* Report Type */}
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel id="report-type-label">Report Type</InputLabel>
|
||||
<Select
|
||||
labelId="report-type-label"
|
||||
value={reportType}
|
||||
label="Report Type"
|
||||
onChange={(e) => setReportType(e.target.value as ReportType)}
|
||||
>
|
||||
<MenuItem value="compliance">Compliance Report</MenuItem>
|
||||
<MenuItem value="patch-history">Patch History</MenuItem>
|
||||
<MenuItem value="vulnerability">Vulnerability Exposure</MenuItem>
|
||||
<MenuItem value="audit">Audit Trail</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Date Range */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 2 }}>
|
||||
<TextField
|
||||
label="From"
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="To"
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Group Filter — compliance only */}
|
||||
{reportType === 'compliance' && (
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel id="group-filter-label">Group (optional)</InputLabel>
|
||||
<Select
|
||||
labelId="group-filter-label"
|
||||
value={groupId}
|
||||
label="Group (optional)"
|
||||
onChange={(e) => setGroupId(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">All Groups</MenuItem>
|
||||
{groups.map((g) => (
|
||||
<MenuItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>Filter compliance report by a specific group</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Download buttons */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={
|
||||
downloading ? <CircularProgress size={20} color="inherit" /> : <DescriptionIcon />
|
||||
}
|
||||
onClick={() => handleDownload('csv')}
|
||||
disabled={downloading}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={
|
||||
downloading ? <CircularProgress size={20} color="inherit" /> : <PictureAsPdfIcon />
|
||||
}
|
||||
onClick={() => handleDownload('pdf')}
|
||||
disabled={downloading}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* ── Audit Integrity card ── */}
|
||||
<Paper variant="outlined" sx={{ p: 3, mt: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
|
||||
Audit Integrity Verification
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Verify the audit log hash chain has not been tampered with. Each entry is cryptographically linked to the previous one.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={verifyingIntegrity ? <CircularProgress size={20} /> : <VerifiedUserIcon />}
|
||||
onClick={handleVerifyIntegrity}
|
||||
disabled={verifyingIntegrity}
|
||||
>
|
||||
Verify Integrity
|
||||
</Button>
|
||||
{integrityResult && (
|
||||
<Alert severity={integrityResult.intact ? 'success' : 'error'} sx={{ mt: 2 }}>
|
||||
{integrityResult.intact
|
||||
? `✓ Chain intact — ${integrityResult.rows_checked} rows verified`
|
||||
: `✗ Chain compromised! ${integrityResult.errors.length} error(s) in ${integrityResult.rows_checked} rows`}
|
||||
{integrityResult.errors.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{integrityResult.errors.slice(0, 5).map((e, i) => (
|
||||
<Typography key={i} variant="body2">
|
||||
Row {e.row_id}: expected {e.expected_hash.substring(0, 16)}… got {e.actual_hash.substring(0, 16)}…
|
||||
</Typography>
|
||||
))}
|
||||
{integrityResult.errors.length > 5 && (
|
||||
<Typography variant="body2">…and {integrityResult.errors.length - 5} more</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* ── Info card ── */}
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight={600} sx={{ mb: 1 }}>
|
||||
{info.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{info.description}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Columns in this report:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
|
||||
{info.columns.map((col) => (
|
||||
<Chip key={col} label={col} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
📊 PDF includes bar charts for compliance and patch history reports.
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
📁 CSV is suitable for import into Excel or Google Sheets.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* ── Error snackbar ── */}
|
||||
<Snackbar
|
||||
open={!!error}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setError(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="error" onClose={() => setError(null)} sx={{ width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
489
frontend/src/pages/SettingsPage.tsx
Normal file
489
frontend/src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,489 @@
|
||||
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 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 } }>
|
||||
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>
|
||||
)
|
||||
}
|
||||
107
frontend/src/pages/SsoCallbackPage.tsx
Normal file
107
frontend/src/pages/SsoCallbackPage.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box, Container, Paper, Typography, Alert, Button, CircularProgress,
|
||||
} from '@mui/material'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { User } from '../types'
|
||||
|
||||
export default function SsoCallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setTokens, setUser } = useAuthStore()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
// Check for error from backend
|
||||
const errorCode = params.get('error')
|
||||
const errorDescription = params.get('error_description')
|
||||
if (errorCode) {
|
||||
setError(errorDescription || `SSO authentication failed: ${errorCode}`)
|
||||
setProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract tokens
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
setError('Missing authentication tokens. Please try logging in again.')
|
||||
setProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse user JSON from query param
|
||||
const userParam = params.get('user')
|
||||
if (!userParam) {
|
||||
setError('Missing user information. Please try logging in again.')
|
||||
setProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
let parsedUser: Record<string, unknown>
|
||||
try {
|
||||
parsedUser = JSON.parse(userParam)
|
||||
} catch {
|
||||
setError('Malformed user data received. Please try logging in again.')
|
||||
setProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 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: authProvider as User['auth_provider'],
|
||||
mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false,
|
||||
is_active: true,
|
||||
force_password_reset: false,
|
||||
}
|
||||
|
||||
// Store tokens and user, then navigate
|
||||
setTokens(accessToken, refreshToken)
|
||||
setUser(user)
|
||||
navigate('/dashboard', { replace: true })
|
||||
}, [setTokens, setUser, navigate])
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs" sx={{ mt: 12 }}>
|
||||
<Paper elevation={4} sx={{ p: 4 }}>
|
||||
<Typography variant="h5" fontWeight={700} mb={3} align="center">
|
||||
🐉 Linux Patch Manager
|
||||
</Typography>
|
||||
|
||||
{processing ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress size={48} sx={{ mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Completing sign-in…
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
613
frontend/src/pages/UsersPage.tsx
Normal file
613
frontend/src/pages/UsersPage.tsx
Normal file
@ -0,0 +1,613 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions,
|
||||
DialogContent, DialogContentText, DialogTitle, FormControlLabel, IconButton,
|
||||
MenuItem, Paper, Select, Switch, Table, TableBody, TableCell,
|
||||
TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography,
|
||||
Snackbar, Alert, InputAdornment, FormControl, InputLabel,
|
||||
List, ListItem, ListItemIcon, ListItemText,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Add as AddIcon, Lock as LockIcon, Edit as EditIcon,
|
||||
VpnKey as VpnKeyIcon, Delete as DeleteIcon, Search as SearchIcon,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { usersApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { User, UpdateUserRequest, AdminResetPasswordRequest } from '../types'
|
||||
|
||||
/** Password strength checker */
|
||||
function checkPasswordStrength(password: string) {
|
||||
return {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
digit: /[0-9]/.test(password),
|
||||
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
|
||||
}
|
||||
}
|
||||
|
||||
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
|
||||
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
|
||||
}
|
||||
|
||||
/** Reusable password strength checklist component */
|
||||
function PasswordStrengthIndicator({ password }: { password: string }) {
|
||||
if (!password) return null
|
||||
const checks = checkPasswordStrength(password)
|
||||
return (
|
||||
<Box sx={{ mt: 0.5, mb: 1 }}>
|
||||
<List dense disablePadding>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{checks.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 }}>
|
||||
{checks.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 }}>
|
||||
{checks.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 }}>
|
||||
{checks.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 }}>
|
||||
{checks.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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
const navigate = useNavigate()
|
||||
const isAdmin = currentUser?.role === 'admin'
|
||||
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Snackbar
|
||||
const [snack, setSnack] = useState<{ open: boolean; severity: 'success' | 'error'; message: string }>({
|
||||
open: false, severity: 'success', message: '',
|
||||
})
|
||||
const showSnack = (severity: 'success' | 'error', message: string) =>
|
||||
setSnack({ open: true, severity, message })
|
||||
|
||||
// Search / filter
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState('all')
|
||||
|
||||
// Add User dialog
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [addForm, setAddForm] = useState({ username: '', display_name: '', email: '', role: 'operator', password: '' })
|
||||
|
||||
// Edit User dialog
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editUser, setEditUser] = useState<User | null>(null)
|
||||
const [editForm, setEditForm] = useState<UpdateUserRequest & { display_name: string; email: string; role: string; is_active: boolean; force_password_reset: boolean }>({
|
||||
display_name: '', email: '', role: 'operator', is_active: true, force_password_reset: false,
|
||||
})
|
||||
|
||||
// Password Reset dialog
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const [resetUser, setResetUser] = useState<User | null>(null)
|
||||
const [resetForm, setResetForm] = useState({ new_password: '', confirm_password: '', force_password_reset: true })
|
||||
|
||||
// MFA Disable confirmation dialog
|
||||
const [mfaConfirmOpen, setMfaConfirmOpen] = useState(false)
|
||||
const [mfaDisableUser, setMfaDisableUser] = useState<User | null>(null)
|
||||
|
||||
// Delete confirmation dialog
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteUser, setDeleteUser] = useState<User | null>(null)
|
||||
|
||||
const addPwValid = isPasswordValid(checkPasswordStrength(addForm.password))
|
||||
const resetPwValid = isPasswordValid(checkPasswordStrength(resetForm.new_password))
|
||||
const resetPwMismatch = !!(resetForm.confirm_password && resetForm.new_password !== resetForm.confirm_password)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await usersApi.list()
|
||||
setUsers(r.data)
|
||||
} catch {
|
||||
showSnack('error', 'Failed to load users')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
// Filtered users
|
||||
const filteredUsers = useMemo(() => {
|
||||
let list = users
|
||||
if (roleFilter !== 'all') {
|
||||
list = list.filter(u => u.role === roleFilter)
|
||||
}
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.toLowerCase()
|
||||
list = list.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
u.display_name.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [users, roleFilter, searchText])
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!addPwValid) {
|
||||
showSnack('error', 'Password does not meet strength requirements')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await usersApi.create(addForm)
|
||||
setAddOpen(false)
|
||||
setAddForm({ username: '', display_name: '', email: '', role: 'operator', password: '' })
|
||||
showSnack('success', 'User created successfully')
|
||||
load()
|
||||
} catch {
|
||||
showSnack('error', 'Failed to create user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
try {
|
||||
await usersApi.revokeSessions(id)
|
||||
showSnack('success', 'Sessions revoked')
|
||||
} catch {
|
||||
showSnack('error', 'Failed to revoke sessions')
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (u: User) => {
|
||||
setEditUser(u)
|
||||
setEditForm({
|
||||
display_name: u.display_name || '',
|
||||
email: u.email || '',
|
||||
role: u.role,
|
||||
is_active: u.is_active,
|
||||
force_password_reset: u.force_password_reset,
|
||||
})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!editUser) return
|
||||
try {
|
||||
await usersApi.update(editUser.id, editForm)
|
||||
setEditOpen(false)
|
||||
showSnack('success', 'User updated successfully')
|
||||
load()
|
||||
} catch {
|
||||
showSnack('error', 'Failed to update user')
|
||||
}
|
||||
}
|
||||
|
||||
const openReset = (u: User) => {
|
||||
setResetUser(u)
|
||||
setResetForm({ new_password: '', confirm_password: '', force_password_reset: true })
|
||||
setResetOpen(true)
|
||||
}
|
||||
|
||||
const handleResetSave = async () => {
|
||||
if (!resetUser) return
|
||||
if (resetPwMismatch) {
|
||||
showSnack('error', 'Passwords do not match')
|
||||
return
|
||||
}
|
||||
if (!resetPwValid) {
|
||||
showSnack('error', 'Password does not meet strength requirements')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data: AdminResetPasswordRequest = {
|
||||
new_password: resetForm.new_password,
|
||||
force_password_reset: resetForm.force_password_reset,
|
||||
}
|
||||
await usersApi.adminResetPassword(resetUser.id, data)
|
||||
setResetOpen(false)
|
||||
showSnack('success', 'Password reset successfully')
|
||||
} catch {
|
||||
showSnack('error', 'Failed to reset password')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMfaDisable = (u: User) => {
|
||||
setMfaDisableUser(u)
|
||||
setMfaConfirmOpen(true)
|
||||
}
|
||||
|
||||
const handleMfaDisableConfirm = async () => {
|
||||
if (!mfaDisableUser) return
|
||||
try {
|
||||
await usersApi.adminDisableMfa(mfaDisableUser.id)
|
||||
setMfaConfirmOpen(false)
|
||||
showSnack('success', 'MFA disabled successfully')
|
||||
load()
|
||||
} catch {
|
||||
showSnack('error', 'Failed to disable MFA')
|
||||
}
|
||||
}
|
||||
|
||||
const openDelete = (u: User) => {
|
||||
setDeleteUser(u)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteUser) return
|
||||
try {
|
||||
await usersApi.delete(deleteUser.id)
|
||||
setDeleteOpen(false)
|
||||
showSnack('success', 'User deleted successfully')
|
||||
load()
|
||||
} catch {
|
||||
showSnack('error', 'Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Users</Typography>
|
||||
{isAdmin && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>Add User</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
|
||||
{/* Search / Filter bar */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search by username, name, or email…"
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select value={roleFilter} label="Role" onChange={e => setRoleFilter(e.target.value)}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="operator">Operator</MenuItem>
|
||||
<MenuItem value="reporter">Reporter</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Display Name</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>MFA</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredUsers.map(u => (
|
||||
<TableRow key={u.id} hover>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell>{u.display_name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.role}
|
||||
color={u.role === 'admin' ? 'primary' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.mfa_enabled ? (
|
||||
<Chip size="small" label="On" color="success" />
|
||||
) : currentUser?.id === u.id ? (
|
||||
<Tooltip title="Enable MFA">
|
||||
<Chip size="small" label="Off" color="warning"
|
||||
sx={{ cursor: 'pointer', '&:hover': { opacity: 0.8 } }}
|
||||
onClick={() => navigate('/mfa/setup')} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Chip size="small" label="Off" color="default" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.is_active ? 'Active' : 'Disabled'}
|
||||
color={u.is_active ? 'success' : 'error'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Edit User">
|
||||
<IconButton size="small" color="primary" onClick={() => openEdit(u)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{isAdmin && (
|
||||
<Tooltip title="Reset Password">
|
||||
<IconButton size="small" color="warning" onClick={() => openReset(u)}>
|
||||
<VpnKeyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Revoke All Sessions">
|
||||
<IconButton size="small" color="warning" onClick={() => handleRevoke(u.id)}>
|
||||
<LockIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{isAdmin && (
|
||||
<Tooltip title="Delete User">
|
||||
<IconButton size="small" color="error" onClick={() => openDelete(u)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" sx={{ py: 3, color: 'text.secondary' }}>
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* ── Add User Dialog ──────────────────────────────────────────────────── */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="Username"
|
||||
value={addForm.username}
|
||||
onChange={e => setAddForm({ ...addForm, username: e.target.value })}
|
||||
margin="normal" required />
|
||||
<TextField fullWidth label="Display Name"
|
||||
value={addForm.display_name}
|
||||
onChange={e => setAddForm({ ...addForm, display_name: e.target.value })}
|
||||
margin="normal" />
|
||||
<TextField fullWidth label="Email" type="email"
|
||||
value={addForm.email}
|
||||
onChange={e => setAddForm({ ...addForm, email: e.target.value })}
|
||||
margin="normal" required />
|
||||
<TextField fullWidth label="Password" type="password"
|
||||
value={addForm.password}
|
||||
onChange={e => setAddForm({ ...addForm, password: e.target.value })}
|
||||
margin="normal" required />
|
||||
<PasswordStrengthIndicator password={addForm.password} />
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select value={addForm.role} label="Role"
|
||||
onChange={e => setAddForm({ ...addForm, role: e.target.value })}>
|
||||
<MenuItem value="operator">Operator</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="reporter">Reporter</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate}
|
||||
disabled={!addForm.username || !addForm.email || !addForm.password || !addPwValid}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Edit User Dialog ──────────────────────────────────────────────────── */}
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="Username"
|
||||
value={editUser?.username ?? ''}
|
||||
margin="normal" slotProps={{ input: { readOnly: true } }}
|
||||
helperText="Username cannot be changed"
|
||||
/>
|
||||
<TextField fullWidth label="Display Name"
|
||||
value={editForm.display_name}
|
||||
onChange={e => setEditForm({ ...editForm, display_name: e.target.value })}
|
||||
margin="normal" />
|
||||
<TextField fullWidth label="Email" type="email"
|
||||
value={editForm.email}
|
||||
onChange={e => setEditForm({ ...editForm, email: e.target.value })}
|
||||
margin="normal" />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select value={editForm.role} label="Role"
|
||||
onChange={e => setEditForm({ ...editForm, role: e.target.value })}>
|
||||
<MenuItem value="operator">Operator</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="reporter">Reporter</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={editForm.is_active}
|
||||
onChange={e => setEditForm({ ...editForm, is_active: e.target.checked })} />
|
||||
}
|
||||
label="Active"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={editForm.force_password_reset}
|
||||
onChange={e => setEditForm({ ...editForm, force_password_reset: e.target.checked })} />
|
||||
}
|
||||
label="Force Password Reset"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* MFA status */}
|
||||
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">MFA Status:</Typography>
|
||||
<Chip size="small"
|
||||
label={editUser?.mfa_enabled ? 'Enabled' : 'Disabled'}
|
||||
color={editUser?.mfa_enabled ? 'success' : 'default'}
|
||||
/>
|
||||
{editUser?.mfa_enabled ? (
|
||||
<Button size="small" color="error" variant="outlined"
|
||||
onClick={() => editUser && handleMfaDisable(editUser)}>
|
||||
Disable MFA
|
||||
</Button>
|
||||
) : (
|
||||
currentUser?.id === editUser?.id ? (
|
||||
<Button size="small" color="primary" variant="outlined"
|
||||
onClick={() => navigate('/mfa/setup')}>
|
||||
Enable MFA
|
||||
</Button>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
User must enable MFA from their own profile settings.
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
{editUser?.mfa_enabled ? (
|
||||
<Typography variant="caption" color="warning.main" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Disabling MFA reduces account security for this user.
|
||||
</Typography>
|
||||
) : (
|
||||
currentUser?.id === editUser?.id && (
|
||||
<Typography variant="caption" color="info.main" sx={{ display: 'block', mt: 0.5 }}>
|
||||
You will be guided through authenticator app setup.
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleEditSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Admin Password Reset Dialog ─────────────────────────────────────── */}
|
||||
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Reset Password for {resetUser?.username}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="New Password" type="password"
|
||||
value={resetForm.new_password}
|
||||
onChange={e => setResetForm({ ...resetForm, new_password: e.target.value })}
|
||||
margin="normal" required
|
||||
/>
|
||||
<PasswordStrengthIndicator password={resetForm.new_password} />
|
||||
<TextField fullWidth label="Confirm Password" type="password"
|
||||
value={resetForm.confirm_password}
|
||||
onChange={e => setResetForm({ ...resetForm, confirm_password: e.target.value })}
|
||||
margin="normal" required
|
||||
error={resetPwMismatch}
|
||||
helperText={resetPwMismatch ? 'Passwords do not match' : ''}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={resetForm.force_password_reset}
|
||||
onChange={e => setResetForm({ ...resetForm, force_password_reset: e.target.checked })} />
|
||||
}
|
||||
label="Force password reset on next login"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setResetOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" color="warning" onClick={handleResetSave}
|
||||
disabled={
|
||||
!resetForm.new_password ||
|
||||
!resetPwValid ||
|
||||
resetPwMismatch
|
||||
}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── MFA Disable Confirmation Dialog ──────────────────────────────────── */}
|
||||
<Dialog open={mfaConfirmOpen} onClose={() => setMfaConfirmOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Disable MFA</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to disable MFA for user <strong>{mfaDisableUser?.username}</strong>?
|
||||
This will reduce the security of their account.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setMfaConfirmOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" color="error" onClick={handleMfaDisableConfirm}>
|
||||
Disable MFA
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Confirmation Dialog ────────────────────────────────────────── */}
|
||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete user <strong>{deleteUser?.username}</strong>?
|
||||
This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" color="error" onClick={handleDeleteConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Snackbar ──────────────────────────────────────────────────────────── */}
|
||||
<Snackbar
|
||||
open={snack.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnack(s => ({ ...s, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setSnack(s => ({ ...s, open: false }))}
|
||||
severity={snack.severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{snack.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
77
frontend/src/store/authStore.ts
Normal file
77
frontend/src/store/authStore.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import axios from 'axios'
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isRestoring: boolean
|
||||
setTokens: (access: string, refresh: string) => void
|
||||
setUser: (user: User) => void
|
||||
logout: () => void
|
||||
restoreSession: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isRestoring: true,
|
||||
|
||||
setTokens: (access, refresh) =>
|
||||
set({ accessToken: access, refreshToken: refresh, isAuthenticated: true }),
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
|
||||
logout: () =>
|
||||
set({ accessToken: null, refreshToken: null, user: null, isAuthenticated: false, isRestoring: false }),
|
||||
|
||||
restoreSession: async () => {
|
||||
const { refreshToken } = get()
|
||||
if (!refreshToken) {
|
||||
console.warn('[auth] No refresh token found, skipping restoration')
|
||||
set({ isRestoring: false })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
'/api/v1/auth/refresh',
|
||||
{ refresh_token: refreshToken },
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
console.warn('[auth] Token refresh successful')
|
||||
set({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
user: data.user ?? get().user,
|
||||
isAuthenticated: true,
|
||||
isRestoring: false,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
const message = (err as Error)?.message
|
||||
console.warn('[auth] Token refresh failed:', status, message)
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isRestoring: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'pm-auth',
|
||||
// Only persist refresh token; access token regenerated on load
|
||||
partialize: (state) => ({ refreshToken: state.refreshToken, user: state.user }),
|
||||
}
|
||||
)
|
||||
)
|
||||
23
frontend/src/theme/theme.ts
Normal file
23
frontend/src/theme/theme.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { createTheme } from '@mui/material/styles'
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: '#1565C0' },
|
||||
secondary: { main: '#0288D1' },
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
},
|
||||
})
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#42A5F5' },
|
||||
secondary: { main: '#26C6DA' },
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
},
|
||||
})
|
||||
404
frontend/src/types/index.ts
Normal file
404
frontend/src/types/index.ts
Normal file
@ -0,0 +1,404 @@
|
||||
// Core TypeScript types — expanded per milestone
|
||||
|
||||
export type UserRole = 'admin' | 'operator' | 'reporter'
|
||||
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'
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
request_id?: string
|
||||
details?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
id: string
|
||||
fqdn: string
|
||||
ip_address: string
|
||||
display_name: string
|
||||
health_status: HostHealthStatus
|
||||
os_family?: string
|
||||
os_name?: string
|
||||
agent_version?: string
|
||||
patches_missing: number
|
||||
registered_at: string
|
||||
health_check_status?: 'all_healthy' | 'some_unhealthy' | 'none'
|
||||
}
|
||||
|
||||
export interface CreateHostRequest {
|
||||
fqdn: string
|
||||
display_name?: string
|
||||
agent_port?: number
|
||||
notes?: string
|
||||
group_ids?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateHostRequest {
|
||||
fqdn?: string
|
||||
ip_address?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
display_name: string
|
||||
email: string
|
||||
role: UserRole
|
||||
auth_provider: AuthProvider
|
||||
mfa_enabled: boolean
|
||||
is_active: boolean
|
||||
force_password_reset: boolean
|
||||
last_login_at?: string
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export interface AdminResetPasswordRequest {
|
||||
new_password: string
|
||||
force_password_reset?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
display_name?: string
|
||||
email?: string
|
||||
role?: string
|
||||
is_active?: boolean
|
||||
force_password_reset?: boolean
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string
|
||||
display_name?: string
|
||||
email: string
|
||||
role: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface FleetStatus {
|
||||
total_hosts: number
|
||||
healthy: number
|
||||
degraded: number
|
||||
unreachable: number
|
||||
pending: number
|
||||
total_pending_patches: number
|
||||
hosts_requiring_reboot: number
|
||||
compliance_pct: number
|
||||
}
|
||||
|
||||
export interface PatchInfo {
|
||||
name: string
|
||||
current_version: string
|
||||
available_version: string
|
||||
severity: 'critical' | 'high' | 'medium' | 'low'
|
||||
description: string
|
||||
cve_ids: string[]
|
||||
requires_reboot: boolean
|
||||
}
|
||||
|
||||
export interface PatchJobHost {
|
||||
id: string
|
||||
job_id: string
|
||||
host_id: string
|
||||
host_display_name: string
|
||||
status: JobStatus
|
||||
agent_job_id?: string
|
||||
retry_count: number
|
||||
output: string
|
||||
error_message?: string
|
||||
retry_next_at?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export interface PatchJob {
|
||||
id: string
|
||||
kind: JobKind
|
||||
status: JobStatus
|
||||
immediate: boolean
|
||||
patch_selection: string[]
|
||||
notes: string
|
||||
created_at: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
hosts: PatchJobHost[]
|
||||
}
|
||||
|
||||
export interface PatchJobSummary {
|
||||
id: string
|
||||
kind: JobKind
|
||||
status: JobStatus
|
||||
immediate: boolean
|
||||
host_count: number
|
||||
succeeded_count: number
|
||||
failed_count: number
|
||||
notes: string
|
||||
created_at: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export interface CreateJobRequest {
|
||||
host_ids: string[]
|
||||
packages: string[] // empty = all patches
|
||||
immediate: boolean
|
||||
maintenance_window_id?: string
|
||||
allow_reboot?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// ── Maintenance Windows ───────────────────────────────────────────────────────
|
||||
|
||||
export type WindowRecurrence = 'once' | 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string
|
||||
host_id: string
|
||||
label: string
|
||||
recurrence: WindowRecurrence
|
||||
/** Absolute start (once) or time-of-day reference (recurring) — ISO 8601 UTC */
|
||||
start_at: string
|
||||
/** Duration in minutes */
|
||||
duration_minutes: number
|
||||
/** 0-6 for weekly (0=Sun), 1-31 for monthly, null for once/daily */
|
||||
recurrence_day?: number | null
|
||||
enabled: boolean
|
||||
auto_apply: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateMaintenanceWindowRequest {
|
||||
label: string
|
||||
recurrence: WindowRecurrence
|
||||
start_at: string
|
||||
duration_minutes?: number
|
||||
recurrence_day?: number | null
|
||||
enabled?: boolean
|
||||
auto_apply?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateMaintenanceWindowRequest {
|
||||
label?: string
|
||||
recurrence?: WindowRecurrence
|
||||
start_at?: string
|
||||
duration_minutes?: number
|
||||
recurrence_day?: number | null
|
||||
enabled?: boolean
|
||||
auto_apply?: boolean
|
||||
}
|
||||
|
||||
// ── WebSocket event types (M7) ────────────────────────────────────────────────
|
||||
|
||||
export interface JobWsEvent {
|
||||
event_type?: 'host' | 'job' // defaults to 'host' for backward compat
|
||||
job_id: string
|
||||
host_id: string
|
||||
status: JobStatus
|
||||
output?: string
|
||||
error_message?: string
|
||||
agent_job_id?: string
|
||||
// Job-level fields (only present when event_type === 'job')
|
||||
succeeded_count?: number
|
||||
failed_count?: number
|
||||
host_count?: number
|
||||
}
|
||||
|
||||
// ── Certificates (M8) ────────────────────────────────────────────────────────
|
||||
|
||||
export type CertStatus = 'active' | 'revoked' | 'expired'
|
||||
|
||||
export interface Certificate {
|
||||
id: string
|
||||
host_id: string | null // null = root CA cert
|
||||
serial_number: string
|
||||
common_name: string
|
||||
status: CertStatus
|
||||
issued_at: string
|
||||
expires_at: string
|
||||
revoked_at: string | null
|
||||
cert_pem: string
|
||||
}
|
||||
|
||||
export interface IssuedCert {
|
||||
cert_pem: string
|
||||
key_pem: string
|
||||
serial_number: string
|
||||
expires_at: string
|
||||
server_cert_pem: string
|
||||
server_key_pem: string
|
||||
server_serial_number: string
|
||||
ca_root_pem: string
|
||||
}
|
||||
|
||||
// ── Reports (M9) ─────────────────────────────────────────────────────────────
|
||||
export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit'
|
||||
|
||||
// ── Settings (M10) ──────────────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated Use OidcConfigResponse instead */
|
||||
export interface AzureSsoConfig {
|
||||
enabled: boolean
|
||||
tenant_id: string
|
||||
client_id: string
|
||||
redirect_uri: string
|
||||
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
|
||||
port: number
|
||||
username: string
|
||||
from: string
|
||||
tls_mode: string
|
||||
}
|
||||
|
||||
export interface PollingConfig {
|
||||
health_poll_interval_secs: number
|
||||
patch_poll_interval_secs: number
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
email_enabled: boolean
|
||||
email_from: string
|
||||
recipients: string[]
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
oidc: OidcConfigResponse
|
||||
smtp: SmtpConfig
|
||||
polling: PollingConfig
|
||||
ip_whitelist: string[]
|
||||
web_tls_strategy: string
|
||||
notification: NotificationConfig
|
||||
}
|
||||
|
||||
export interface AuditIntegrityResult {
|
||||
intact: boolean
|
||||
rows_checked: number
|
||||
errors: Array<{
|
||||
row_id: number
|
||||
expected_hash: string
|
||||
actual_hash: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type ReportFormat = 'csv' | 'pdf'
|
||||
|
||||
// ── Health Checks ────────────────────────────────────────────────────────────
|
||||
|
||||
export type HealthCheckType = 'service' | 'http'
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string
|
||||
host_id: string
|
||||
name: string
|
||||
check_type: HealthCheckType
|
||||
enabled: boolean
|
||||
service_name?: string
|
||||
url?: string
|
||||
expected_body?: string
|
||||
ignore_cert_errors: boolean
|
||||
basic_auth_user?: string
|
||||
target_host_id?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface HealthCheckResult {
|
||||
id: string
|
||||
check_id: string
|
||||
healthy: boolean
|
||||
detail?: string
|
||||
latency_ms?: number
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export interface HealthCheckWithResult extends HealthCheck {
|
||||
last_result?: HealthCheckResult
|
||||
}
|
||||
|
||||
export interface HealthCheckListResponse {
|
||||
checks: HealthCheckWithResult[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CreateHealthCheckRequest {
|
||||
name: string
|
||||
check_type: HealthCheckType
|
||||
service_name?: string
|
||||
url?: string
|
||||
expected_body?: string
|
||||
ignore_cert_errors?: boolean
|
||||
basic_auth_user?: string
|
||||
basic_auth_pass?: string
|
||||
target_host_id?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateHealthCheckRequest {
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
service_name?: string
|
||||
url?: string
|
||||
expected_body?: string
|
||||
ignore_cert_errors?: boolean
|
||||
basic_auth_user?: string
|
||||
basic_auth_pass?: string
|
||||
target_host_id?: string | null
|
||||
}
|
||||
|
||||
// ── Enrollment (Self-Enrollment) ─────────────────────────────────────────
|
||||
export interface EnrollmentRequest {
|
||||
id: string
|
||||
machine_id: string
|
||||
fqdn: string
|
||||
ip_address: string
|
||||
os_details: Record<string, unknown>
|
||||
polling_token: string // hashed token stored in DB
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface EnrollmentConflictResponse {
|
||||
error: string
|
||||
conflict: {
|
||||
existing_host: Host
|
||||
message: string
|
||||
}
|
||||
}
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare const __APP_VERSION__: string;
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
42
frontend/vite.config.ts
Normal file
42
frontend/vite.config.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { version } from './package.json'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/status': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
mui: ['@mui/material', '@mui/icons-material'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user