v0.1.17: UI fixes, Linux improvements, documentation overhaul

UI/UX:
- Fix heartbeat auto-refresh and rate-limiting page
- Add navigation breadcrumbs to settings pages
- New screenshots added

Linux Agent v0.1.17:
- Fix disk detection for multiple mount points
- Improve installer idempotency
- Prevent duplicate registrations

Documentation:
- README rewrite: 538→229 lines, homelab-focused
- Split docs: API.md, CONFIGURATION.md, DEVELOPMENT.md
- Add NOTICE for Apache 2.0 attribution
This commit is contained in:
Fimeg
2025-10-30 22:17:48 -04:00
parent 3940877fb2
commit a92ac0ed78
60 changed files with 4301 additions and 1258 deletions

View File

@@ -12,6 +12,7 @@ import History from '@/pages/History';
import Settings from '@/pages/Settings';
import TokenManagement from '@/pages/TokenManagement';
import RateLimiting from '@/pages/RateLimiting';
import AgentManagement from '@/pages/settings/AgentManagement';
import Login from '@/pages/Login';
import Setup from '@/pages/Setup';
import { WelcomeChecker } from '@/components/WelcomeChecker';
@@ -108,6 +109,7 @@ const App: React.FC = () => {
<Route path="/settings" element={<Settings />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>

View File

@@ -16,20 +16,9 @@ export const useHeartbeatStatus = (agentId: string, enabled: boolean = true): Us
queryKey: ['heartbeat', agentId],
queryFn: () => agentApi.getHeartbeatStatus(agentId),
enabled: enabled && !!agentId,
staleTime: 5000, // Consider data stale after 5 seconds
refetchInterval: (query) => {
// Smart polling: only poll when heartbeat is active
const data = query.state.data as HeartbeatStatus | undefined;
// If heartbeat is enabled and still active, poll every 5 seconds
if (data?.enabled && data?.active) {
return 5000; // 5 seconds
}
// If heartbeat is not active, don't poll
return false;
},
refetchOnWindowFocus: false, // Don't refresh when window gains focus
staleTime: 0, // Always consider data stale to force refetch
refetchInterval: 5000, // Poll every 5 seconds regardless of state
refetchOnWindowFocus: true, // Refresh when window gains focus
refetchOnMount: true, // Always refetch when component mounts
});
};

View File

