422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
import { useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Key, Plus, Copy, Trash2, AlertTriangle, Check } from 'lucide-react'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import {
|
|
apiKeysApi,
|
|
type ApiKeyResponse,
|
|
type ApiKeyCreateResponse,
|
|
} from '@/lib/api'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
|
|
const AVAILABLE_SCOPES = [
|
|
{ id: 'devices:read', label: 'Devices: Read' },
|
|
{ id: 'devices:write', label: 'Devices: Write' },
|
|
{ id: 'config:read', label: 'Config: Read' },
|
|
{ id: 'config:write', label: 'Config: Write' },
|
|
{ id: 'alerts:read', label: 'Alerts: Read' },
|
|
{ id: 'firmware:write', label: 'Firmware: Write' },
|
|
] as const
|
|
|
|
function formatRelativeTime(iso: string | null): string {
|
|
if (!iso) return 'Never'
|
|
const date = new Date(iso)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMins = Math.floor(diffMs / 60000)
|
|
if (diffMins < 1) return 'Just now'
|
|
if (diffMins < 60) return `${diffMins}m ago`
|
|
const diffHours = Math.floor(diffMins / 60)
|
|
if (diffHours < 24) return `${diffHours}h ago`
|
|
const diffDays = Math.floor(diffHours / 24)
|
|
if (diffDays < 30) return `${diffDays}d ago`
|
|
return date.toLocaleDateString()
|
|
}
|
|
|
|
function formatDate(iso: string | null): string {
|
|
if (!iso) return 'Never'
|
|
return new Date(iso).toLocaleDateString()
|
|
}
|
|
|
|
function getKeyStatus(key: ApiKeyResponse): {
|
|
label: string
|
|
className: string
|
|
} {
|
|
if (key.revoked_at) {
|
|
return { label: 'Revoked', className: 'border-border bg-elevated/50 text-text-muted' }
|
|
}
|
|
if (key.expires_at && new Date(key.expires_at) <= new Date()) {
|
|
return { label: 'Expired', className: 'border-error/30 bg-error/10 text-error' }
|
|
}
|
|
return { label: 'Active', className: 'border-success/30 bg-success/10 text-success' }
|
|
}
|
|
|
|
interface ApiKeysPageProps {
|
|
tenantId: string
|
|
}
|
|
|
|
export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
|
|
const queryClient = useQueryClient()
|
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
|
const [showKeyDialog, setShowKeyDialog] = useState(false)
|
|
const [showRevokeDialog, setShowRevokeDialog] = useState(false)
|
|
const [revokeTarget, setRevokeTarget] = useState<ApiKeyResponse | null>(null)
|
|
const [newKey, setNewKey] = useState<ApiKeyCreateResponse | null>(null)
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
// Create form state
|
|
const [name, setName] = useState('')
|
|
const [selectedScopes, setSelectedScopes] = useState<string[]>([])
|
|
const [expiresAt, setExpiresAt] = useState('')
|
|
|
|
const keysQuery = useQuery({
|
|
queryKey: ['api-keys', tenantId],
|
|
queryFn: () => apiKeysApi.list(tenantId),
|
|
enabled: !!tenantId,
|
|
})
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: { name: string; scopes: string[]; expires_at?: string }) =>
|
|
apiKeysApi.create(tenantId, data),
|
|
onSuccess: (data) => {
|
|
setNewKey(data)
|
|
setShowCreateDialog(false)
|
|
setShowKeyDialog(true)
|
|
resetForm()
|
|
queryClient.invalidateQueries({ queryKey: ['api-keys', tenantId] })
|
|
},
|
|
})
|
|
|
|
const revokeMutation = useMutation({
|
|
mutationFn: (keyId: string) => apiKeysApi.revoke(tenantId, keyId),
|
|
onSuccess: () => {
|
|
setShowRevokeDialog(false)
|
|
setRevokeTarget(null)
|
|
queryClient.invalidateQueries({ queryKey: ['api-keys', tenantId] })
|
|
},
|
|
})
|
|
|
|
function resetForm() {
|
|
setName('')
|
|
setSelectedScopes([])
|
|
setExpiresAt('')
|
|
}
|
|
|
|
function handleCreate() {
|
|
const data: { name: string; scopes: string[]; expires_at?: string } = {
|
|
name,
|
|
scopes: selectedScopes,
|
|
}
|
|
if (expiresAt) {
|
|
data.expires_at = new Date(expiresAt).toISOString()
|
|
}
|
|
createMutation.mutate(data)
|
|
}
|
|
|
|
function toggleScope(scope: string) {
|
|
setSelectedScopes((prev) =>
|
|
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope],
|
|
)
|
|
}
|
|
|
|
async function copyKey() {
|
|
if (!newKey) return
|
|
await navigator.clipboard.writeText(newKey.key)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
const keys = keysQuery.data ?? []
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Key className="h-5 w-5 text-text-muted" />
|
|
<h1 className="text-lg font-semibold">API Keys</h1>
|
|
</div>
|
|
<p className="text-sm text-text-muted mt-0.5">
|
|
Create and manage API keys for programmatic access
|
|
</p>
|
|
</div>
|
|
<Button onClick={() => setShowCreateDialog(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Create API Key
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Key list */}
|
|
{keys.length === 0 && !keysQuery.isLoading ? (
|
|
<EmptyState
|
|
icon={Key}
|
|
title="No API keys"
|
|
description="Create API keys for programmatic access to the portal."
|
|
action={{ label: 'Create API Key', onClick: () => setShowCreateDialog(true) }}
|
|
/>
|
|
) : (
|
|
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-elevated/30">
|
|
<th className="text-left px-4 py-2.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Name</th>
|
|
<th className="text-left px-4 py-2.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Key</th>
|
|
<th className="text-left px-4 py-2.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Scopes</th>
|
|
<th className="text-left px-4 py-2.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Last Used</th>
|
|
<th className="text-left px-4 py-2.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Expires</th>
|
|
<th className="text-left px-4 py-2.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Status</th>
|
|
<th className="text-right px-4 py-2.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{keys.map((key) => {
|
|
const status = getKeyStatus(key)
|
|
const isInactive = !!key.revoked_at
|
|
return (
|
|
<tr
|
|
key={key.id}
|
|
className={`border-b border-border/50 last:border-0 ${isInactive ? 'opacity-50' : ''}`}
|
|
>
|
|
<td className="px-4 py-3 font-medium">{key.name}</td>
|
|
<td className="px-4 py-3">
|
|
<code className="text-xs font-mono bg-elevated/50 px-1.5 py-0.5 rounded">
|
|
{key.key_prefix}...
|
|
</code>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex flex-wrap gap-1">
|
|
{key.scopes.map((scope) => (
|
|
<Badge key={scope} className="text-[10px]">
|
|
{scope}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-text-muted text-xs">
|
|
{formatRelativeTime(key.last_used_at)}
|
|
</td>
|
|
<td className="px-4 py-3 text-text-muted text-xs">
|
|
{formatDate(key.expires_at)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium border ${status.className}`}
|
|
>
|
|
{status.label}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{!key.revoked_at && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-error hover:text-error hover:bg-error/10"
|
|
onClick={() => {
|
|
setRevokeTarget(key)
|
|
setShowRevokeDialog(true)
|
|
}}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Revoke
|
|
</Button>
|
|
)}
|
|
{key.revoked_at && (
|
|
<span className="text-xs text-text-muted">
|
|
Revoked {formatDate(key.revoked_at)}
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create API Key Dialog */}
|
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create API Key</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new API key for programmatic access. Choose which scopes the key should have
|
|
access to.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
{/* Name */}
|
|
<div>
|
|
<label className="text-sm font-medium text-text-secondary block mb-1.5">
|
|
Key Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-md border border-border-default bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
|
placeholder="e.g. Monitoring Integration"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Scopes */}
|
|
<div>
|
|
<label className="text-sm font-medium text-text-secondary block mb-2">
|
|
Permissions
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{AVAILABLE_SCOPES.map((scope) => (
|
|
<label
|
|
key={scope.id}
|
|
className="flex items-center gap-2 rounded-md border border-border/50 px-3 py-2 hover:bg-elevated/30 cursor-pointer"
|
|
>
|
|
<Checkbox
|
|
checked={selectedScopes.includes(scope.id)}
|
|
onCheckedChange={() => toggleScope(scope.id)}
|
|
/>
|
|
<span className="text-sm">{scope.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expiry */}
|
|
<div>
|
|
<label className="text-sm font-medium text-text-secondary block mb-1.5">
|
|
Expiry Date{' '}
|
|
<span className="text-text-muted font-normal">(optional)</span>
|
|
</label>
|
|
<input
|
|
type="date"
|
|
className="w-full rounded-md border border-border-default bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
|
value={expiresAt}
|
|
onChange={(e) => setExpiresAt(e.target.value)}
|
|
min={new Date().toISOString().split('T')[0]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleCreate}
|
|
disabled={!name.trim() || selectedScopes.length === 0 || createMutation.isPending}
|
|
>
|
|
{createMutation.isPending ? 'Creating...' : 'Create Key'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* One-Time Key Display Dialog */}
|
|
<Dialog
|
|
open={showKeyDialog}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setShowKeyDialog(false)
|
|
setNewKey(null)
|
|
setCopied(false)
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>API Key Created</DialogTitle>
|
|
<DialogDescription>
|
|
Copy your API key now. You will not be able to see it again.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
<div className="rounded-lg border border-warning/30 bg-warning/5 p-3">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
|
|
<p className="text-xs text-warning">
|
|
This key will not be shown again. Store it securely.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<pre className="rounded-lg border border-border bg-elevated/50 p-4 font-mono text-sm break-all whitespace-pre-wrap">
|
|
{newKey?.key}
|
|
</pre>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="absolute top-2 right-2"
|
|
onClick={copyKey}
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<Check className="h-3.5 w-3.5 text-success" />
|
|
Copied
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
Copy
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => {
|
|
setShowKeyDialog(false)
|
|
setNewKey(null)
|
|
setCopied(false)
|
|
}}
|
|
>
|
|
Done
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Revoke Confirmation Dialog */}
|
|
<Dialog open={showRevokeDialog} onOpenChange={setShowRevokeDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Revoke API Key</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to revoke{' '}
|
|
<span className="font-medium text-text-primary">{revokeTarget?.name}</span>? This
|
|
action cannot be undone and will immediately prevent any requests using this key.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowRevokeDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => revokeTarget && revokeMutation.mutate(revokeTarget.id)}
|
|
disabled={revokeMutation.isPending}
|
|
>
|
|
{revokeMutation.isPending ? 'Revoking...' : 'Revoke Key'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|