feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
326
frontend/src/components/certificates/BulkDeployDialog.tsx
Normal file
326
frontend/src/components/certificates/BulkDeployDialog.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* BulkDeployDialog -- Multi-device certificate deployment dialog.
|
||||
*
|
||||
* Shows a checkbox list of devices without deployed certs, with Select All / Deselect All.
|
||||
* On deploy, calls bulkDeploy API and shows progress + results summary.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Layers,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import { certificatesApi } from '@/lib/certificatesApi'
|
||||
import { devicesApi, type DeviceResponse } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type BulkStep = 'select' | 'deploying' | 'results'
|
||||
|
||||
interface BulkResult {
|
||||
success: number
|
||||
failed: number
|
||||
errors: Array<{ device_id: string; error: string }>
|
||||
}
|
||||
|
||||
interface BulkDeployDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export function BulkDeployDialog({
|
||||
open,
|
||||
onClose,
|
||||
tenantId,
|
||||
}: BulkDeployDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [step, setStep] = useState<BulkStep>('select')
|
||||
const [result, setResult] = useState<BulkResult | null>(null)
|
||||
|
||||
// Fetch devices
|
||||
const { data: deviceList = [] } = useQuery({
|
||||
queryKey: ['devices-for-cert', tenantId],
|
||||
queryFn: async () => {
|
||||
const result = await devicesApi.list(tenantId)
|
||||
return (result as any).items ?? result
|
||||
},
|
||||
enabled: !!tenantId && open,
|
||||
})
|
||||
|
||||
// Fetch existing device certs to filter
|
||||
const { data: existingCerts = [] } = useQuery({
|
||||
queryKey: ['deviceCerts', tenantId],
|
||||
queryFn: () => certificatesApi.getDeviceCerts(undefined, tenantId),
|
||||
enabled: !!tenantId && open,
|
||||
})
|
||||
|
||||
const deployedDeviceIds = new Set(
|
||||
existingCerts
|
||||
.filter((c) => c.status === 'deployed' || c.status === 'deploying')
|
||||
.map((c) => c.device_id),
|
||||
)
|
||||
|
||||
const availableDevices = (deviceList as DeviceResponse[]).filter(
|
||||
(d) => !deployedDeviceIds.has(d.id),
|
||||
)
|
||||
|
||||
const toggleDevice = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
setSelected(new Set(availableDevices.map((d) => d.id)))
|
||||
}
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelected(new Set())
|
||||
}
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (selected.size === 0) return
|
||||
|
||||
setStep('deploying')
|
||||
try {
|
||||
const responses = await certificatesApi.bulkDeploy(Array.from(selected), tenantId)
|
||||
const succeeded = responses.filter((r) => r.success).length
|
||||
const failed = responses.filter((r) => !r.success)
|
||||
|
||||
const bulkResult: BulkResult = {
|
||||
success: succeeded,
|
||||
failed: failed.length,
|
||||
errors: failed.map((f) => ({
|
||||
device_id: f.device_id,
|
||||
error: f.error ?? 'Unknown error',
|
||||
})),
|
||||
}
|
||||
|
||||
setResult(bulkResult)
|
||||
setStep('results')
|
||||
void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] })
|
||||
|
||||
if (failed.length === 0) {
|
||||
toast({ title: `${succeeded} certificate(s) deployed successfully` })
|
||||
} else {
|
||||
toast({
|
||||
title: `${succeeded} deployed, ${failed.length} failed`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
setResult({
|
||||
success: 0,
|
||||
failed: selected.size,
|
||||
errors: [
|
||||
{
|
||||
device_id: 'bulk',
|
||||
error: e?.response?.data?.detail || 'Bulk deployment failed',
|
||||
},
|
||||
],
|
||||
})
|
||||
setStep('results')
|
||||
toast({ title: 'Bulk deployment failed', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onClose()
|
||||
setSelected(new Set())
|
||||
setStep('select')
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Certificate Deployment</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{step === 'select' && (
|
||||
<>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Select devices to sign and deploy TLS certificates in batch.
|
||||
</p>
|
||||
|
||||
{availableDevices.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-elevated/50 p-4 text-center">
|
||||
<CheckCircle className="h-6 w-6 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
All devices have certificates
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Every device already has a deployed certificate.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Select All / Deselect All */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-muted">
|
||||
{selected.size} of {availableDevices.length} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="text-xs text-accent hover:underline"
|
||||
onClick={selectAll}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
className="text-xs text-text-muted hover:underline"
|
||||
onClick={deselectAll}
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device list */}
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-border divide-y divide-border">
|
||||
{availableDevices.map((d: DeviceResponse) => (
|
||||
<label
|
||||
key={d.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-elevated/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(d.id)}
|
||||
onCheckedChange={() => toggleDevice(d.id)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-text-primary block truncate">
|
||||
{d.hostname}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{d.ip_address}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] uppercase px-1.5 py-0.5 rounded',
|
||||
d.status === 'online'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-text-muted/10 text-text-muted',
|
||||
)}
|
||||
>
|
||||
{d.status}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
Deploy to {selected.size} device
|
||||
{selected.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'deploying' && (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<Loader2 className="h-8 w-8 text-accent mx-auto animate-spin" />
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
Deploying certificates...
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Signing and deploying to {selected.size} device
|
||||
{selected.size !== 1 ? 's' : ''}. This may take a moment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'results' && result && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-green-500/30 bg-green-500/5 p-4 text-center">
|
||||
<CheckCircle className="h-6 w-6 text-green-500 mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold text-green-500">
|
||||
{result.success}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">Succeeded</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4 text-center',
|
||||
result.failed > 0
|
||||
? 'border-error/30 bg-error/5'
|
||||
: 'border-border bg-surface',
|
||||
)}
|
||||
>
|
||||
<XCircle
|
||||
className={cn(
|
||||
'h-6 w-6 mx-auto mb-1',
|
||||
result.failed > 0 ? 'text-error' : 'text-text-muted',
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'text-2xl font-bold',
|
||||
result.failed > 0 ? 'text-error' : 'text-text-muted',
|
||||
)}
|
||||
>
|
||||
{result.failed}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error details */}
|
||||
{result.errors.length > 0 && (
|
||||
<div className="rounded-lg border border-error/30 bg-error/5 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-error">
|
||||
Failed deployments:
|
||||
</p>
|
||||
{result.errors.map((err, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs text-text-secondary flex items-start gap-2"
|
||||
>
|
||||
<XCircle className="h-3 w-3 text-error mt-0.5 flex-shrink-0" />
|
||||
<span>{err.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full" onClick={handleClose}>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
229
frontend/src/components/certificates/CAStatusCard.tsx
Normal file
229
frontend/src/components/certificates/CAStatusCard.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* CAStatusCard -- Shows the CA initialization state or active CA details.
|
||||
*
|
||||
* When NO CA exists: centered prompt with "Initialize CA" button.
|
||||
* When CA exists: card with fingerprint, validity, download, and status badge.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Download,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { certificatesApi, type CAResponse } from '@/lib/certificatesApi'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CAStatusCardProps {
|
||||
ca: CAResponse | null
|
||||
canWrite: boolean
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export function CAStatusCard({ ca, canWrite: writable, tenantId }: CAStatusCardProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const initMutation = useMutation({
|
||||
mutationFn: () => certificatesApi.createCA(undefined, undefined, tenantId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['ca'] })
|
||||
toast({ title: 'Certificate Authority initialized' })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast({
|
||||
title: e?.response?.data?.detail || 'Failed to initialize CA',
|
||||
variant: 'destructive',
|
||||
}),
|
||||
})
|
||||
|
||||
const handleDownloadPEM = async () => {
|
||||
try {
|
||||
const pem = await certificatesApi.getCACertPEM(tenantId)
|
||||
const blob = new Blob([pem], { type: 'application/x-pem-file' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'portal-ca.pem'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast({ title: 'CA certificate downloaded' })
|
||||
} catch {
|
||||
toast({ title: 'Failed to download certificate', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
const copyFingerprint = (fp: string) => {
|
||||
navigator.clipboard.writeText(fp)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
toast({ title: 'Fingerprint copied' })
|
||||
}
|
||||
|
||||
const isExpired = ca
|
||||
? new Date(ca.not_valid_after) < new Date()
|
||||
: false
|
||||
|
||||
// ── No CA state ──
|
||||
if (!ca) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="rounded-xl border border-border bg-surface p-8 text-center space-y-6">
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-accent/10 flex items-center justify-center">
|
||||
<Shield className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-text-primary">
|
||||
No Certificate Authority
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary mt-2 max-w-sm mx-auto">
|
||||
Initialize a Certificate Authority to secure device API connections
|
||||
with proper TLS certificates.
|
||||
</p>
|
||||
</div>
|
||||
{writable && (
|
||||
<Button
|
||||
onClick={() => initMutation.mutate()}
|
||||
disabled={initMutation.isPending}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{initMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Initializing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||
Initialize CA
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CA exists state ──
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border bg-surface p-6 space-y-4',
|
||||
isExpired ? 'border-error/40' : 'border-green-500/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center',
|
||||
isExpired ? 'bg-error/10' : 'bg-green-500/10',
|
||||
)}
|
||||
>
|
||||
<ShieldCheck
|
||||
className={cn(
|
||||
'h-5 w-5',
|
||||
isExpired ? 'text-error' : 'text-green-500',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
{ca.common_name}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border mt-0.5',
|
||||
isExpired
|
||||
? 'bg-error/20 text-error border-error/40'
|
||||
: 'bg-green-500/20 text-green-500 border-green-500/40',
|
||||
)}
|
||||
>
|
||||
{isExpired ? 'Expired' : 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadPEM}>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download CA Cert
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
{/* Fingerprint */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-text-muted uppercase tracking-wider">
|
||||
SHA-256 Fingerprint
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-text-secondary truncate max-w-[280px]">
|
||||
{ca.fingerprint_sha256}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyFingerprint(ca.fingerprint_sha256)}
|
||||
className="text-text-muted hover:text-text-secondary flex-shrink-0"
|
||||
title="Copy fingerprint"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Serial */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-text-muted uppercase tracking-wider">
|
||||
Serial Number
|
||||
</span>
|
||||
<code className="text-xs font-mono text-text-secondary block">
|
||||
{ca.serial_number}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Valid From */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-text-muted uppercase tracking-wider">
|
||||
Valid From
|
||||
</span>
|
||||
<span className="text-text-primary block">
|
||||
{new Date(ca.not_valid_before).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Valid Until */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-text-muted uppercase tracking-wider">
|
||||
Valid Until
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'block',
|
||||
isExpired ? 'text-error font-medium' : 'text-text-primary',
|
||||
)}
|
||||
>
|
||||
{new Date(ca.not_valid_after).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
frontend/src/components/certificates/CertConfirmDialog.tsx
Normal file
146
frontend/src/components/certificates/CertConfirmDialog.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* CertConfirmDialog -- Confirmation dialog for certificate operations.
|
||||
*
|
||||
* - Rotate: Standard confirmation with consequence text.
|
||||
* - Revoke: Type-to-confirm (must type hostname), destructive red styling.
|
||||
*
|
||||
* Uses the project's existing Dialog primitives (Radix react-dialog).
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AlertTriangle, RefreshCw, XCircle } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface CertConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
action: 'rotate' | 'revoke'
|
||||
deviceHostname: string
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function CertConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
action,
|
||||
deviceHostname,
|
||||
onConfirm,
|
||||
}: CertConfirmDialogProps) {
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const isRevoke = action === 'revoke'
|
||||
const canConfirm = isRevoke ? confirmText === deviceHostname : true
|
||||
|
||||
// Reset confirm text when dialog opens/closes or action changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setConfirmText('')
|
||||
}
|
||||
}, [open, action])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!canConfirm) return
|
||||
onConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${
|
||||
isRevoke ? 'bg-error/10' : 'bg-amber-500/10'
|
||||
}`}
|
||||
>
|
||||
{isRevoke ? (
|
||||
<XCircle className="h-5 w-5 text-error" />
|
||||
) : (
|
||||
<RefreshCw className="h-5 w-5 text-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
<DialogTitle className="text-lg">
|
||||
{isRevoke ? 'Revoke Certificate' : 'Rotate Certificate'}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{isRevoke
|
||||
? `This will permanently revoke the certificate for ${deviceHostname}. The device will fall back to insecure TLS mode.`
|
||||
: `This will generate a new certificate for ${deviceHostname}. The old certificate will be superseded.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 my-2">
|
||||
{/* Warning callout */}
|
||||
<div
|
||||
className={`flex items-start gap-3 rounded-md p-3 ${
|
||||
isRevoke
|
||||
? 'bg-error/10 border border-error/30'
|
||||
: 'bg-amber-500/10 border border-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle
|
||||
className={`h-4 w-4 mt-0.5 shrink-0 ${
|
||||
isRevoke ? 'text-error' : 'text-amber-500'
|
||||
}`}
|
||||
/>
|
||||
<p className="text-xs text-text-secondary leading-relaxed">
|
||||
{isRevoke
|
||||
? 'This action cannot be undone. The device will lose its verified TLS certificate and revert to self-signed mode until a new certificate is deployed.'
|
||||
: 'The current certificate will be marked as superseded. A new certificate will be signed and deployed to the device.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Type-to-confirm for revoke */}
|
||||
{isRevoke && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm-hostname" className="text-sm">
|
||||
Type <span className="font-mono font-semibold text-text-primary">{deviceHostname}</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-hostname"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={deviceHostname}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={isRevoke ? 'destructive' : 'default'}
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
{isRevoke ? (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 mr-1.5" />
|
||||
Revoke Certificate
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
Rotate Certificate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
115
frontend/src/components/certificates/CertificatesPage.tsx
Normal file
115
frontend/src/components/certificates/CertificatesPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* CertificatesPage -- Main certificate management page.
|
||||
*
|
||||
* Two sections:
|
||||
* 1. CA Status Card -- shows CA state or initialization prompt
|
||||
* 2. Device Certificates Table -- per-device cert status with actions
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { Shield, Building2 } from 'lucide-react'
|
||||
import {
|
||||
certificatesApi,
|
||||
type CAResponse,
|
||||
type DeviceCertResponse,
|
||||
} from '@/lib/certificatesApi'
|
||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||
import { canWrite } from '@/lib/auth'
|
||||
import { CAStatusCard } from './CAStatusCard'
|
||||
import { DeviceCertTable } from './DeviceCertTable'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||
|
||||
export function CertificatesPage() {
|
||||
const { user } = useAuth()
|
||||
const writable = canWrite(user)
|
||||
|
||||
const { selectedTenantId } = useUIStore()
|
||||
const tenantId = isSuperAdmin(user)
|
||||
? (selectedTenantId ?? '')
|
||||
: (user?.tenant_id ?? '')
|
||||
|
||||
// ── Queries ──
|
||||
|
||||
const {
|
||||
data: ca,
|
||||
isLoading: caLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['ca', tenantId],
|
||||
queryFn: () => certificatesApi.getCA(tenantId),
|
||||
enabled: !!tenantId,
|
||||
})
|
||||
|
||||
const {
|
||||
data: deviceCerts = [],
|
||||
isLoading: certsLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['deviceCerts', tenantId],
|
||||
queryFn: () => certificatesApi.getDeviceCerts(undefined, tenantId),
|
||||
enabled: !!tenantId && ca !== undefined,
|
||||
})
|
||||
|
||||
// Super admin needs to select a tenant from the header
|
||||
if (isSuperAdmin(user) && !tenantId) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-text-muted" />
|
||||
<h1 className="text-2xl font-bold text-text-primary">
|
||||
Certificate Authority
|
||||
</h1>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="No Organization Selected"
|
||||
description="Select an organization from the header to manage certificates."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (caLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-text-muted" />
|
||||
<h1 className="text-2xl font-bold text-text-primary">
|
||||
Certificate Authority
|
||||
</h1>
|
||||
</div>
|
||||
<TableSkeleton rows={3} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-text-muted" />
|
||||
<h1 className="text-2xl font-bold text-text-primary">
|
||||
Certificate Authority
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* CA Status */}
|
||||
<section>
|
||||
<CAStatusCard ca={ca ?? null} canWrite={writable} tenantId={tenantId} />
|
||||
</section>
|
||||
|
||||
{/* Device Certificates (only when CA exists) */}
|
||||
{ca && (
|
||||
<section>
|
||||
<DeviceCertTable
|
||||
certs={deviceCerts}
|
||||
loading={certsLoading}
|
||||
caExists={!!ca}
|
||||
canWrite={writable}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
frontend/src/components/certificates/DeployCertDialog.tsx
Normal file
256
frontend/src/components/certificates/DeployCertDialog.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* DeployCertDialog -- Dialog for signing and deploying a certificate to a single device.
|
||||
*
|
||||
* Flow: select device -> sign cert -> deploy to device -> done.
|
||||
* Shows progress states: Signing... -> Deploying... -> Done.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ShieldCheck,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { certificatesApi } from '@/lib/certificatesApi'
|
||||
import { devicesApi, type DeviceResponse } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
type DeployStep = 'idle' | 'signing' | 'deploying' | 'done' | 'error'
|
||||
|
||||
interface DeployCertDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export function DeployCertDialog({
|
||||
open,
|
||||
onClose,
|
||||
tenantId,
|
||||
}: DeployCertDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedDevice, setSelectedDevice] = useState('')
|
||||
const [validityDays, setValidityDays] = useState('730')
|
||||
const [step, setStep] = useState<DeployStep>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
// Fetch devices for the selector
|
||||
const { data: deviceList = [] } = useQuery({
|
||||
queryKey: ['devices-for-cert', tenantId],
|
||||
queryFn: async () => {
|
||||
const result = await devicesApi.list(tenantId)
|
||||
// The list endpoint returns { items, total, ... } or an array
|
||||
return (result as any).items ?? result
|
||||
},
|
||||
enabled: !!tenantId && open,
|
||||
})
|
||||
|
||||
// Fetch existing device certs to filter out devices that already have deployed certs
|
||||
const { data: existingCerts = [] } = useQuery({
|
||||
queryKey: ['deviceCerts', tenantId],
|
||||
queryFn: () => certificatesApi.getDeviceCerts(undefined, tenantId),
|
||||
enabled: !!tenantId && open,
|
||||
})
|
||||
|
||||
const deployedDeviceIds = new Set(
|
||||
existingCerts
|
||||
.filter((c) => c.status === 'deployed' || c.status === 'deploying')
|
||||
.map((c) => c.device_id),
|
||||
)
|
||||
|
||||
const availableDevices = (deviceList as DeviceResponse[]).filter(
|
||||
(d) => !deployedDeviceIds.has(d.id),
|
||||
)
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!selectedDevice) return
|
||||
|
||||
try {
|
||||
// Step 1: Sign
|
||||
setStep('signing')
|
||||
const cert = await certificatesApi.signCert(
|
||||
selectedDevice,
|
||||
Number(validityDays) || 730,
|
||||
tenantId,
|
||||
)
|
||||
|
||||
// Step 2: Deploy
|
||||
setStep('deploying')
|
||||
const result = await certificatesApi.deployCert(cert.id, tenantId)
|
||||
|
||||
if (result.success) {
|
||||
setStep('done')
|
||||
void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] })
|
||||
toast({ title: 'Certificate signed and deployed' })
|
||||
// Auto-close after a brief delay
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
resetState()
|
||||
}, 1500)
|
||||
} else {
|
||||
setStep('error')
|
||||
setErrorMsg(result.error ?? 'Deployment failed')
|
||||
toast({ title: result.error ?? 'Deployment failed', variant: 'destructive' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
setStep('error')
|
||||
const detail = e?.response?.data?.detail || 'Failed to deploy certificate'
|
||||
setErrorMsg(detail)
|
||||
toast({ title: detail, variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
setSelectedDevice('')
|
||||
setValidityDays('730')
|
||||
setStep('idle')
|
||||
setErrorMsg('')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onClose()
|
||||
resetState()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign & Deploy Certificate</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{step === 'idle' && (
|
||||
<>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Select a device to sign a TLS certificate and deploy it
|
||||
automatically.
|
||||
</p>
|
||||
|
||||
{availableDevices.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-elevated/50 p-4 text-center">
|
||||
<CheckCircle className="h-6 w-6 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
All devices have certificates
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Every device already has a deployed certificate. Use rotate to
|
||||
renew.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Label>Device</Label>
|
||||
<Select
|
||||
value={selectedDevice}
|
||||
onValueChange={setSelectedDevice}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a device..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDevices.map((d: DeviceResponse) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.hostname} ({d.ip_address})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Validity (days)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={validityDays}
|
||||
onChange={(e) => setValidityDays(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Default: 730 days (2 years)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!selectedDevice}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||
Sign & Deploy
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'signing' && (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<Loader2 className="h-8 w-8 text-accent mx-auto animate-spin" />
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
Creating secure certificate for this device...
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Generating device certificate with your CA
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'deploying' && (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<Loader2 className="h-8 w-8 text-accent mx-auto animate-spin" />
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
Deploying to device...
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Uploading certificate via SFTP and configuring TLS
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<CheckCircle className="h-8 w-8 text-green-500 mx-auto" />
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
Certificate deployed successfully
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'error' && (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<XCircle className="h-8 w-8 text-error mx-auto" />
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
Deployment failed
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{errorMsg}</p>
|
||||
<Button variant="outline" size="sm" onClick={resetState}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
434
frontend/src/components/certificates/DeviceCertTable.tsx
Normal file
434
frontend/src/components/certificates/DeviceCertTable.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* DeviceCertTable -- Table of device certificates with status badges,
|
||||
* action dropdown (deploy/rotate/revoke), toolbar with Sign & Deploy / Bulk Deploy buttons.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
Plus,
|
||||
Layers,
|
||||
MoreHorizontal,
|
||||
Upload,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
certificatesApi,
|
||||
type DeviceCertResponse,
|
||||
} from '@/lib/certificatesApi'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||
import { DeployCertDialog } from './DeployCertDialog'
|
||||
import { BulkDeployDialog } from './BulkDeployDialog'
|
||||
import { CertConfirmDialog } from './CertConfirmDialog'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badge config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; className: string; icon?: React.FC<{ className?: string }> }
|
||||
> = {
|
||||
issued: {
|
||||
label: 'Issued',
|
||||
className: 'bg-info/20 text-info border-info/40',
|
||||
},
|
||||
deploying: {
|
||||
label: 'Deploying...',
|
||||
className: 'bg-amber-500/20 text-amber-500 border-amber-500/40',
|
||||
icon: Loader2,
|
||||
},
|
||||
deployed: {
|
||||
label: 'Deployed',
|
||||
className: 'bg-green-500/20 text-green-500 border-green-500/40',
|
||||
},
|
||||
expiring: {
|
||||
label: 'Expiring Soon',
|
||||
className: 'bg-yellow-500/20 text-yellow-500 border-yellow-500/40',
|
||||
},
|
||||
expired: {
|
||||
label: 'Expired',
|
||||
className: 'bg-error/20 text-error border-error/40',
|
||||
},
|
||||
revoked: {
|
||||
label: 'Revoked',
|
||||
className: 'bg-text-muted/20 text-text-muted border-text-muted/40',
|
||||
},
|
||||
superseded: {
|
||||
label: 'Superseded',
|
||||
className: 'bg-text-muted/20 text-text-muted border-text-muted/40',
|
||||
},
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.issued
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
|
||||
config.className,
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-3 w-3 animate-spin" />}
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeviceCertTableProps {
|
||||
certs: DeviceCertResponse[]
|
||||
loading: boolean
|
||||
caExists: boolean
|
||||
canWrite: boolean
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export function DeviceCertTable({
|
||||
certs,
|
||||
loading,
|
||||
caExists,
|
||||
canWrite: writable,
|
||||
tenantId,
|
||||
}: DeviceCertTableProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showDeployDialog, setShowDeployDialog] = useState(false)
|
||||
const [showBulkDialog, setShowBulkDialog] = useState(false)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
action: 'rotate' | 'revoke'
|
||||
certId: string
|
||||
hostname: string
|
||||
} | null>(null)
|
||||
|
||||
// ── Mutations ──
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: (certId: string) => certificatesApi.deployCert(certId, tenantId),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] })
|
||||
if (result.success) {
|
||||
toast({ title: 'Certificate deployed successfully' })
|
||||
} else {
|
||||
toast({ title: result.error ?? 'Deployment failed', variant: 'destructive' })
|
||||
}
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast({
|
||||
title: e?.response?.data?.detail || 'Failed to deploy certificate',
|
||||
variant: 'destructive',
|
||||
}),
|
||||
})
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: (certId: string) => certificatesApi.rotateCert(certId, tenantId),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] })
|
||||
if (result.success) {
|
||||
toast({ title: 'Certificate rotated successfully' })
|
||||
} else {
|
||||
toast({ title: result.error ?? 'Rotation failed', variant: 'destructive' })
|
||||
}
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast({
|
||||
title: e?.response?.data?.detail || 'Failed to rotate certificate',
|
||||
variant: 'destructive',
|
||||
}),
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (certId: string) => certificatesApi.revokeCert(certId, tenantId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['deviceCerts'] })
|
||||
toast({ title: 'Certificate revoked' })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast({
|
||||
title: e?.response?.data?.detail || 'Failed to revoke certificate',
|
||||
variant: 'destructive',
|
||||
}),
|
||||
})
|
||||
|
||||
// ── Filtering ──
|
||||
// By default hide superseded certs; show only latest per device
|
||||
const filteredCerts = showAll
|
||||
? certs
|
||||
: certs.filter((c) => c.status !== 'superseded')
|
||||
|
||||
const isExpiringSoon = (dateStr: string) => {
|
||||
const expiry = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const daysLeft = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
||||
return daysLeft <= 30
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <TableSkeleton rows={4} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-text-secondary">
|
||||
Device Certificates
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Toggle superseded */}
|
||||
{certs.some((c) => c.status === 'superseded') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
title={showAll ? 'Hide superseded' : 'Show all'}
|
||||
>
|
||||
{showAll ? (
|
||||
<><EyeOff className="h-3.5 w-3.5 mr-1" /> Hide Superseded</>
|
||||
) : (
|
||||
<><Eye className="h-3.5 w-3.5 mr-1" /> Show All</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{writable && caExists && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowBulkDialog(true)}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 mr-1.5" />
|
||||
Bulk Deploy
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowDeployDialog(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Sign & Deploy
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredCerts.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-accent/30 bg-accent/5 p-8 text-center space-y-3">
|
||||
<ShieldCheck className="h-10 w-10 text-accent mx-auto" />
|
||||
<h3 className="text-base font-semibold text-text-primary">
|
||||
No device certificates yet
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary max-w-md mx-auto">
|
||||
Deploy certificates to your devices to secure API connections with
|
||||
proper TLS.
|
||||
</p>
|
||||
{writable && caExists && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowDeployDialog(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" /> Deploy Your First Certificate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Table */
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-elevated/50 text-left">
|
||||
<th className="px-4 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Device
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Fingerprint
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Valid Until
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Deployed
|
||||
</th>
|
||||
{writable && (
|
||||
<th className="px-4 py-3 text-xs font-medium text-text-muted uppercase tracking-wider text-right">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredCerts.map((cert) => {
|
||||
const expired = new Date(cert.not_valid_after) < new Date()
|
||||
const expiringSoon =
|
||||
!expired && isExpiringSoon(cert.not_valid_after)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={cert.id}
|
||||
className="hover:bg-elevated/30 transition-colors"
|
||||
>
|
||||
{/* Device */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{cert.common_name}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Fingerprint */}
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs font-mono text-text-secondary">
|
||||
{cert.fingerprint_sha256.slice(0, 24)}...
|
||||
</code>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={cert.status} />
|
||||
</td>
|
||||
|
||||
{/* Valid Until */}
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
expired || expiringSoon
|
||||
? 'text-error font-medium'
|
||||
: 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{new Date(cert.not_valid_after).toLocaleDateString()}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Deployed At */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{cert.deployed_at
|
||||
? new Date(cert.deployed_at).toLocaleDateString()
|
||||
: '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
{writable && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" aria-label={`Actions for ${cert.common_name}`}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{cert.status === 'issued' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => deployMutation.mutate(cert.id)}
|
||||
disabled={deployMutation.isPending}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 mr-2" />
|
||||
Deploy
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(cert.status === 'deployed' || cert.status === 'expiring') && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setConfirmAction({
|
||||
action: 'rotate',
|
||||
certId: cert.id,
|
||||
hostname: cert.common_name,
|
||||
})
|
||||
}}
|
||||
disabled={rotateMutation.isPending}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-2" />
|
||||
Rotate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setConfirmAction({
|
||||
action: 'revoke',
|
||||
certId: cert.id,
|
||||
hostname: cert.common_name,
|
||||
})
|
||||
}}
|
||||
disabled={revokeMutation.isPending}
|
||||
className="text-error focus:text-error"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5 mr-2" />
|
||||
Revoke
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!['issued', 'deployed', 'expiring'].includes(cert.status) && (
|
||||
<DropdownMenuItem disabled>
|
||||
No actions available
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
{showDeployDialog && (
|
||||
<DeployCertDialog
|
||||
open={showDeployDialog}
|
||||
onClose={() => setShowDeployDialog(false)}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
)}
|
||||
{showBulkDialog && (
|
||||
<BulkDeployDialog
|
||||
open={showBulkDialog}
|
||||
onClose={() => setShowBulkDialog(false)}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Certificate action confirmation dialog */}
|
||||
<CertConfirmDialog
|
||||
open={!!confirmAction}
|
||||
onOpenChange={(open) => !open && setConfirmAction(null)}
|
||||
action={confirmAction?.action ?? 'rotate'}
|
||||
deviceHostname={confirmAction?.hostname ?? ''}
|
||||
onConfirm={() => {
|
||||
if (confirmAction?.action === 'rotate') {
|
||||
rotateMutation.mutate(confirmAction.certId)
|
||||
} else if (confirmAction?.action === 'revoke') {
|
||||
revokeMutation.mutate(confirmAction.certId)
|
||||
}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user