Watch
1
0
Fork
You've already forked RedFlag
0

v0.2.9.0 — Windows desktop tray ships; unified Agents & Enrollment page

Desktop:
- Windows tray cross-compiled (cargo-xwin), installed with per-user
  autostart Run key; tray actions trigger_scan/approve_update wired to
  the local API
- Linux tray off the service child-spawn path — XDG autostart only, kills
  the double-launch
- signalDesktopRestart no longer no-ops on Windows (taskkill /F /IM)
- server serves /desktop/:platform/:arch

Web:
- TokenManagement + AgentManagement folded into one Agents & Enrollment
  settings page (useRegistrationTokens hook)

Agent/server:
- platform-aware self-update staging (constants/paths.go), no more
  hardcoded /var/lib/redflag
- consumer helper gated: sudo systemd-run on Linux, child proc elsewhere
- migration 060 drops the never-used token_seats table
- droppage of dead constructors and orphaned windows.go service methods
This commit is contained in:
Fimeg 2026-06-15 20:51:44 -04:00
commit 99d97a07ee
46 changed files with 1879 additions and 1977 deletions

View file

@ -18,9 +18,8 @@ const Docker = lazy(() => import('@/pages/Docker'));
const LiveOperations = lazy(() => import('@/pages/LiveOperations'));
const History = lazy(() => import('@/pages/History'));
const Settings = lazy(() => import('@/pages/Settings'));
const TokenManagement = lazy(() => import('@/pages/TokenManagement'));
const RateLimiting = lazy(() => import('@/pages/RateLimiting'));
const AgentManagement = lazy(() => import('@/pages/settings/AgentManagement'));
const AgentsEnrollment = lazy(() => import('@/pages/settings/AgentsEnrollment'));
const MaintenanceWindows = lazy(() => import('@/pages/settings/MaintenanceWindows'));
const UpstreamTracking = lazy(() => import('@/pages/settings/UpstreamTracking'));
const General = lazy(() => import('@/pages/settings/General'));
@ -158,9 +157,9 @@ const App: React.FC = () => {
<Route path="/history" element={<History />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/general" element={<General />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/tokens" element={<Navigate to="/settings/agents" replace />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="/settings/agents" element={<AgentsEnrollment />} />
<Route path="/settings/polling" element={<AgentPolling />} />
<Route path="/settings/security" element={<SecuritySettings />} />
<Route path="/settings/security/:tab" element={<SecuritySettings />} />

View file

@ -14,8 +14,10 @@ export const registrationTokenKeys = {
details: () => [...registrationTokenKeys.all, 'detail'] as const,
detail: (id: string) => [...registrationTokenKeys.details(), id] as const,
stats: () => [...registrationTokenKeys.all, 'stats'] as const,
boundAgents: (tokenId: string) => [...registrationTokenKeys.all, 'bound-agents', tokenId] as const,
};
// Hooks
export const useRegistrationTokens = (params?: {
page?: number;
@ -117,4 +119,39 @@ export const useCleanupRegistrationTokens = () => {
toast.error(error.response?.data?.message || 'Failed to cleanup registration tokens');
},
});
};
// useBoundAgents fetches agents that enrolled with a specific registration token.
// Enabled only when tokenId is non-empty so it doesn't fire on empty selection.
export const useBoundAgents = (tokenId: string) => {
return useQuery({
queryKey: registrationTokenKeys.boundAgents(tokenId),
queryFn: () => adminApi.tokens.getBoundAgents(tokenId),
enabled: !!tokenId,
staleTime: 1000 * 30,
});
};
// useRevokeAgent invalidates an agent's refresh tokens (explicit per-agent action,
// not a side effect of token revocation). Invalidates: bound-agents for the
// selected token, token list, and stats so seat counts refresh.
export const useRevokeAgent = (selectedTokenId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ agentId, reason }: { agentId: string; reason?: string }) =>
adminApi.agents.revoke(agentId, reason),
onSuccess: () => {
toast.success('Agent revoked');
if (selectedTokenId) {
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.boundAgents(selectedTokenId) });
}
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
},
onError: (error: any) => {
console.error('Failed to revoke agent:', error);
toast.error(error.response?.data?.error || 'Failed to revoke agent');
},
});
};

View file

@ -868,6 +868,23 @@ export const adminApi = {
const response = await api.post('/admin/registration-tokens/cleanup');
return response.data;
},
// Get agents bound to a registration token (by token UUID)
getBoundAgents: async (id: string): Promise<{ agents: import('@/types').BoundAgent[]; count: number }> => {
const response = await api.get(`/admin/registration-tokens/${id}/agents`);
return response.data;
},
},
// Admin agent operations (revoke, etc.)
agents: {
// Revoke an agent — invalidates refresh tokens so the agent can no longer
// check in. Enrolled agents are NOT affected by token revocation; this is
// the explicit per-agent path.
revoke: async (agentId: string, reason?: string): Promise<{ status: string; agent_id: string }> => {
const response = await api.post(`/admin/agents/${agentId}/revoke`, { reason });
return response.data;
},
},
// Signing key management — Ed25519 key rotation roster.

View file