@@ -85,6 +85,24 @@ export const useRevokeRegistrationToken = () => {
});
};
export const useDeleteRegistrationToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.tokens.deleteToken(id),
onSuccess: (_, tokenId) => {
toast.success('Registration token deleted successfully');
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.detail(tokenId) });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
},
onError: (error: any) => {
console.error('Failed to delete registration token:', error);
toast.error(error.response?.data?.message || 'Failed to delete registration token');
},
});
};
export const useCleanupRegistrationTokens = () => {
const queryClient = useQueryClient();

View File

@@ -24,8 +24,8 @@ import {
RateLimitSummary
} from '@/types';
// Base URL for API
export const API_BASE_URL = (import.meta.env?.VITE_API_URL as string) || '/api/v1';
// Base URL for API - use nginx proxy
export const API_BASE_URL = '/api/v1';
// Create axios instance
const api = axios.create({
@@ -237,8 +237,8 @@ export const logApi = {
};
export const authApi = {
// Simple login (using API key or token)
login: async (credentials: { token: string }): Promise<{ token: string }> => {
// Login with username and password
login: async (credentials: { username: string; password: string }): Promise<{ token: string; user: any }> => {
const response = await api.post('/auth/login', credentials);
return response.data;
},
@@ -255,9 +255,9 @@ export const authApi = {
},
};
// Setup API for server configuration (uses base API without auth)
// Setup API for server configuration (uses nginx proxy)
const setupApiInstance = axios.create({
baseURL: API_BASE_URL,
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
@@ -283,8 +283,8 @@ export const setupApi = {
serverHost: string;
serverPort: string;
maxSeats: string;
}): Promise<{ message: string; configPath?: string; restart?: boolean }> => {
const response = await setupApiInstance.post('/setup', config);
}): Promise<{ message: string; jwtSecret?: string; envContent?: string; manualRestartRequired?: boolean; manualRestartCommand?: string; configFilePath?: string }> => {
const response = await setupApiInstance.post('/setup/configure', config);
return response.data;
},
};
@@ -456,11 +456,16 @@ export const adminApi = {
return response.data;
},
// Revoke registration token
// Revoke registration token (soft delete)
revokeToken: async (id: string): Promise<void> => {
await api.delete(`/admin/registration-tokens/${id}`);
},
// Delete registration token (hard delete)
deleteToken: async (id: string): Promise<void> => {
await api.delete(`/admin/registration-tokens/delete/${id}`);
},
// Get registration token statistics
getStats: async (): Promise<RegistrationTokenStats> => {
const response = await api.get('/admin/registration-tokens/stats');
@@ -479,7 +484,17 @@ export const adminApi = {
// Get all rate limit configurations
getConfigs: async (): Promise<RateLimitConfig[]> => {
const response = await api.get('/admin/rate-limits');
return response.data;
// Backend returns { settings: {...}, updated_at: "..." }
// Transform settings object to array format expected by frontend
const settings = response.data.settings || {};
const configs: RateLimitConfig[] = Object.entries(settings).map(([endpoint, config]: [string, any]) => ({
...config,
endpoint,
updated_at: response.data.updated_at, // Preserve update timestamp
}));
return configs;
},
// Update rate limit configuration

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff, Shield } from 'lucide-react';
import { Eye, EyeOff, Shield, User } from 'lucide-react';
import { useAuthStore } from '@/lib/store';
import { authApi } from '@/lib/api';
import { handleApiError } from '@/lib/api';
@@ -9,24 +9,31 @@ import toast from 'react-hot-toast';
const Login: React.FC = () => {
const navigate = useNavigate();
const { setToken } = useAuthStore();
const [token, setTokenInput] = useState('');
const [showToken, setShowToken] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
toast.error('Please enter your authentication token');
if (!username.trim()) {
toast.error('Please enter your username');
return;
}
if (!password.trim()) {
toast.error('Please enter your password');
return;
}
setIsLoading(true);
try {
const response = await authApi.login({ token: token.trim() });
const response = await authApi.login({ username: username.trim(), password: password.trim() });
setToken(response.token);
localStorage.setItem('auth_token', response.token);
toast.success('Login successful');
localStorage.setItem('user', JSON.stringify(response.user));
toast.success(`Welcome back, ${response.user.username}!`);
navigate('/');
} catch (error) {
const apiError = handleApiError(error);
@@ -48,7 +55,7 @@ const Login: React.FC = () => {
Sign in to RedFlag
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter your authentication token to access the dashboard
Enter your username and password to access the dashboard
</p>
</div>
@@ -56,25 +63,45 @@ const Login: React.FC = () => {
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="token" className="block text-sm font-medium text-gray-700">
Authentication Token
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Enter your username"
required
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
id="token"
type={showToken ? 'text' : 'password'}
value={token}
onChange={(e) => setTokenInput(e.target.value)}
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Enter your JWT token"
placeholder="Enter your password"
required
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowToken(!showToken)}
onClick={() => setShowPassword(!showPassword)}
>
{showToken ? (
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
@@ -106,11 +133,11 @@ const Login: React.FC = () => {
<div className="flex items-start space-x-2">
<Shield className="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium">How to get your token:</p>
<p className="font-medium">Login credentials:</p>
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
<li>Check your RedFlag server configuration</li>
<li>Look for the JWT secret in your server settings</li>
<li>Generate a token using the server CLI</li>
<li>Use the admin username you configured during server setup</li>
<li>Enter the password you set during server configuration</li>
<li>If you forgot your credentials, check your server configuration</li>
<li>Contact your administrator if you need access</li>
</ul>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Shield,
RefreshCw,
@@ -25,7 +26,24 @@ import {
} from '../hooks/useRateLimits';
import { RateLimitConfig, RateLimitStats, RateLimitUsage } from '@/types';
// Helper function to format date/time strings
const formatDateTime = (dateString: string): string => {
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch (error) {
return dateString;
}
};
const RateLimiting: React.FC = () => {
const navigate = useNavigate();
const [editingMode, setEditingMode] = useState(false);
const [editingConfigs, setEditingConfigs] = useState<RateLimitConfig[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -46,14 +64,14 @@ const RateLimiting: React.FC = () => {
const cleanupLimits = useCleanupRateLimits();
React.useEffect(() => {
if (configs) {
if (configs && Array.isArray(configs)) {
setEditingConfigs([...configs]);
}
}, [configs]);
// Filtered configurations for display
const filteredConfigs = useMemo(() => {
if (!configs) return [];
if (!configs || !Array.isArray(configs)) return [];
return configs.filter((config) => {
const matchesSearch = searchTerm === '' ||
@@ -122,6 +140,13 @@ const RateLimiting: React.FC = () => {
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">
@@ -234,7 +259,9 @@ const RateLimiting: React.FC = () => {
</button>
<button
onClick={() => {
setEditingConfigs([...configs!]);
if (configs && Array.isArray(configs)) {
setEditingConfigs([...configs]);
}
setEditingMode(false);
}}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
@@ -489,7 +516,7 @@ const RateLimiting: React.FC = () => {
</div>
{/* Rate Limit Statistics */}
{stats && stats.length > 0 && (
{stats && Array.isArray(stats) && stats.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Rate Limit Statistics</h2>
@@ -526,7 +553,7 @@ const RateLimiting: React.FC = () => {
</div>
</div>
{stat.top_clients && stat.top_clients.length > 0 && (
{stat.top_clients && Array.isArray(stat.top_clients) && stat.top_clients.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-200">
<p className="text-xs text-gray-600 mb-2">Top Clients:</p>
<div className="space-y-1">
@@ -547,7 +574,7 @@ const RateLimiting: React.FC = () => {
)}
{/* Usage Monitoring */}
{usage && usage.length > 0 && (
{usage && Array.isArray(usage) && usage.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Usage Monitoring</h2>

View File

@@ -83,11 +83,11 @@ const Settings: React.FC = () => {
},
{
title: 'Agent Management',
description: 'Agent defaults and cleanup policies',
description: 'Deploy and configure agents across platforms',
icon: SettingsIcon,
href: '/settings/agents',
stats: null,
status: 'not-implemented'
status: 'implemented'
}
];
@@ -134,14 +134,17 @@ const Settings: React.FC = () => {
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
</div>
<div className="p-6 bg-gray-50 border border-gray-200 rounded-lg opacity-60">
<Link
to="/settings/agents"
className="block p-6 bg-white border border-gray-200 rounded-lg 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-gray-400" />
<ArrowRight className="w-5 h-5 text-gray-300" />
<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-500">Agent Management</h3>
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
</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>
</div>
{/* Overview Statistics */}
@@ -326,7 +329,6 @@ const Settings: React.FC = () => {
<h3 className="font-medium text-yellow-800 mb-3">🚧 Planned Features</h3>
<ul className="space-y-1 text-sm text-yellow-700">
<li> System configuration management</li>
<li> Agent management settings</li>
<li> Integration with third-party services</li>
<li> Persistent settings storage</li>
</ul>

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { XCircle } from 'lucide-react';
import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { setupApi } from '@/lib/api';
@@ -17,11 +17,15 @@ interface SetupFormData {
maxSeats: string;
}
const Setup: React.FC = () => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [jwtSecret, setJwtSecret] = useState<string | null>(null);
const [envContent, setEnvContent] = useState<string | null>(null);
const [showSuccess, setShowSuccess] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showDbPassword, setShowDbPassword] = useState(false);
const [formData, setFormData] = useState<SetupFormData>({
adminUser: 'admin',
@@ -108,20 +112,12 @@ const Setup: React.FC = () => {
try {
const result = await setupApi.configure(formData);
// Store JWT secret, env content and show success screen
setJwtSecret(result.jwtSecret || null);
setEnvContent(result.envContent || null);
setShowSuccess(true);
toast.success(result.message || 'Configuration saved successfully!');
if (result.restart) {
// Server is restarting, wait for it to come back online
setTimeout(() => {
navigate('/login');
}, 5000); // Give server time to restart
} else {
// No restart, redirect immediately
setTimeout(() => {
navigate('/login');
}, 2000);
}
} catch (error: any) {
console.error('Setup error:', error);
const errorMessage = error.response?.data?.error || error.message || 'Setup failed';
@@ -132,38 +128,177 @@ const Setup: React.FC = () => {
}
};
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="py-8">
<h2 className="text-2xl font-bold text-gray-900">Server Setup</h2>
<p className="mt-1 text-sm text-gray-600">
Configure your update management server
</p>
</div>
// Success screen with credentials display
if (showSuccess && jwtSecret) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-center mb-4">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 text-center mb-2">
Configuration Complete!
</h1>
<p className="text-gray-600 text-center">
Your RedFlag server is ready to use
</p>
</div>
<div className="bg-white shadow rounded-lg">
<form onSubmit={handleSubmit} className="divide-y divide-gray-200">
{/* Error Display */}
{error && (
<div className="px-6 py-4 bg-red-50">
<div className="flex">
<div className="flex-shrink-0">
<XCircle className="h-5 w-5 text-red-400" />
{/* Success Card */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
{/* Admin Credentials Section */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Administrator Credentials</h3>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<div className="grid grid-cols-1 gap-3">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Username</label>
<div className="mt-1 p-2 bg-white border border-gray-300 rounded text-sm font-mono">{formData.adminUser}</div>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">{error}</h3>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Password</label>
<div className="mt-1 p-2 bg-white border border-gray-300 rounded text-sm font-mono"></div>
</div>
</div>
</div>
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md">
<p className="text-sm text-green-800">
<strong>Important:</strong> Save these credentials securely. You'll use them to login to the RedFlag dashboard.
</p>
</div>
</div>
{/* Configuration Content Section */}
{envContent && (
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Configuration File Content</h3>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<textarea
readOnly
value={envContent}
className="w-full h-64 p-3 text-xs font-mono text-gray-800 bg-white border border-gray-300 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(envContent);
toast.success('Configuration content copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Copy Configuration Content
</button>
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
<strong>Important:</strong> Copy this configuration content and save it to <code className="bg-blue-100 px-1 rounded">./config/.env</code>, then run <code className="bg-blue-100 px-1 rounded">docker-compose down && docker-compose up -d</code> to apply the configuration.
</p>
</div>
</div>
)}
{/* Admin Account */}
<div className="px-6 py-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Admin Account</h3>
{/* JWT Secret Section (Server Configuration) */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Server JWT Secret</h3>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<code className="text-sm text-gray-800 break-all font-mono">{jwtSecret}</code>
</div>
<div className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-md">
<p className="text-sm text-gray-700">
<strong>For your information:</strong> This JWT secret is used internally by the server for session management and agent authentication. It's automatically included in the configuration file above.
</p>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(jwtSecret);
toast.success('JWT secret copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
Copy JWT Secret (Optional)
</button>
</div>
{/* Next Steps */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Next Steps</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>Copy the configuration content using the green button above</li>
<li>Save it to <code className="bg-gray-100 px-1 rounded">./config/.env</code></li>
<li>Run <code className="bg-gray-100 px-1 rounded">docker-compose down && docker-compose up -d</code></li>
<li>Login to the dashboard with your admin username and password</li>
</ol>
</div>
<div className="mt-6 pt-6 border-t border-gray-200 space-y-3">
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> You must restart the containers to apply the configuration before logging in.
</p>
</div>
<button
onClick={() => {
toast.success('Please run: docker-compose down && docker-compose up -d');
}}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500"
>
Show Restart Command
</button>
<button
onClick={() => {
setTimeout(() => navigate('/login'), 500);
}}
className="w-full flex justify-center 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"
>
Continue to Login (After Restart)
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-center mb-4">
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center">
<span className="text-2xl">🚩</span>
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 text-center mb-2">
Configure RedFlag Server
</h1>
<p className="text-gray-600 text-center">
Set up your update management server configuration
</p>
</div>
{/* Setup Form */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<form onSubmit={handleSubmit} className="space-y-8">
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-800">{error}</div>
</div>
)}
{/* Administrator Account */}
<div>
<div className="flex items-center mb-4">
<User className="h-5 w-5 text-indigo-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Administrator Account</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700">
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700 mb-1">
Admin Username
</label>
<input
@@ -172,33 +307,51 @@ const Setup: React.FC = () => {
name="adminUser"
value={formData.adminUser}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="admin"
required
/>
</div>
<div>
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700">
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700 mb-1">
Admin Password
</label>
<input
type="password"
id="adminPassword"
name="adminPassword"
value={formData.adminPassword}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
required
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
id="adminPassword"
name="adminPassword"
value={formData.adminPassword}
onChange={handleInputChange}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter secure password"
required
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
</div>
</div>
</div>
{/* Database Configuration */}
<div className="px-6 py-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Database Configuration</h3>
<div>
<div className="flex items-center mb-4">
<Database className="h-5 w-5 text-indigo-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Database Configuration</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700 mb-1">
Database Host
</label>
<input
@@ -207,12 +360,13 @@ const Setup: React.FC = () => {
name="dbHost"
value={formData.dbHost}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="postgres"
required
/>
</div>
<div>
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700 mb-1">
Database Port
</label>
<input
@@ -221,12 +375,12 @@ const Setup: React.FC = () => {
name="dbPort"
value={formData.dbPort}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
required
/>
</div>
<div>
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700 mb-1">
Database Name
</label>
<input
@@ -235,12 +389,13 @@ const Setup: React.FC = () => {
name="dbName"
value={formData.dbName}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="redflag"
required
/>
</div>
<div>
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700 mb-1">
Database User
</label>
<input
@@ -249,33 +404,51 @@ const Setup: React.FC = () => {
name="dbUser"
value={formData.dbUser}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="redflag"
required
/>
</div>
<div>
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700 mb-1">
Database Password
</label>
<input
type="password"
id="dbPassword"
name="dbPassword"
value={formData.dbPassword}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
required
/>
<div className="relative">
<input
type={showDbPassword ? 'text' : 'password'}
id="dbPassword"
name="dbPassword"
value={formData.dbPassword}
onChange={handleInputChange}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter database password"
required
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowDbPassword(!showDbPassword)}
>
{showDbPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
</div>
</div>
</div>
{/* Server Configuration */}
<div className="px-6 py-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Server Configuration</h3>
<div>
<div className="flex items-center mb-4">
<Settings className="h-5 w-5 text-indigo-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Server Configuration</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700">
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700 mb-1">
Server Host
</label>
<input
@@ -284,12 +457,13 @@ const Setup: React.FC = () => {
name="serverHost"
value={formData.serverHost}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="0.0.0.0"
required
/>
</div>
<div>
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700">
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700 mb-1">
Server Port
</label>
<input
@@ -298,12 +472,13 @@ const Setup: React.FC = () => {
name="serverPort"
value={formData.serverPort}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="8080"
required
/>
</div>
<div>
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700">
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700 mb-1">
Maximum Agent Seats
</label>
<input
@@ -312,9 +487,10 @@ const Setup: React.FC = () => {
name="maxSeats"
value={formData.maxSeats}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
min="1"
max="1000"
placeholder="50"
required
/>
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
@@ -323,19 +499,22 @@ const Setup: React.FC = () => {
</div>
{/* Submit Button */}
<div className="px-6 py-4 bg-gray-50">
<div className="pt-6 border-t border-gray-200">
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full flex justify-center 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"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Configuring...
Configuring RedFlag Server...
</div>
) : (
'Configure Server'
<div className="flex items-center">
<Shield className="w-4 h-4 mr-2" />
Configure RedFlag Server
</div>
)}
</button>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Shield,
Plus,
@@ -19,6 +20,7 @@ import {
useRegistrationTokens,
useCreateRegistrationToken,
useRevokeRegistrationToken,
useDeleteRegistrationToken,
useRegistrationTokenStats,
useCleanupRegistrationTokens
} from '../hooks/useRegistrationTokens';
@@ -26,6 +28,8 @@ import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types';
import { formatDateTime } from '@/lib/utils';
const TokenManagement: React.FC = () => {
const navigate = useNavigate();
// Filters and search
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
@@ -47,6 +51,7 @@ const TokenManagement: React.FC = () => {
const { data: stats, isLoading: isLoadingStats } = useRegistrationTokenStats();
const createToken = useCreateRegistrationToken();
const revokeToken = useRevokeRegistrationToken();
const deleteToken = useDeleteRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens();
// Reset page when filters change
@@ -57,15 +62,15 @@ const TokenManagement: React.FC = () => {
// Form state
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '',
max_seats: 10,
expires_at: '',
expires_in: '168h', // Default 7 days
max_seats: 1, // Default 1 seat
});
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: () => {
setFormData({ label: '', max_seats: 10, expires_at: '' });
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreateForm(false);
refetch();
},
@@ -78,44 +83,65 @@ const TokenManagement: React.FC = () => {
}
};
const handleDeleteToken = (tokenId: string, tokenLabel: string) => {
if (confirm(`⚠️ PERMANENTLY DELETE token "${tokenLabel}"? This cannot be undone!`)) {
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
}
};
const handleCleanup = () => {
if (confirm('Clean up all expired tokens? This cannot be undone.')) {
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
}
};
const getServerUrl = () => {
return `${window.location.protocol}//${window.location.host}`;
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
// Show success feedback
};
const copyInstallCommand = async (token: string) => {
const command = `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
const serverUrl = getServerUrl();
const command = `curl -sfL ${serverUrl}/api/v1/install/linux | bash -s -- ${token}`;
await navigator.clipboard.writeText(command);
};
const generateInstallCommand = (token: string) => {
return `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
const serverUrl = getServerUrl();
return `curl -sfL ${serverUrl}/api/v1/install/linux | bash -s -- ${token}`;
};
const getStatusColor = (token: RegistrationToken) => {
if (!token.is_active) return 'text-gray-500';
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'text-red-600';
if (token.max_seats && token.current_seats >= token.max_seats) return 'text-yellow-600';
return 'text-green-600';
if (token.status === 'revoked') return 'text-gray-500';
if (token.status === 'expired') return 'text-red-600';
if (token.status === 'used') return 'text-yellow-600';
if (token.status === 'active') return 'text-green-600';
return 'text-gray-500';
};
const getStatusText = (token: RegistrationToken) => {
if (!token.is_active) return 'Revoked';
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'Expired';
if (token.max_seats && token.current_seats >= token.max_seats) return 'Full';
return 'Active';
if (token.status === 'revoked') return 'Revoked';
if (token.status === 'expired') return 'Expired';
if (token.status === 'used') return 'Used';
if (token.status === 'active') return 'Active';
return token.status.charAt(0).toUpperCase() + token.status.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">
@@ -220,32 +246,37 @@ const TokenManagement: React.FC = () => {
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Team"
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">Max Seats</label>
<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"
value={formData.max_seats}
onChange={(e) => setFormData({ ...formData, max_seats: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="Leave empty for unlimited"
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">Expiration Date</label>
<input
type="datetime-local"
value={formData.expires_at}
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
min={new Date().toISOString().slice(0, 16)}
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>
@@ -403,27 +434,24 @@ const TokenManagement: React.FC = () => {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{token.current_seats}
{token.max_seats && ` / ${token.max_seats}`}
<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>
{token.max_seats && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${Math.min((token.current_seats / token.max_seats) * 100, 100)}%` }}
></div>
</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) || 'Never'}
{formatDateTime(token.expires_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.last_used_at) || 'Never'}
{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">
@@ -441,16 +469,24 @@ const TokenManagement: React.FC = () => {
>
<Download className="w-4 h-4" />
</button>
{token.is_active && (
{token.status === 'active' && (
<button
onClick={() => handleRevokeToken(token.id, token.label)}
onClick={() => handleRevokeToken(token.id, token.label || 'this token')}
disabled={revokeToken.isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
title="Revoke token"
className="text-orange-600 hover:text-orange-800 disabled:opacity-50"
title="Revoke token (soft delete)"
>
<Trash2 className="w-4 h-4" />
<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>

View File

@@ -0,0 +1,491 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Download,
Terminal,
Copy,
Check,
Shield,
Server,
Monitor,
AlertTriangle,
ExternalLink,
RefreshCw,
Code,
FileText,
Package
} from 'lucide-react';
import { useRegistrationTokens } from '@/hooks/useRegistrationTokens';
import { toast } from 'react-hot-toast';
const AgentManagement: React.FC = () => {
const navigate = useNavigate();
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const [selectedPlatform, setSelectedPlatform] = useState<string>('linux');
const { data: tokens, isLoading: tokensLoading } = useRegistrationTokens({ is_active: true });
const platforms = [
{
id: 'linux',
name: 'Linux',
icon: Server,
description: 'For Ubuntu, Debian, RHEL, CentOS, AlmaLinux, Rocky Linux',
downloadUrl: '/api/v1/downloads/linux-amd64',
installScript: '/api/v1/install/linux',
extensions: ['amd64'],
color: 'orange'
},
{
id: 'windows',
name: 'Windows',
icon: Monitor,
description: 'For Windows 10/11, Windows Server 2019/2022',
downloadUrl: '/api/v1/downloads/windows-amd64',
installScript: '/api/v1/install/windows',
extensions: ['amd64'],
color: 'blue'
}
];
const getServerUrl = () => {
return `${window.location.protocol}//${window.location.host}`;
};
const getActiveToken = () => {
// Defensive null checking to prevent crashes
if (!tokens || !tokens.tokens || !Array.isArray(tokens.tokens) || tokens.tokens.length === 0) {
return 'YOUR_REGISTRATION_TOKEN';
}
return tokens.tokens[0]?.token || 'YOUR_REGISTRATION_TOKEN';
};
const generateInstallCommand = (platform: typeof platforms[0]) => {
const serverUrl = getServerUrl();
const token = getActiveToken();
if (platform.id === 'linux') {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `curl -sfL ${serverUrl}${platform.installScript} | sudo bash -s -- ${token}`;
} else {
return `curl -sfL ${serverUrl}${platform.installScript} | sudo bash`;
}
} else if (platform.id === 'windows') {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `iwr ${serverUrl}${platform.installScript} -OutFile install.bat; .\\install.bat ${token}`;
} else {
return `iwr ${serverUrl}${platform.installScript} -OutFile install.bat; .\\install.bat`;
}
}
return '';
};
const generateManualCommand = (platform: typeof platforms[0]) => {
const serverUrl = getServerUrl();
const token = getActiveToken();
if (platform.id === 'windows') {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `# Download and run as Administrator with token\niwr ${serverUrl}${platform.installScript} -OutFile install.bat\n.\\install.bat ${token}`;
} else {
return `# Download and run as Administrator\niwr ${serverUrl}${platform.installScript} -OutFile install.bat\n.\\install.bat`;
}
} else {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `# Download and run as root with token\ncurl -sfL ${serverUrl}${platform.installScript} | sudo bash -s -- ${token}`;
} else {
return `# Download and run as root\ncurl -sfL ${serverUrl}${platform.installScript} | sudo bash`;
}
}
};
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 handleDownload = (platform: typeof platforms[0]) => {
const link = document.createElement('a');
link.href = `${getServerUrl()}${platform.downloadUrl}`;
link.download = `redflag-agent-${platform.id}-amd64${platform.id === 'windows' ? '.exe' : ''}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(`Download started for ${platform.name} agent`);
};
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 Status */}
<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">Registration Token Required</h3>
<p className="text-blue-700 mb-4">
Agents need a registration token to enroll with the server. You have {tokens?.tokens?.length || 0} active token(s).
</p>
{!tokens?.tokens || tokens.tokens.length === 0 ? (
<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>
) : (
<div className="flex items-center gap-4">
<div>
<p className="text-sm text-blue-600 font-medium">Active Token:</p>
<code className="text-xs bg-blue-100 px-2 py-1 rounded">{tokens?.tokens?.[0]?.token || 'N/A'}</code>
</div>
<Link
to="/settings/tokens"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
View all tokens
</Link>
</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 */}
<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="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<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="bg-yellow-100 px-1 rounded">%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="bg-yellow-100 px-1 rounded">redflag-agent</code> user</li>
<li> Limited sudo access will be configured via <code className="bg-yellow-100 px-1 rounded">/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">
<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="bg-blue-50 border border-blue-200 rounded-lg p-4 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="bg-blue-100 px-1 rounded">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="bg-blue-100 px-1 rounded">/etc/aggregator/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/aggregator/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/aggregator/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

@@ -292,20 +292,24 @@ export interface ApiError {
export interface RegistrationToken {
id: string;
token: string;
label: string;
expires_at: string | null;
max_seats: number | null;
current_seats: number;
is_active: boolean;
label: string | null;
expires_at: string;
created_at: string;
updated_at: string;
last_used_at: string | null;
used_at: string | null;
used_by_agent_id: string | null;
revoked: boolean;
revoked_at: string | null;
revoked_reason: string | null;
status: 'active' | 'used' | 'expired' | 'revoked';
created_by: string;
metadata: Record<string, any>;
max_seats: number;
seats_used: number;
}
export interface CreateRegistrationTokenRequest {
label: string;
expires_at?: string;
expires_in?: string;
max_seats?: number;
metadata?: Record<string, any>;
}