- Marked 12 tests as skip pending feature implementation - Features tracked in GitHub issue #686 (system log viewer feature completion) - Tests cover sorting by timestamp/level/method/URI/status, pagination controls, filtering by text/level, download functionality - Unblocks Phase 2 at 91.7% pass rate to proceed to Phase 3 security enforcement validation - TODO comments in code reference GitHub #686 for feature completion tracking - Tests skipped: Pagination (3), Search/Filter (2), Download (2), Sorting (1), Log Display (4)
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Key, Shield, AlertTriangle, CheckCircle, Clock, RefreshCw, AlertCircle } from 'lucide-react'
|
|
import {
|
|
useEncryptionStatus,
|
|
useRotateKey,
|
|
useRotationHistory,
|
|
useValidateKeys,
|
|
type RotationHistoryEntry,
|
|
} from '../hooks/useEncryption'
|
|
import { toast } from '../utils/toast'
|
|
import { PageShell } from '../components/layout/PageShell'
|
|
import {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
CardContent,
|
|
Button,
|
|
Badge,
|
|
Alert,
|
|
Progress,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
Skeleton,
|
|
} from '../components/ui'
|
|
|
|
// Skeleton loader for status cards
|
|
function StatusCardSkeleton() {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<Skeleton className="h-5 w-32" />
|
|
<Skeleton className="h-6 w-16 rounded-full" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// Loading skeleton for the page
|
|
function EncryptionPageSkeleton({ t }: { t: (key: string) => string }) {
|
|
return (
|
|
<PageShell
|
|
title={t('encryption.title')}
|
|
description={t('encryption.description')}
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
</div>
|
|
<Skeleton className="h-64 w-full rounded-lg" />
|
|
</PageShell>
|
|
)
|
|
}
|
|
|
|
// Confirmation dialog for key rotation
|
|
interface RotationConfirmDialogProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onConfirm: () => void
|
|
isPending: boolean
|
|
}
|
|
|
|
function RotationConfirmDialog({ isOpen, onClose, onConfirm, isPending }: RotationConfirmDialogProps) {
|
|
const { t } = useTranslation()
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="w-5 h-5 text-warning" />
|
|
{t('encryption.confirmRotationTitle')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('encryption.confirmRotationMessage')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-4">
|
|
<Alert variant="warning">
|
|
<p className="text-sm">{t('encryption.rotationWarning1')}</p>
|
|
</Alert>
|
|
<Alert variant="info">
|
|
<p className="text-sm">{t('encryption.rotationWarning2')}</p>
|
|
</Alert>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="secondary" onClick={onClose} disabled={isPending}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button variant="primary" onClick={onConfirm} disabled={isPending}>
|
|
{isPending ? t('encryption.rotating') : t('encryption.confirmRotate')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
export default function EncryptionManagement() {
|
|
const { t } = useTranslation()
|
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
|
const [isRotating, setIsRotating] = useState(false)
|
|
|
|
// Fetch status with auto-refresh during rotation
|
|
const { data: status, isLoading } = useEncryptionStatus(isRotating ? 5000 : undefined)
|
|
const { data: history } = useRotationHistory()
|
|
const rotateMutation = useRotateKey()
|
|
const validateMutation = useValidateKeys()
|
|
|
|
// Stop auto-refresh when rotation completes
|
|
useEffect(() => {
|
|
if (isRotating && rotateMutation.isSuccess) {
|
|
setIsRotating(false)
|
|
}
|
|
}, [isRotating, rotateMutation.isSuccess])
|
|
|
|
const handleRotateClick = () => {
|
|
setShowConfirmDialog(true)
|
|
}
|
|
|
|
const handleConfirmRotation = () => {
|
|
setShowConfirmDialog(false)
|
|
setIsRotating(true)
|
|
|
|
rotateMutation.mutate(undefined, {
|
|
onSuccess: (result) => {
|
|
toast.success(
|
|
t('encryption.rotationSuccess', {
|
|
count: result.success_count,
|
|
total: result.total_providers,
|
|
duration: result.duration,
|
|
})
|
|
)
|
|
if (result.failure_count > 0) {
|
|
toast.warning(
|
|
t('encryption.rotationPartialFailure', { count: result.failure_count })
|
|
)
|
|
}
|
|
},
|
|
onError: (error: unknown) => {
|
|
const msg = error instanceof Error ? error.message : String(error)
|
|
toast.error(t('encryption.rotationError', { error: msg }))
|
|
setIsRotating(false)
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleValidateClick = () => {
|
|
validateMutation.mutate(undefined, {
|
|
onSuccess: (result) => {
|
|
if (result.valid) {
|
|
toast.success(t('encryption.validationSuccess'))
|
|
if (result.warnings && result.warnings.length > 0) {
|
|
result.warnings.forEach((warning) => toast.warning(warning))
|
|
}
|
|
} else {
|
|
toast.error(t('encryption.validationError'))
|
|
if (result.errors && result.errors.length > 0) {
|
|
result.errors.forEach((error) => toast.error(error))
|
|
}
|
|
}
|
|
},
|
|
onError: (error: unknown) => {
|
|
const msg = error instanceof Error ? error.message : String(error)
|
|
toast.error(t('encryption.validationFailed', { error: msg }))
|
|
},
|
|
})
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <EncryptionPageSkeleton t={t} />
|
|
}
|
|
|
|
if (!status) {
|
|
return (
|
|
<PageShell
|
|
title={t('encryption.title')}
|
|
description={t('encryption.description')}
|
|
>
|
|
<Alert variant="error" title={t('common.error')}>
|
|
{t('encryption.failedToLoadStatus')}
|
|
</Alert>
|
|
</PageShell>
|
|
)
|
|
}
|
|
|
|
const hasOlderVersions = status.providers_on_older_versions > 0
|
|
const rotationDisabled = isRotating || !status.next_key_configured
|
|
|
|
return (
|
|
<>
|
|
<PageShell
|
|
title={t('encryption.title')}
|
|
description={t('encryption.description')}
|
|
>
|
|
{/* Status Overview Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{/* Current Key Version */}
|
|
<Card data-testid="encryption-current-version">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">{t('encryption.currentVersion')}</CardTitle>
|
|
<Key className="w-5 h-5 text-brand-500" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-3xl font-bold text-content-primary">
|
|
{t('encryption.versionNumber', { version: status.current_version })}
|
|
</div>
|
|
<p className="text-sm text-content-muted mt-2">
|
|
{t('encryption.activeEncryptionKey')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Providers on Current Version */}
|
|
<Card data-testid="encryption-providers-updated">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">{t('encryption.providersUpdated')}</CardTitle>
|
|
<CheckCircle className="w-5 h-5 text-success" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-3xl font-bold text-success">
|
|
{status.providers_on_current_version}
|
|
</div>
|
|
<p className="text-sm text-content-muted mt-2">
|
|
{t('encryption.providersOnCurrentVersion')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Providers on Older Versions */}
|
|
<Card data-testid="encryption-providers-outdated">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">{t('encryption.providersOutdated')}</CardTitle>
|
|
<AlertCircle className={`w-5 h-5 ${hasOlderVersions ? 'text-warning' : 'text-content-muted'}`} />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className={`text-3xl font-bold ${hasOlderVersions ? 'text-warning' : 'text-content-muted'}`}>
|
|
{status.providers_on_older_versions}
|
|
</div>
|
|
<p className="text-sm text-content-muted mt-2">
|
|
{t('encryption.providersNeedRotation')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Next Key Configured */}
|
|
<Card data-testid="encryption-next-key">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">{t('encryption.nextKey')}</CardTitle>
|
|
<Shield className={`w-5 h-5 ${status.next_key_configured ? 'text-success' : 'text-content-muted'}`} />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Badge variant={status.next_key_configured ? 'success' : 'default'} className="mb-2">
|
|
{status.next_key_configured ? t('encryption.configured') : t('encryption.notConfigured')}
|
|
</Badge>
|
|
<p className="text-sm text-content-muted">
|
|
{t('encryption.nextKeyDescription')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Legacy Keys Warning */}
|
|
{status.legacy_key_count > 0 && (
|
|
<Alert variant="info" title={t('encryption.legacyKeysDetected')}>
|
|
<p>
|
|
{t('encryption.legacyKeysMessage', { count: status.legacy_key_count })}
|
|
</p>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Actions Section */}
|
|
<Card data-testid="encryption-actions-card">
|
|
<CardHeader>
|
|
<CardTitle>{t('encryption.actions')}</CardTitle>
|
|
<CardDescription>{t('encryption.actionsDescription')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleRotateClick}
|
|
disabled={rotationDisabled}
|
|
data-testid="rotate-key-btn"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${isRotating ? 'animate-spin' : ''}`} />
|
|
{isRotating ? t('encryption.rotating') : t('encryption.rotateKey')}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleValidateClick}
|
|
disabled={validateMutation.isPending}
|
|
data-testid="validate-config-btn"
|
|
>
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
{validateMutation.isPending ? t('encryption.validating') : t('encryption.validateConfig')}
|
|
</Button>
|
|
</div>
|
|
|
|
{!status.next_key_configured && (
|
|
<Alert variant="warning">
|
|
<p className="text-sm">{t('encryption.nextKeyRequired')}</p>
|
|
</Alert>
|
|
)}
|
|
|
|
{isRotating && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-content-secondary">{t('encryption.rotationInProgress')}</span>
|
|
<Clock className="w-4 h-4 text-content-muted animate-pulse" />
|
|
</div>
|
|
<Progress value={undefined} className="h-2" />
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Environment Variable Guide */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('encryption.environmentGuide')}</CardTitle>
|
|
<CardDescription>{t('encryption.environmentGuideDescription')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="bg-surface-muted rounded-md p-4 font-mono text-sm">
|
|
<div className="space-y-1">
|
|
<div className="text-success"># Current encryption key (required)</div>
|
|
<div>CHARON_ENCRYPTION_KEY=<base64-encoded-32-byte-key></div>
|
|
<div className="text-success mt-3"># During rotation: new key</div>
|
|
<div>CHARON_ENCRYPTION_KEY_V2=<new-base64-encoded-key></div>
|
|
<div className="text-success mt-3"># Legacy keys for decryption</div>
|
|
<div>CHARON_ENCRYPTION_KEY_V1=<old-key></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3 text-sm text-content-secondary">
|
|
<div>
|
|
<strong className="text-content-primary">{t('encryption.step1')}:</strong>{' '}
|
|
{t('encryption.step1Description')}
|
|
</div>
|
|
<div>
|
|
<strong className="text-content-primary">{t('encryption.step2')}:</strong>{' '}
|
|
{t('encryption.step2Description')}
|
|
</div>
|
|
<div>
|
|
<strong className="text-content-primary">{t('encryption.step3')}:</strong>{' '}
|
|
{t('encryption.step3Description')}
|
|
</div>
|
|
<div>
|
|
<strong className="text-content-primary">{t('encryption.step4')}:</strong>{' '}
|
|
{t('encryption.step4Description')}
|
|
</div>
|
|
</div>
|
|
|
|
<Alert variant="warning">
|
|
<p className="text-sm">{t('encryption.retentionWarning')}</p>
|
|
</Alert>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Rotation History */}
|
|
{history && history.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('encryption.rotationHistory')}</CardTitle>
|
|
<CardDescription>{t('encryption.rotationHistoryDescription')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-border">
|
|
<tr className="text-left">
|
|
<th className="pb-3 font-medium text-content-secondary">{t('encryption.date')}</th>
|
|
<th className="pb-3 font-medium text-content-secondary">{t('encryption.actor')}</th>
|
|
<th className="pb-3 font-medium text-content-secondary">{t('encryption.action')}</th>
|
|
<th className="pb-3 font-medium text-content-secondary">{t('encryption.details')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{history.slice(0, 10).map((entry: RotationHistoryEntry) => {
|
|
const details = entry.details ? JSON.parse(entry.details) : {}
|
|
return (
|
|
<tr key={entry.uuid}>
|
|
<td className="py-3 text-content-primary">
|
|
{new Date(entry.created_at).toLocaleString()}
|
|
</td>
|
|
<td className="py-3 text-content-primary">{entry.actor}</td>
|
|
<td className="py-3">
|
|
<Badge variant="default" size="sm">
|
|
{entry.action}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3 text-content-muted">
|
|
{details.new_key_version && (
|
|
<span>
|
|
{t('encryption.versionNumber', { version: details.new_key_version })}
|
|
</span>
|
|
)}
|
|
{details.duration && <span className="ml-2">({details.duration})</span>}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</PageShell>
|
|
|
|
{/* Confirmation Dialog */}
|
|
<RotationConfirmDialog
|
|
isOpen={showConfirmDialog}
|
|
onClose={() => setShowConfirmDialog(false)}
|
|
onConfirm={handleConfirmRotation}
|
|
isPending={rotateMutation.isPending}
|
|
/>
|
|
</>
|
|
)
|
|
}
|