@ -17,6 +17,7 @@ import {
Download,
CheckCircle,
AlertCircle,
Ban,
Power,
MonitorPlay,
Upload,
@ -37,6 +38,7 @@ import type { Column } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useColumnSort } from '@/hooks/useColumnSort';
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { useRevokeAgent } from '@/hooks/useRegistrationTokens';
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
import { agentApi } from '@/lib/api';
@ -249,6 +251,7 @@ const Agents: React.FC = () => {
const scanMultipleMutation = useScanMultipleAgents();
const unregisterAgentMutation = useUnregisterAgent();
const revokeAgentMutation = useRevokeAgent();
// Active commands for live status
const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands();
@ -356,6 +359,22 @@ const Agents: React.FC = () => {
}
};
// Revoke an agent — invalidates its refresh tokens so it can no longer check
// in. Distinct from Remove (which deletes the record). Enrolled agents are not
// affected by key revocation; this is the explicit per-agent path.
const handleRevokeAgent = async (agentId: string, hostname: string) => {
if (!(await confirm({
title: 'Revoke agent',
body: `Revoke agent "${hostname}"? Its refresh tokens are invalidated so it can no longer check in. The agent record and its history are kept; re-enrolling needs a new registration key.`,
confirmLabel: 'Revoke agent',
danger: true,
}))) return;
revokeAgentMutation.mutate(
{ agentId },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agents'] }) },
);
};
// Handle command cancellation
const handleCancelCommand = async (commandId: string) => {
try {
@ -534,6 +553,14 @@ const Agents: React.FC = () => {
>
<Upload className="h-4 w-4" />
</button>
<button
onClick={() => handleRevokeAgent(agent.id, agent.hostname)}
disabled={revokeAgentMutation.isPending}
className="text-gray-400 hover:text-orange-600"
title="Revoke agent (invalidate refresh tokens)"
>
<Ban className="h-4 w-4" />
</button>
<button
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
disabled={unregisterAgentMutation.isPending}
@ -552,7 +579,7 @@ const Agents: React.FC = () => {
</div>
),
},
] as Column<typeof sortedAgents[number]>[], [navigate, handleRemoveAgent, unregisterAgentMutation.isPending]);
] as Column<typeof sortedAgents[number]>[], [navigate, handleRemoveAgent, handleRevokeAgent, unregisterAgentMutation.isPending, revokeAgentMutation.isPending]);
// Agent detail view
if (id && selectedAgent) {

View file

@ -4,7 +4,6 @@ import {
Shield,
Lock,
SlidersHorizontal,
Settings as SettingsIcon,
ArrowRight,
CheckCircle,
Activity,
@ -43,15 +42,15 @@ const Settings: React.FC = () => {
</Link>
<Link
to="/settings/tokens"
to="/settings/agents"
className="card block hover:border-blue-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<Shield className="w-8 h-8 text-blue-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<h3 className="font-semibold text-gray-900">Registration Tokens</h3>
<p className="text-sm text-gray-600 mt-1">Manage agent registration tokens</p>
<h3 className="font-semibold text-gray-900">Agents &amp; Enrollment</h3>
<p className="text-sm text-gray-600 mt-1">Deploy agents, manage keys and revocation</p>
</Link>
<Link
@ -66,18 +65,6 @@ const Settings: React.FC = () => {
<p className="text-sm text-gray-600 mt-1">Configure API rate limits</p>
</Link>
<Link
to="/settings/agents"
className="card block hover:border-purple-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<SettingsIcon className="w-8 h-8 text-purple-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<h3 className="font-semibold text-gray-900">Agent Management</h3>
<p className="text-sm text-gray-600 mt-1">Deploy and configure agents</p>
</Link>
<Link
to="/settings/polling"
className="card block hover:border-amber-300 hover:shadow-sm transition-all"
@ -146,7 +133,7 @@ const Settings: React.FC = () => {
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Token Overview</h2>
<Link
to="/settings/tokens"
to="/settings/agents"
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Manage all

View file

@ -1,533 +0,0 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConfirm } from '@/components/primitives';
import {
Shield,
Plus,
RefreshCw,
Trash2,
Copy,
AlertTriangle,
CheckCircle,
Clock,
Users
} from 'lucide-react';
import { SearchInput, Pagination } from '@/components/primitives';
import {
useRegistrationTokens,
useCreateRegistrationToken,
useRevokeRegistrationToken,
useDeleteRegistrationToken,
useRegistrationTokenStats,
useCleanupRegistrationTokens
} from '../hooks/useRegistrationTokens';
import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { tokenStatusColor } from '@/components/primitives/statusColors';
const TokenManagement: React.FC = () => {
const navigate = useNavigate();
const confirm = useConfirm();
// Filters and search
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
const [showCreateForm, setShowCreateForm] = useState(false);
const [createdToken, setCreatedToken] = useState<{ token: string; install_command: string } | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 50;
// Token management
const { data: tokensData, isLoading, refetch } = useRegistrationTokens({
page: currentPage,
page_size: pageSize,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
label: searchTerm || undefined,
});
const { data: stats } = useRegistrationTokenStats();
const createToken = useCreateRegistrationToken();
const revokeToken = useRevokeRegistrationToken();
const deleteToken = useDeleteRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens();
// Reset page when filters change
React.useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter]);
// Form state
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '',
expires_in: '168h', // Default 7 days
max_seats: 1, // Default 1 seat
});
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: (data: any) => {
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreateForm(false);
setCreatedToken({ token: data.token, install_command: data.install_command });
refetch();
},
});
};
const handleRevokeToken = async (tokenId: string, tokenLabel: string) => {
if (!(await confirm({
title: 'Revoke Token',
body: `Revoke token "${tokenLabel}"? Agents using it will need to re-register.`,
confirmLabel: 'Revoke',
danger: true,
}))) return;
revokeToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleDeleteToken = async (tokenId: string, tokenLabel: string) => {
if (!(await confirm({
title: 'PERMANENTLY DELETE Token',
body: `PERMANENTLY DELETE token "${tokenLabel}"? This cannot be undone!`,
confirmLabel: 'Delete',
danger: true,
}))) return;
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleCleanup = async () => {
if (!(await confirm({
title: 'Cleanup Expired Tokens',
body: 'Clean up all expired tokens? This cannot be undone.',
confirmLabel: 'Clean Up',
danger: true,
}))) return;
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
// Show success feedback
};
const getStatusText = (token: RegistrationToken) => {
if (token.status === 'revoked') return 'Revoked';
if (token.status === 'expired') return 'Expired';
if (token.status === 'used') return 'Used';
if (token.status === 'active') return 'Active';
const s = String(token.status);
return s.charAt(0).toUpperCase() + s.slice(1);
};
const filteredTokens = tokensData?.tokens || [];
return (
<div className="max-w-7xl mx-auto px-6 py-8">
<button
onClick={() => navigate('/settings')}
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
>
Back to Settings
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Registration Tokens</h1>
<p className="mt-2 text-gray-600">Manage agent registration tokens and monitor their usage</p>
</div>
<div className="flex gap-3">
<button
onClick={handleCleanup}
disabled={cleanupTokens.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${cleanupTokens.isPending ? 'animate-spin' : ''}`} />
Cleanup Expired
</button>
<button
onClick={() => refetch()}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Create Token
</button>
</div>
</div>
</div>
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Tokens</p>
<p className="text-2xl font-bold text-gray-900">{stats.total_tokens}</p>
</div>
<Shield className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Active</p>
<p className="text-2xl font-bold text-green-600">{stats.active_tokens}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Used</p>
<p className="text-2xl font-bold text-blue-600">{stats.used_tokens}</p>
</div>
<Users className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Expired</p>
<p className="text-2xl font-bold text-gray-600">{stats.expired_tokens}</p>
</div>
<Clock className="w-8 h-8 text-gray-600" />
</div>
</div>
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Seats Used</p>
<p className="text-2xl font-bold text-purple-600">
{stats.total_seats_used}/{stats.total_seats_available || '∞'}
</p>
</div>
<Users className="w-8 h-8 text-purple-600" />
</div>
</div>
</div>
)}
{/* Create Token Form */}
{showCreateForm && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Create New Registration Token</h3>
<form onSubmit={handleCreateToken} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
<input
type="text"
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Servers, Development Team"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
<select
value={formData.expires_in}
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="24h">24 hours</option>
<option value="72h">3 days</option>
<option value="168h">7 days (1 week)</option>
</select>
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
<input
type="number"
min="1"
max="100"
value={formData.max_seats || 1}
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
placeholder="1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">Number of agents that can use this token</p>
</div>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={createToken.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createToken.isPending ? 'Creating...' : 'Create Token'}
</button>
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Created token reveal — shown once, dismissed by the operator */}
{createdToken && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 mb-8">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<h3 className="text-lg font-semibold text-green-900">Token Created</h3>
</div>
<button
onClick={() => setCreatedToken(null)}
className="text-green-600 hover:text-green-800 text-sm"
>
Dismiss
</button>
</div>
<p className="text-sm text-green-800 mb-3">
Copy this token now. It cannot be retrieved again only a hash is stored.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Token</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-sm bg-white border border-green-200 px-3 py-2 rounded select-all">
{createdToken.token}
</code>
<button
onClick={() => copyToClipboard(createdToken.token)}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy token"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Install command</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-white border border-green-200 px-3 py-2 rounded select-all overflow-x-auto">
{createdToken.install_command}
</code>
<button
onClick={() => copyToClipboard(createdToken.install_command)}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy install command"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
)}
{/* Filters and Search */}
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search by label..."
/>
</div>
<div className="flex gap-2">
<button
onClick={() => setStatusFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'all'
? 'bg-gray-100 text-gray-800 border border-gray-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('active')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'active'
? 'bg-green-100 text-green-800 border border-green-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Active
</button>
<button
onClick={() => setStatusFilter('used')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'used'
? 'bg-blue-100 text-blue-800 border border-blue-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Used
</button>
<button
onClick={() => setStatusFilter('expired')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'expired'
? 'bg-red-100 text-red-800 border border-red-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Expired
</button>
</div>
</div>
</div>
{/* Tokens List */}
<div className="bg-white rounded-lg border border-gray-200">
{isLoading ? (
<div className="p-12 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading tokens...</p>
</div>
) : filteredTokens.length === 0 ? (
<div className="p-12 text-center">
<Shield className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No tokens found</h3>
<p className="text-gray-600">
{searchTerm || statusFilter !== 'all'
? 'Try adjusting your search or filter criteria'
: 'Create your first token to begin registering agents'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Token
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Label
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Seats
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Expires
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Used
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredTokens.map((token) => (
<tr key={token.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-mono text-sm text-gray-500 bg-gray-100 px-3 py-2 rounded">
{token.id.slice(0, 8)}...
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{token.label}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`badge badge-lg ${tokenStatusColor(token.status)}`}>
{getStatusText(token)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{token.seats_used}/{token.max_seats} used
{token.seats_used >= token.max_seats && (
<span className="ml-2 text-xs text-red-600">(Full)</span>
)}
{token.seats_used < token.max_seats && token.status === 'active' && (
<span className="ml-2 text-xs text-green-600">({token.max_seats - token.seats_used} available)</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.expires_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{token.used_at ? formatDateTime(token.used_at) : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
{token.status === 'active' && (
<button
onClick={() => handleRevokeToken(token.id, token.label || 'this token')}
disabled={revokeToken.isPending}
className="text-orange-600 hover:text-orange-800 disabled:opacity-50"
title="Revoke token (soft delete)"
>
<AlertTriangle className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDeleteToken(token.id, token.label || 'this token')}
disabled={deleteToken.isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
title="Permanently delete token"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination — Pagination primitive */}
{tokensData && tokensData.total > pageSize && (
<div className="mt-6">
<Pagination
page={currentPage}
total={tokensData.total}
pageSize={pageSize}
onChange={setCurrentPage}
/>
</div>
)}
</div>
);
};
export default TokenManagement;

View file

@ -1,636 +0,0 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Terminal,
Check,
Copy,
Shield,
Server,
Monitor,
Laptop,
AlertTriangle,
Key,
Code
} from 'lucide-react';
import { useRegistrationTokens } from '@/hooks/useRegistrationTokens';
import { toast } from 'react-hot-toast';
import { useServerKeySecurity } from '@/hooks/useSecurity';
const AgentManagement: React.FC = () => {
const navigate = useNavigate();
const [selectedPlatform, setSelectedPlatform] = useState<string>('linux');
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const { data: tokens } = useRegistrationTokens({ is_active: true });
const { data: serverKeySecurity, isLoading: isLoadingServerKeySecurity, refetch: refetchServerKeySecurity } = useServerKeySecurity();
const [generatingKeys, setGeneratingKeys] = useState(false);
const availableTokens = React.useMemo(() => {
if (!tokens?.tokens || !Array.isArray(tokens.tokens)) return [];
return tokens.tokens.filter(
(t) => !t.revoked && t.status !== 'revoked' && t.status !== 'expired' && t.seats_used < t.max_seats,
);
}, [tokens]);
const selectedToken = React.useMemo(
() => availableTokens.find((t) => t.id === selectedTokenId) ?? null,
[availableTokens, selectedTokenId],
);
// Drop a stale selection if the token list changes underneath us, but never
// auto-pick — operator chooses explicitly.
React.useEffect(() => {
if (selectedTokenId && !availableTokens.some((t) => t.id === selectedTokenId)) {
setSelectedTokenId('');
}
}, [availableTokens, selectedTokenId]);
const platforms = [
{
id: 'linux',
name: 'Linux',
icon: Server,
description: 'Ubuntu, Debian, RHEL, CentOS, AlmaLinux, Rocky Linux (AMD64 + ARM64)',
downloadUrl: '/api/v1/downloads/linux-amd64',
installScript: '/api/v1/install/linux',
extensions: ['amd64', 'arm64'],
color: 'orange',
available: true,
},
{
id: 'windows',
name: 'Windows',
icon: Monitor,
description: 'Windows 10/11, Server 2019/2022 (AMD64 + ARM64)',
downloadUrl: '/api/v1/downloads/windows-amd64',
installScript: '/api/v1/install/windows',
extensions: ['amd64', 'arm64'],
color: 'blue',
available: true,
},
{
id: 'macos',
name: 'macOS',
icon: Laptop,
description: 'macOS 12+ (Apple Silicon + Intel)',
downloadUrl: '/api/v1/downloads/macos-arm64',
installScript: '/api/v1/install/macos',
extensions: ['arm64', 'amd64'],
color: 'gray',
available: false,
}
];
const getServerUrl = () => {
// Use the host:port the browser is actually on — that's always reachable
// by the agent machine. The nginx proxy (:31336) forwards /api/ to the
// server; direct API access (:31337) also works.
const { protocol, hostname, port } = window.location;
const portSuffix = port ? `:${port}` : '';
return `${protocol}//${hostname}${portSuffix}`;
};
const generateInstallCommand = (platform: typeof platforms[0]) => {
if (!selectedToken?.token) return '';
const serverUrl = getServerUrl();
const token = selectedToken.token;
// SEC-002: token travels in the X-Registration-Token header, never the
// URL — query strings land in shell history, process lists, and access logs.
switch (platform.id) {
case 'linux':
case 'macos':
return `curl -sfL -H "X-Registration-Token: ${token}" "${serverUrl}${platform.installScript}" | sudo bash`;
case 'windows':
// irm returns the response body as a string, so the pipe into iex runs
// the script directly — no temp file, no params (token is baked in at
// render time). Must be pasted into an elevated PowerShell.
return `irm "${serverUrl}${platform.installScript}" -Headers @{'X-Registration-Token'='${token}'} | iex`;
default:
return '';
}
};
const copyToClipboard = async (text: string, commandId: string) => {
try {
if (!text || text.trim() === '') {
toast.error('No command to copy');
return;
}
await navigator.clipboard.writeText(text);
setCopiedCommand(commandId);
toast.success('Command copied to clipboard!');
setTimeout(() => setCopiedCommand(null), 2000);
} catch (error) {
console.error('Copy failed:', error);
toast.error('Failed to copy command. Please copy manually.');
}
};
const formatTokenOptionLabel = (t: typeof availableTokens[number]) => {
const prefix = (t.token ?? t.id).slice(0, 12);
const seats = `${t.seats_used}/${t.max_seats} seats`;
const label = t.label ? ` · ${t.label}` : '';
let expiry = '';
if (t.expires_at) {
const days = Math.round((new Date(t.expires_at).getTime() - Date.now()) / 86400000);
expiry = ` · expires ${days}d`;
}
return `${prefix}${label} · ${seats}${expiry}`;
};
const selectedPlatformData = platforms.find(p => p.id === selectedPlatform);
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<button
onClick={() => navigate('/settings')}
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
>
Back to Settings
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agent Management</h1>
<p className="mt-2 text-gray-600">Deploy and configure RedFlag agents across your infrastructure</p>
</div>
<Link
to="/settings/tokens"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Shield className="w-4 h-4" />
Manage Tokens
</Link>
</div>
</div>
{/* Token Selector */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<div className="flex items-start gap-4">
<Shield className="w-6 h-6 text-blue-600 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-blue-900 mb-2">Choose a Registration Token</h3>
{availableTokens.length === 0 ? (
<>
<p className="text-blue-700 mb-4">
No registration tokens with available seats. Create one to enroll new agents existing agents are unaffected.
</p>
<Link
to="/settings/tokens"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Shield className="w-4 h-4" />
Generate Registration Token
</Link>
</>
) : (
<>
<p className="text-blue-700 mb-3">
Each token defines a group of agents. Pick the one this install belongs to the
selection is baked into the command below.
</p>
<div className="flex items-center gap-3 flex-wrap">
<select
value={selectedTokenId}
onChange={(e) => setSelectedTokenId(e.target.value)}
className="px-3 py-2 border border-blue-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[420px]"
>
<option value=""> Select a token ({availableTokens.length} available) </option>
{availableTokens.map((t) => (
<option key={t.id} value={t.id}>
{formatTokenOptionLabel(t)}
</option>
))}
</select>
<Link
to="/settings/tokens"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
Manage tokens
</Link>
</div>
{selectedToken && (
<div className="mt-3 text-xs text-blue-800 bg-blue-100 rounded px-3 py-2 inline-block">
ID: <code className="font-mono">{selectedToken.id.slice(0, 8)}</code>
{selectedToken.label && <> · {selectedToken.label}</>}
· {selectedToken.seats_used}/{selectedToken.max_seats} seats used
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Platform Selection */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6">1. Select Target Platform</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{platforms.map((platform) => {
const Icon = platform.icon;
return (
<button
key={platform.id}
onClick={() => setSelectedPlatform(platform.id)}
className={`p-6 border-2 rounded-lg transition-all ${
selectedPlatform === platform.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-4">
<Icon className={`w-8 h-8 ${
platform.id === 'linux' ? 'text-orange-600' :
platform.id === 'windows' ? 'text-blue-600' : 'text-gray-600'
}`} />
{selectedPlatform === platform.id && (
<Check className="w-5 h-5 text-blue-600" />
)}
</div>
<h3 className="font-semibold text-gray-900 mb-2">{platform.name}</h3>
<p className="text-sm text-gray-600">{platform.description}</p>
</button>
);
})}
</div>
</div>
{/* Installation Methods */}
{selectedPlatformData && (
<div className="space-y-8">
{/* One-Liner Installation */}
{!selectedPlatformData.available ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<Terminal className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-gray-700 mb-1">
{selectedPlatformData.name} agent coming soon
</h2>
<p className="text-sm text-gray-500">
The {selectedPlatformData.name} installer isn't available yet. Linux and Windows are ready today.
</p>
</div>
) : !selectedToken ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<Terminal className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-gray-700 mb-1">
Select a token above to generate the install command
</h2>
<p className="text-sm text-gray-500">
Pick a registration token and the one-liner for {selectedPlatformData.name} appears here.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">2. One-Liner Installation (Recommended)</h2>
<p className="text-gray-600 mt-1">
Automatically downloads and configures the agent for {selectedPlatformData.name}
</p>
</div>
<Terminal className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Installation Command{' '}
{selectedPlatformData.id === 'windows' && (
<span className="text-blue-600">(Run in PowerShell as Administrator)</span>
)}
</label>
<div className="relative">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{generateInstallCommand(selectedPlatformData)}</code>
</pre>
<button
onClick={() => copyToClipboard(generateInstallCommand(selectedPlatformData), 'one-liner')}
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600 transition-colors"
>
{copiedCommand === 'one-liner' ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="alert alert-warning">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<h4 className="font-medium text-yellow-900">Before Running</h4>
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
{selectedPlatformData.id === 'windows' ? (
<>
<li> Open <strong>PowerShell as Administrator</strong></li>
<li> The script will download and install the agent to <code className="code code-warning">%ProgramFiles%\RedFlag</code></li>
<li> A Windows service will be created and started automatically</li>
<li> Script is idempotent - safe to re-run for upgrades</li>
</>
) : (
<>
<li> Run this command as <strong>root</strong> (use sudo)</li>
<li> The script will create a dedicated <code className="code code-warning">redflag-agent</code> user</li>
<li> Limited sudo access will be configured via <code className="code code-warning">/etc/sudoers.d/redflag-agent</code></li>
<li> Systemd service will be installed and enabled automatically</li>
<li> Script is idempotent - safe to re-run for upgrades</li>
</>
)}
</ul>
</div>
</div>
</div>
</div>
</div>
)}
{/* Security Information */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">3. Security Information</h2>
<p className="text-gray-600 mt-1">
Understanding the security model and installation details
</p>
</div>
<Shield className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-6">
{/* Server Signing Key */}
<div>
<h4 className="font-medium text-gray-900 mb-3">🔑 Server Signing Key</h4>
{isLoadingServerKeySecurity ? (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-sm text-gray-500 mt-2">Loading key status...</p>
</div>
) : serverKeySecurity?.has_private_key ? (
<div className="space-y-3">
<div className="alert alert-success rounded-md p-3">
<p className="text-sm text-green-800">
Server has a private key for signing agent updates.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Public Key Fingerprint
</label>
<input
readOnly
value={serverKeySecurity.public_key_fingerprint}
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Algorithm
</label>
<input
readOnly
value={serverKeySecurity.algorithm?.toUpperCase()}
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md text-sm"
/>
</div>
</div>
) : (
<div className="space-y-3">
<div className="alert alert-warning rounded-md p-3">
<p className="text-sm text-yellow-800">
Your server is missing a private key. Generate one to enable secure agent updates.
</p>
</div>
<button
type="button"
onClick={async () => {
setGeneratingKeys(true);
try {
const response = await fetch('/api/setup/generate-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to generate keys');
}
toast.success('Signing keys generated successfully! Please restart your server.');
refetchServerKeySecurity(); // Refresh status
} catch (error: any) {
toast.error(error.message || 'Failed to generate keys');
} finally {
setGeneratingKeys(false);
}
}}
disabled={generatingKeys}
className="w-full py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{generatingKeys ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Generating Keys...
</>
) : (
<>
<Key className="h-4 w-4 mr-2" />
Generate Signing Keys
</>
)}
</button>
</div>
)}
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">🛡 Security Model</h4>
<p className="text-sm text-gray-600 mb-4">
The installation script follows the principle of least privilege by creating a dedicated system user with minimal permissions:
</p>
<div className="alert alert-info space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>System User:</strong> <code className="code code-info">redflag-agent</code> with no login shell</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Sudo Access:</strong> Limited to package management commands only</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Systemd Service:</strong> Runs with security hardening (ProtectSystem, ProtectHome)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Configuration:</strong> Secured in <code className="code code-info">/etc/redflag/config.json</code> with restricted permissions</span>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">📁 Installation Files</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 space-y-1">
{`Binary: /usr/local/bin/redflag-agent
Config: /etc/redflag/config.json
Service: /etc/systemd/system/redflag-agent.service
Sudoers: /etc/sudoers.d/redflag-agent
Home Dir: /var/lib/redflag-agent
Logs: journalctl -u redflag-agent`}
</pre>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3"> Sudoers Configuration</h4>
<p className="text-sm text-gray-600 mb-2">
The agent gets sudo access only for these specific commands:
</p>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-xs text-gray-700 overflow-x-auto">
{`# APT (Debian/Ubuntu)
/usr/bin/apt-get update
/usr/bin/apt-get install -y *
/usr/bin/apt-get upgrade -y *
/usr/bin/apt-get install --dry-run --yes *
# DNF (RHEL/Fedora/Rocky/Alma)
/usr/bin/dnf makecache
/usr/bin/dnf install -y *
/usr/bin/dnf upgrade -y *
/usr/bin/dnf install --assumeno --downloadonly *
# Docker
/usr/bin/docker pull *
/usr/bin/docker image inspect *
/usr/bin/docker manifest inspect *`}
</pre>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">🔄 Updates and Upgrades</h4>
<p className="text-sm text-gray-600">
The installation script is <strong>idempotent</strong> - it's safe to run multiple times.
RedFlag agents update themselves automatically when new versions are released.
If you need to manually reinstall or upgrade, simply run the same one-liner command.
</p>
</div>
</div>
</div>
{/* Advanced Configuration */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">4. Advanced Configuration</h2>
<p className="text-gray-600 mt-1">
Additional agent configuration options
</p>
</div>
<Code className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-6">
{/* Configuration Options */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Command Line Options</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700">
{`./redflag-agent [options]
Options:
--server <url> Server URL (default: http://localhost:8080)
--token <token> Registration token
--proxy-http <url> HTTP proxy URL
--proxy-https <url> HTTPS proxy URL
--log-level <level> Log level (debug, info, warn, error)
--organization <name> Organization name
--tags <tags> Comma-separated tags
--name <display> Display name for the agent
--insecure-tls Skip TLS certificate verification`}
</pre>
</div>
</div>
{/* Environment Variables */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Environment Variables</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700">
{`REDFLAG_SERVER_URL="https://your-server.com"
REDFLAG_REGISTRATION_TOKEN="your-token-here"
REDFLAG_HTTP_PROXY="http://proxy.company.com:8080"
REDFLAG_HTTPS_PROXY="https://proxy.company.com:8080"
REDFLAG_NO_PROXY="localhost,127.0.0.1"
REDFLAG_LOG_LEVEL="info"
REDFLAG_ORGANIZATION="IT Department"`}
</pre>
</div>
</div>
{/* Configuration File */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Configuration File</h4>
<p className="text-sm text-gray-600 mb-3">
After installation, the agent configuration is stored at <code>/etc/redflag/agent/config.json</code> (Linux) or
<code>%ProgramData%\RedFlag\config.json</code> (Windows):
</p>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 overflow-x-auto">
{`{
"server_url": "https://your-server.com",
"registration_token": "your-token-here",
"proxy": {
"enabled": true,
"http": "http://proxy.company.com:8080",
"https": "https://proxy.company.com:8080",
"no_proxy": "localhost,127.0.0.1"
},
"network": {
"timeout": "30s",
"retry_count": 3,
"retry_delay": "5s"
},
"tls": {
"insecure_skip_verify": false
},
"logging": {
"level": "info",
"max_size": 100,
"max_backups": 3
},
"tags": ["production", "webserver"],
"organization": "IT Department",
"display_name": "Web Server 01"
}`}
</pre>
</div>
</div>
</div>
</div>
{/* Next Steps */}
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-start gap-4">
<Check className="w-6 h-6 text-green-600 mt-1" />
<div>
<h3 className="font-semibold text-green-900 mb-2">Next Steps</h3>
<ol className="text-sm text-green-800 space-y-2">
<li>1. Deploy agents to your target machines using the methods above</li>
<li>2. Monitor agent registration in the <Link to="/agents" className="underline">Agents dashboard</Link></li>
<li>3. Configure update policies and scanning schedules</li>
<li>4. Review agent status and system information</li>
</ol>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AgentManagement;

View file

@ -0,0 +1,884 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Shield,
Plus,
RefreshCw,
Trash2,
Copy,
AlertTriangle,
CheckCircle,
Clock,
Users,
Key,
Terminal,
Server,
Monitor,
Laptop,
} from 'lucide-react';
import { SearchInput, useConfirm } from '@/components/primitives';
import { tokenStatusColor } from '@/components/primitives/statusColors';
import {
useRegistrationTokens,
useCreateRegistrationToken,
useRevokeRegistrationToken,
useDeleteRegistrationToken,
useRegistrationTokenStats,
useCleanupRegistrationTokens,
useBoundAgents,
useRevokeAgent,
} from '@/hooks/useRegistrationTokens';
import { useServerKeySecurity } from '@/hooks/useSecurity';
import type { RegistrationToken, CreateRegistrationTokenRequest, BoundAgent } from '@/types';
import { formatDateTime, formatRelativeTime, isOnline, cn } from '@/lib/utils';
import toast from 'react-hot-toast';
// Install one-liner generation — matches what /api/v1/install/{linux,windows,macos}
// expects. Token travels in the X-Registration-Token header, never the URL
// (SEC-002: query strings land in shell history, process lists, access logs).
const PLATFORMS = [
{
id: 'linux',
name: 'Linux',
icon: Server,
installScript: '/api/v1/install/linux',
available: true,
description: 'Ubuntu, Debian, RHEL, Fedora, Rocky, Alma (AMD64 + ARM64)',
},
{
id: 'windows',
name: 'Windows',
icon: Monitor,
installScript: '/api/v1/install/windows',
available: true,
description: 'Windows 10/11, Server 2019/2022 (AMD64 + ARM64)',
},
{
id: 'macos',
name: 'macOS',
icon: Laptop,
installScript: '/api/v1/install/macos',
available: false,
description: 'macOS 12+ (Apple Silicon + Intel) — coming soon',
},
] as const;
function getServerUrl(): string {
// The host:port the browser is on is always reachable by the agent machine.
const { protocol, hostname, port } = window.location;
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
}
function generateInstallCommand(platformId: string, token: string | undefined): string {
if (!token) return '';
const serverUrl = getServerUrl();
const script = PLATFORMS.find((p) => p.id === platformId)?.installScript;
if (!script) return '';
if (platformId === 'windows') {
return `irm "${serverUrl}${script}" -Headers @{'X-Registration-Token'='${token}'} | iex`;
}
return `curl -sfL -H "X-Registration-Token: ${token}" "${serverUrl}${script}" | sudo bash`;
}
type StatusFilter = 'all' | 'active' | 'used' | 'expired' | 'revoked';
const STATUS_DOT: Record<string, string> = {
active: 'bg-green-500',
used: 'bg-blue-500',
expired: 'bg-amber-500',
revoked: 'bg-gray-400',
};
const getStatusText = (token: RegistrationToken): string => {
const s = String(token.status);
return s.charAt(0).toUpperCase() + s.slice(1);
};
const tokenLabel = (t: RegistrationToken): string => t.label || `token ${t.id.slice(0, 8)}`;
const AgentsEnrollment: React.FC = () => {
const navigate = useNavigate();
const confirm = useConfirm();
// Key list — fetch all (incl. revoked/expired) so the operator sees the full
// roster; status chips filter client-side.
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const { data: tokensData, isLoading, refetch } = useRegistrationTokens({
page: 1,
page_size: 100,
label: searchTerm || undefined,
});
const { data: stats } = useRegistrationTokenStats();
const createToken = useCreateRegistrationToken();
const revokeToken = useRevokeRegistrationToken();
const deleteToken = useDeleteRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens();
// Selection + panels
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
const [showInstall, setShowInstall] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [revealToken, setRevealToken] = useState(false);
const [createdToken, setCreatedToken] = useState<{ token: string; install_command: string } | null>(null);
// Install panel state (separate from detail selection — install picks a live
// token to enroll a NEW agent with; detail inspects any token)
const [installPlatform, setInstallPlatform] = useState<string>('linux');
const [installTokenId, setInstallTokenId] = useState<string>('');
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
// Create-key form
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '',
expires_in: '168h',
max_seats: 1,
});
// Signing keys (preserved from the old Agent Management page)
const { data: serverKeySecurity, isLoading: isLoadingServerKeySecurity, refetch: refetchServerKeySecurity } =
useServerKeySecurity();
const [generatingKeys, setGeneratingKeys] = useState(false);
const allTokens = tokensData?.tokens || [];
// Client-side status filter
const filteredTokens = allTokens.filter((t) => {
if (statusFilter !== 'all' && t.status !== statusFilter) return false;
return true;
});
// Auto-select the first key for the detail pane once the list lands, but never
// override an explicit operator selection.
React.useEffect(() => {
if (!selectedTokenId && filteredTokens.length > 0) {
setSelectedTokenId(filteredTokens[0].id);
}
}, [filteredTokens, selectedTokenId]);
// Drop a stale selection if the token list changes underneath us.
React.useEffect(() => {
if (selectedTokenId && !allTokens.some((t) => t.id === selectedTokenId)) {
setSelectedTokenId(allTokens[0]?.id ?? '');
}
}, [allTokens, selectedTokenId]);
const selectedToken = allTokens.find((t) => t.id === selectedTokenId) || null;
// Active tokens with available seats — what the install panel offers.
const availableTokens = React.useMemo(
() =>
allTokens.filter(
(t) => !t.revoked && t.status === 'active' && t.seats_used < t.max_seats,
),
[allTokens],
);
const installToken = availableTokens.find((t) => t.id === installTokenId) ?? null;
React.useEffect(() => {
if (installTokenId && !availableTokens.some((t) => t.id === installTokenId)) {
setInstallTokenId('');
}
}, [availableTokens, installTokenId]);
const { data: boundAgentsData, isLoading: boundAgentsLoading } = useBoundAgents(selectedTokenId || '');
const revokeAgentMutation = useRevokeAgent(selectedTokenId || '');
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: (data: any) => {
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreate(false);
setCreatedToken({ token: data.token, install_command: data.install_command });
refetch();
},
});
};
const handleRevokeToken = async (tokenId: string, label: string) => {
// No cascade: revoking a key only stops new enrollments. Enrolled agents
// keep working — revoke them individually. (Test-enforced in the server.)
if (
!(await confirm({
title: 'Revoke registration key',
body: `Revoke key "${label}"? No new agents can enroll with it. Agents already enrolled with this key keep working — revoke them individually below if their access must end.`,
confirmLabel: 'Revoke key',
danger: true,
}))
)
return;
revokeToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleDeleteToken = async (tokenId: string, label: string) => {
if (
!(await confirm({
title: 'PERMANENTLY DELETE key',
body: `PERMANENTLY DELETE key "${label}"? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
}))
)
return;
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleRevokeAgent = async (agentId: string, hostname: string) => {
if (
!(await confirm({
title: 'Revoke agent',
body: `Revoke agent "${hostname}"? Its refresh tokens are invalidated so it can no longer check in. The agent record and its history are kept.`,
confirmLabel: 'Revoke agent',
danger: true,
}))
)
return;
revokeAgentMutation.mutate({ agentId });
};
const handleCleanup = async () => {
if (
!(await confirm({
title: 'Cleanup expired keys',
body: 'Clean up all expired keys? This cannot be undone.',
confirmLabel: 'Clean Up',
danger: true,
}))
)
return;
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
};
const copyToClipboard = async (text: string, id: string) => {
if (!text || !text.trim()) {
toast.error('Nothing to copy');
return;
}
try {
await navigator.clipboard.writeText(text);
setCopiedCommand(id);
toast.success('Copied to clipboard');
setTimeout(() => setCopiedCommand(null), 2000);
} catch {
toast.error('Failed to copy. Copy manually.');
}
};
const generateKeys = async () => {
setGeneratingKeys(true);
try {
const response = await fetch('/api/setup/generate-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) throw new Error('Failed to generate keys');
toast.success('Signing keys generated. Restart the server.');
refetchServerKeySecurity();
} catch (error: any) {
toast.error(error.message || 'Failed to generate keys');
} finally {
setGeneratingKeys(false);
}
};
const installCommand = generateInstallCommand(installPlatform, installToken?.token);
const boundAgents = boundAgentsData?.agents || [];
return (
<div className="max-w-7xl mx-auto px-6 py-8">
<button onClick={() => navigate('/settings')} className="text-sm text-gray-500 hover:text-gray-700 mb-4">
Back to Settings
</button>
{/* Header */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agents &amp; Enrollment</h1>
<p className="mt-2 text-gray-600">
Enroll agents and manage registration keys. Select a key to see who enrolled with it.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowInstall((v) => !v)}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Terminal className="w-4 h-4" />
{showInstall ? 'Hide install' : 'Install agent'}
</button>
<button
onClick={() => setShowCreate((v) => !v)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
New key
</button>
</div>
</div>
{/* Install panel */}
{showInstall && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Install a new agent</h2>
<button onClick={() => setShowInstall(false)} className="text-sm text-gray-500 hover:text-gray-700">
close
</button>
</div>
{availableTokens.length === 0 ? (
<div className="text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-4">
No registration keys with available seats.{' '}
<button onClick={() => setShowCreate(true)} className="text-blue-600 hover:text-blue-800 underline">
Create a key
</button>{' '}
first existing agents are unaffected.
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-gray-600">Enroll with key:</span>
<select
value={installTokenId}
onChange={(e) => setInstallTokenId(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[320px]"
>
<option value=""> Select a key ({availableTokens.length} available) </option>
{availableTokens.map((t) => (
<option key={t.id} value={t.id}>
{(t.token ?? t.id).slice(0, 12)}{t.label ? ` · ${t.label}` : ''} · {t.seats_used}/{t.max_seats} seats
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{PLATFORMS.map((p) => {
const Icon = p.icon;
const selected = installPlatform === p.id;
return (
<button
key={p.id}
onClick={() => setInstallPlatform(p.id)}
disabled={!p.available}
className={cn(
'p-4 border-2 rounded-lg text-left transition-all disabled:opacity-50 disabled:cursor-not-allowed',
selected ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300',
)}
>
<div className="flex items-center justify-between mb-2">
<Icon className={cn('w-6 h-6', p.id === 'linux' ? 'text-orange-600' : p.id === 'windows' ? 'text-blue-600' : 'text-gray-600')} />
{selected && <CheckCircle className="w-4 h-4 text-blue-600" />}
</div>
<div className="font-medium text-gray-900">{p.name}</div>
<div className="text-xs text-gray-500 mt-1">{p.description}</div>
</button>
);
})}
</div>
{installToken ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Installation command
{installPlatform === 'windows' && <span className="text-blue-600"> (Run in PowerShell as Administrator)</span>}
</label>
<div className="relative">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{installCommand}</code>
</pre>
<button
onClick={() => copyToClipboard(installCommand, 'install')}
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600"
title="Copy command"
>
{copiedCommand === 'install' ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
) : (
<div className="text-sm text-gray-500 bg-gray-50 border border-gray-200 rounded-lg p-4">
Select a key above to generate the one-liner.
</div>
)}
</div>
)}
</div>
)}
{/* Create-key form */}
{showCreate && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Create new registration key</h2>
<form onSubmit={handleCreateToken} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
<input
type="text"
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Servers"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
<select
value={formData.expires_in}
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="24h">24 hours</option>
<option value="72h">3 days</option>
<option value="168h">7 days (1 week)</option>
</select>
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
<input
type="number"
min="1"
max="100"
value={formData.max_seats || 1}
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
</div>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={createToken.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createToken.isPending ? 'Creating...' : 'Create key'}
</button>
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Created-key reveal — shown once, dismissed by the operator */}
{createdToken && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 mb-6">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<h3 className="text-lg font-semibold text-green-900">Key created</h3>
</div>
<button onClick={() => setCreatedToken(null)} className="text-green-600 hover:text-green-800 text-sm">
Dismiss
</button>
</div>
<p className="text-sm text-green-800 mb-3">
Copy this key now. It cannot be retrieved again only a hash is stored.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Token</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-sm bg-white border border-green-200 px-3 py-2 rounded select-all">
{createdToken.token}
</code>
<button
onClick={() => copyToClipboard(createdToken.token, 'created-token')}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy token"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Install command</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-white border border-green-200 px-3 py-2 rounded select-all overflow-x-auto">
{createdToken.install_command}
</code>
<button
onClick={() => copyToClipboard(createdToken.install_command, 'created-cmd')}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy install command"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
<StatCard label="Total keys" value={stats.total_tokens} icon={Shield} />
<StatCard label="Active" value={stats.active_tokens} valueClass="text-green-600" icon={CheckCircle} />
<StatCard label="Used" value={stats.used_tokens} valueClass="text-blue-600" icon={Users} />
<StatCard label="Expired" value={stats.expired_tokens} valueClass="text-gray-600" icon={Clock} />
<StatCard
label="Seats used"
value={`${stats.total_seats_used}/${stats.total_seats_available || '∞'}`}
valueClass="text-purple-600"
icon={Users}
/>
</div>
)}
{/* Master-detail */}
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6">
{/* LEFT: key list */}
<div className="bg-white rounded-lg border border-gray-200 flex flex-col max-h-[70vh]">
<div className="p-4 border-b border-gray-200 space-y-3">
<SearchInput value={searchTerm} onChange={setSearchTerm} placeholder="Search by label..." />
<div className="flex flex-wrap gap-1">
{(['all', 'active', 'used', 'expired', 'revoked'] as StatusFilter[]).map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={cn(
'px-2.5 py-1 rounded-md text-xs capitalize transition-colors border',
statusFilter === s
? 'bg-gray-100 text-gray-800 border-gray-300'
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50',
)}
>
{s}
</button>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">{filteredTokens.length} keys</span>
<button
onClick={handleCleanup}
disabled={cleanupTokens.isPending}
className="text-xs text-orange-600 hover:text-orange-800 inline-flex items-center gap-1 disabled:opacity-50"
title="Clean up expired keys"
>
<RefreshCw className={cn('w-3 h-3', cleanupTokens.isPending && 'animate-spin')} />
Cleanup expired
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<p className="mt-2 text-sm text-gray-500">Loading keys...</p>
</div>
) : filteredTokens.length === 0 ? (
<div className="p-8 text-center">
<Shield className="w-10 h-10 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">
{searchTerm || statusFilter !== 'all' ? 'No keys match.' : 'No keys yet. Create one to enroll agents.'}
</p>
</div>
) : (
filteredTokens.map((t) => {
const selected = t.id === selectedTokenId;
return (
<button
key={t.id}
onClick={() => {
setSelectedTokenId(t.id);
setRevealToken(false);
}}
className={cn(
'w-full text-left px-4 py-3 border-b border-gray-100 transition-colors',
selected ? 'bg-blue-50 border-l-2 border-l-blue-500' : 'hover:bg-gray-50 border-l-2 border-l-transparent',
)}
>
<div className={cn('font-medium truncate', t.status === 'revoked' ? 'text-gray-400 line-through' : 'text-gray-900')}>
{tokenLabel(t)}
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500">
<span className={cn('inline-block w-2 h-2 rounded-full', STATUS_DOT[t.status] || 'bg-gray-400')} />
<span>{getStatusText(t)}</span>
<span>·</span>
<span>
{t.seats_used}/{t.max_seats} seats
</span>
{t.status === 'active' && t.expires_at && (
<>
<span>·</span>
<span>
{Math.round((new Date(t.expires_at).getTime() - Date.now()) / 86400000)}d left
</span>
</>
)}
</div>
</button>
);
})
)}
</div>
</div>
{/* RIGHT: detail */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
{!selectedToken ? (
<div className="text-center py-16">
<Shield className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Select a key to see its details and enrolled agents.</p>
</div>
) : (
<div className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className={cn('text-xl font-semibold truncate', selectedToken.status === 'revoked' ? 'text-gray-400 line-through' : 'text-gray-900')}>
{tokenLabel(selectedToken)}
</h2>
<div className="mt-1">
<span className={cn('badge badge-lg', tokenStatusColor(selectedToken.status))}>
{getStatusText(selectedToken)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{selectedToken.status === 'active' && (
<button
onClick={() => handleRevokeToken(selectedToken.id, tokenLabel(selectedToken))}
disabled={revokeToken.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-orange-700 bg-orange-50 border border-orange-200 rounded-md hover:bg-orange-100 disabled:opacity-50"
title="Revoke key — stops new enrollments, enrolled agents keep working"
>
<AlertTriangle className="w-4 h-4" />
Revoke key
</button>
)}
<button
onClick={() => handleDeleteToken(selectedToken.id, tokenLabel(selectedToken))}
disabled={deleteToken.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-700 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 disabled:opacity-50"
title="Permanently delete key"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div>
<dt className="text-gray-500">Token</dt>
<dd className="mt-0.5 font-mono text-gray-900 break-all">
{selectedToken.token ? (
<span className="inline-flex items-center gap-2">
<code>{revealToken ? selectedToken.token : `rk_${'•'.repeat(20)}${selectedToken.token.slice(-4)}`}</code>
<button
onClick={() => setRevealToken((v) => !v)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{revealToken ? 'hide' : 'reveal'}
</button>
<button
onClick={() => copyToClipboard(selectedToken.token!, 'detail-token')}
className="text-gray-400 hover:text-gray-600"
title="Copy token"
>
{copiedCommand === 'detail-token' ? <CheckCircle className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</span>
) : (
<span className="text-gray-400 italic">
Not retrievable ({selectedToken.status} keys aren't decryptable)
</span>
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Seats</dt>
<dd className="mt-0.5 text-gray-900">
{selectedToken.seats_used} used / {selectedToken.max_seats}
{selectedToken.seats_used >= selectedToken.max_seats && (
<span className="ml-2 text-xs text-red-600">(Full)</span>
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Created</dt>
<dd className="mt-0.5 text-gray-900">
{formatDateTime(selectedToken.created_at)}
{selectedToken.created_by && <span className="text-gray-500"> by {selectedToken.created_by}</span>}
</dd>
</div>
<div>
<dt className="text-gray-500">Expires</dt>
<dd className="mt-0.5 text-gray-900">{formatDateTime(selectedToken.expires_at)}</dd>
</div>
<div>
<dt className="text-gray-500">Last used</dt>
<dd className="mt-0.5 text-gray-900">
{selectedToken.used_at ? formatDateTime(selectedToken.used_at) : 'Never'}
</dd>
</div>
</dl>
<div className="alert alert-warning">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800">
Revoking this key stops new enrollments. Agents already enrolled with it keep working revoke them individually below if access must end.
</p>
</div>
</div>
{/* Bound agents */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-900">
Agents enrolled with this key
{boundAgentsData && <span className="ml-1 text-gray-500">({boundAgentsData.count})</span>}
</h3>
<button onClick={() => refetch()} className="text-xs text-gray-400 hover:text-gray-600 inline-flex items-center gap-1">
<RefreshCw className="w-3 h-3" />
Refresh
</button>
</div>
{boundAgentsLoading ? (
<div className="py-6 text-center">
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
</div>
) : boundAgents.length === 0 ? (
<div className="py-8 text-center bg-gray-50 rounded-lg border border-gray-200">
<Users className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">No agents have enrolled with this key yet.</p>
</div>
) : (
<div className="overflow-x-auto border border-gray-200 rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Hostname</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">OS</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Last seen</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Enrolled</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{boundAgents.map((ba: BoundAgent) => {
const online = isOnline(ba.last_seen);
return (
<tr key={ba.agent_id} className="hover:bg-gray-50">
<td className="px-4 py-2.5">
<Link to={`/agents/${ba.agent_id}`} className="font-mono text-sm text-blue-600 hover:text-blue-800">
{ba.hostname}
</Link>
</td>
<td className="px-4 py-2.5 text-sm text-gray-600 capitalize">{ba.os_type}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1.5 text-sm text-gray-700">
<span className={cn('inline-block w-2 h-2 rounded-full', online ? 'bg-green-500' : 'bg-gray-400')} />
{online ? 'online' : 'offline'}
</span>
</td>
<td className="px-4 py-2.5 text-sm text-gray-600">{formatRelativeTime(ba.last_seen)}</td>
<td className="px-4 py-2.5 text-sm text-gray-600">{formatDateTime(ba.used_at)}</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => handleRevokeAgent(ba.agent_id, ba.hostname)}
disabled={revokeAgentMutation.isPending}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-red-700 bg-red-50 border border-red-200 rounded hover:bg-red-100 disabled:opacity-50"
title="Revoke agent — invalidates refresh tokens"
>
<AlertTriangle className="w-3 h-3" />
Revoke agent
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Signing keys — preserved capability from the old Agent Management page */}
<div className="border-t border-gray-200 pt-5">
<h3 className="text-sm font-semibold text-gray-900 mb-3">🔑 Server signing key</h3>
{isLoadingServerKeySecurity ? (
<div className="py-3 text-center">
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
</div>
) : serverKeySecurity?.has_private_key ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="alert alert-success rounded-md p-2.5 flex-1">
<p className="text-sm text-green-800 inline-flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Server has a private key for signing agent updates.
</p>
</div>
<code className="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded font-mono">
{serverKeySecurity.public_key_fingerprint}
</code>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="alert alert-warning rounded-md p-2.5 flex-1">
<p className="text-sm text-amber-800">
Server is missing a private key generate one to enable secure agent updates.
</p>
</div>
<button
onClick={generateKeys}
disabled={generatingKeys}
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md disabled:opacity-50"
>
{generatingKeys ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Generating...
</>
) : (
<>
<Key className="w-4 h-4" />
Generate signing keys
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
};
const StatCard: React.FC<{
label: string;
value: React.ReactNode;
valueClass?: string;
icon: React.ComponentType<{ className?: string }>;
}> = ({ label, value, valueClass, icon: Icon }) => (
<div className="card card-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{label}</p>
<p className={cn('text-2xl font-bold text-gray-900', valueClass)}>{value}</p>
</div>
<Icon className="w-7 h-7 text-gray-400" />
</div>
</div>
);
export default AgentsEnrollment;

View file

@ -477,6 +477,17 @@ export interface ApiError {
details?: any;
}
// BoundAgent — per-seat view returned by GET /admin/registration-tokens/:id/agents.
// Fields match the Go BoundAgent struct in queries/registration_tokens.go.
export interface BoundAgent {
agent_id: string;
hostname: string;
os_type: string;
status: string;
last_seen: string;
used_at: string;
}
// Registration Token types
export interface RegistrationToken {
id: string;