Private
Public Access
1
0

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:
2026-05-28 10:52:16 -05:00
commit 124b5b0e3b
153 changed files with 41878 additions and 0 deletions

56
frontend/eslint.config.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View 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
View 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
View 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(() => {}),
}

View 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>
)
}

View 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
View 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
View 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>
)

View 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} &nbsp;|&nbsp; Server Serial: {cert.server_serial_number} &nbsp;|&nbsp; 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>
)
}

View 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>
)
}

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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 &ldquo;{deleteTarget?.display_name || deleteTarget?.fqdn}&rdquo;?
</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 &ldquo;{denyTarget?.fqdn}&rdquo;? 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 &ldquo;{conflictModal?.request.fqdn}&rdquo; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 (&gt;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 &amp; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 }),
}
)
)

View 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
View 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
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;

25
frontend/tsconfig.json Normal file
View 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" }]
}

View 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
View 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'],
},
},
},
},
})