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:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

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

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

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

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

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

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