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:
parent
e8cd4de44f
commit
99d97a07ee
46 changed files with 1879 additions and 1977 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 & 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 →
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
884
web/src/pages/settings/AgentsEnrollment.tsx
Normal file
884
web/src/pages/settings/AgentsEnrollment.tsx
Normal 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 & 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue