This commit is contained in:
root
2025-11-25 09:56:15 +03:00
commit 68c8f0e80d
23717 changed files with 3200521 additions and 0 deletions

15
mc_test/src/env.d.ts vendored Executable file
View File

@ -0,0 +1,15 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly DEV: boolean
readonly PROD: boolean
readonly MODE: string
readonly VITE_GUACAMOLE_BASE_URL: string
readonly VITE_GUACAMOLE_USERNAME: string
readonly VITE_GUACAMOLE_PASSWORD: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

304
mc_test/src/main/electron.ts Executable file
View File

@ -0,0 +1,304 @@
const { app, BrowserWindow, ipcMain, safeStorage } = require('electron')
const path = require('path')
import { log } from './logger'
import { LogLevel, MachineAction, MachineResponse } from '../types/electron'
import { Machine } from '../renderer/types'
// Temporary storage for demo
const machinesStore: Record<string, Machine> = {
'test-vm-01': {
id: 'test-vm-01',
name: 'Test VM 1',
status: 'running',
os: 'Windows 10',
ip: '192.168.1.100',
hypervisor: 'VMware',
specs: {
cpu: '4 cores',
ram: '8GB',
disk: '256GB'
},
testLinks: [
{ name: 'RDP', url: 'rdp://192.168.1.100' },
{ name: 'Web Console', url: 'https://192.168.1.100:8080' }
],
logs: [
{ timestamp: new Date().toISOString(), message: 'System started', level: 'info' }
]
}
};
// Logging event interface
interface LogEvent {
level: LogLevel;
tag: string;
message: string;
context?: Record<string, unknown>;
}
function createWindow() {
log.info('startup', 'Creating main window...')
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, '..', 'preload.js'),
},
})
// Additional CSP configuration via session - DISABLED for debugging
/*
mainWindow.webContents.session.webRequest.onHeadersReceived((details: any, callback: any) => {
const isDev = process.env.NODE_ENV === 'development';
const cspValue = isDev
? "default-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; style-src 'self' 'unsafe-inline' http://localhost:*; connect-src 'self' http://localhost:* https://localhost:* ws://localhost:* wss://localhost:*; frame-src 'self' http://localhost:* https://localhost:*; img-src 'self' data: blob: http://localhost:*; font-src 'self' data: http://localhost:*; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';"
: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://mc.exbytestudios.com; frame-src 'self' https://mc.exbytestudios.com; img-src 'self' data: blob:; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';";
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [cspValue]
}
});
});
*/
// In development, load from Vite dev server
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
log.debug('startup', 'VITE_DEV_SERVER_URL:', { url: VITE_DEV_SERVER_URL })
if (VITE_DEV_SERVER_URL) {
log.info('startup', 'Loading from dev server', { url: VITE_DEV_SERVER_URL })
mainWindow.loadURL(VITE_DEV_SERVER_URL);
mainWindow.webContents.openDevTools();
} else {
const indexPath = path.join(__dirname, '..', 'renderer', 'index.html')
log.info('startup', 'Loading from file', { path: indexPath })
console.log('Attempting to load file:', indexPath);
mainWindow.loadFile(indexPath);
}
// Add page load debugging
mainWindow.webContents.on('did-finish-load', () => {
log.info('startup', 'Page finished loading');
console.log('Page loaded');
});
mainWindow.webContents.on('did-fail-load', (_event: any, errorCode: any, errorDescription: any) => {
log.error('startup', 'Page failed to load', { errorCode, errorDescription });
console.error('Page load error:', errorCode, errorDescription);
});
// Force open DevTools for debugging
mainWindow.webContents.openDevTools();
// Handle window close
mainWindow.on('closed', () => {
log.info('window', 'Main window closed, quitting application')
app.quit();
})
}
// This method will be called when Electron has finished initialization
app.whenReady().then(() => {
log.info('startup', 'Application ready, initializing...')
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
log.info('window', 'No windows available, creating new window')
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
log.info('window', 'All windows closed, quitting application')
app.quit()
}
})
// Handle logging events from renderer process
ipcMain.on('log-event', (_event: any, { level, tag, message, context }: LogEvent) => {
log[level](tag, message, context)
})
// IPC event handlers
// Connect to machine
ipcMain.handle('connect-to-machine', async (_event: any, machine: Machine): Promise<MachineResponse> => {
log.info('connect-machine', 'Connection request received', {
machineId: machine.id,
machineName: machine.name
});
try {
// TODO: Real connection logic will be added later
return {
success: true,
message: 'Connection successful',
details: { machineId: machine.id }
};
} catch (error) {
log.error('connect-machine', 'Connection failed', {
machineId: machine.id,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
});
// Control machine state
ipcMain.handle('control-machine', async (_event: any, { machineId, action }: { machineId: string; action: MachineAction }): Promise<MachineResponse> => {
log.info('control-machine', `Machine ${action} requested`, { machineId, action });
try {
const machine = machinesStore[machineId];
if (!machine) {
throw new Error(`Machine ${machineId} not found`);
}
// Simulate state change
switch (action) {
case 'start':
machine.status = 'running';
machine.logs.unshift({
timestamp: new Date().toISOString(),
message: 'Machine started',
level: 'info'
});
break;
case 'stop':
machine.status = 'stopped';
machine.logs.unshift({
timestamp: new Date().toISOString(),
message: 'Machine stopped',
level: 'info'
});
break;
case 'restart':
machine.status = 'stopped';
machine.logs.unshift({
timestamp: new Date().toISOString(),
message: 'Machine restarting',
level: 'info'
});
setTimeout(() => {
machine.status = 'running';
machine.logs.unshift({
timestamp: new Date().toISOString(),
message: 'Machine restarted successfully',
level: 'info'
});
log.info('control-machine', 'Machine restarted', { machineId });
}, 2000);
break;
}
return {
success: true,
message: `Machine ${action} successful`,
details: { machineId, newStatus: machine.status }
};
} catch (error) {
log.error('control-machine', `Machine ${action} failed`, {
machineId,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
});
// Get machine status
ipcMain.handle('get-machine-status', async (_event: any, machineId: string): Promise<MachineResponse> => {
log.debug('get-machine-status', 'Status requested', { machineId });
const machine = machinesStore[machineId];
if (!machine) {
throw new Error(`Machine ${machineId} not found`);
}
return {
success: true,
message: 'Status retrieved',
status: machine.status
};
});
// Get machine list
ipcMain.handle('get-machine-list', async (): Promise<MachineResponse> => {
log.debug('get-machine-list', 'Machine list requested');
return {
success: true,
message: 'Machine list retrieved',
machines: Object.values(machinesStore)
};
});
// FIXED: Secure storage (Electron safeStorage) for XSS protection
// Check encryption availability
ipcMain.handle('is-encryption-available', async (): Promise<boolean> => {
try {
return safeStorage.isEncryptionAvailable();
} catch (error) {
log.error('safe-storage', 'Failed to check encryption availability', {
error: error instanceof Error ? error.message : String(error)
});
return false;
}
});
// Encrypt data
ipcMain.handle('encrypt-data', async (_event: any, plaintext: string): Promise<string> => {
try {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('Encryption not available on this platform');
}
const buffer = safeStorage.encryptString(plaintext);
const encrypted = buffer.toString('base64');
log.debug('safe-storage', 'Data encrypted successfully', {
plaintextLength: plaintext.length,
encryptedLength: encrypted.length
});
return encrypted;
} catch (error) {
log.error('safe-storage', 'Failed to encrypt data', {
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
});
// Decrypt data
ipcMain.handle('decrypt-data', async (_event: any, encrypted: string): Promise<string> => {
try {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('Encryption not available on this platform');
}
const buffer = Buffer.from(encrypted, 'base64');
const plaintext = safeStorage.decryptString(buffer);
log.debug('safe-storage', 'Data decrypted successfully', {
encryptedLength: encrypted.length,
plaintextLength: plaintext.length
});
return plaintext;
} catch (error) {
log.error('safe-storage', 'Failed to decrypt data', {
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
});

106
mc_test/src/main/logger.ts Executable file
View File

@ -0,0 +1,106 @@
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import path from 'path';
// Log interface with additional fields
interface LogInfo extends winston.LogEntry {
tag?: string;
context?: Record<string, unknown>;
}
// Create custom format
const customFormat = winston.format.printf(({ level, message, timestamp, tag, context }) => {
const contextStr = context ? ` | context: ${JSON.stringify(context)}` : '';
return `[${timestamp}] [${tag || 'main'}] [${level.toUpperCase()}]: ${message}${contextStr}`;
});
// Logger configuration
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
winston.format.errors({ stack: true }),
customFormat
),
transports: [
// File logging with rotation
new DailyRotateFile({
dirname: path.join(process.cwd(), 'logs'),
filename: 'app-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
level: 'info'
}),
// Console logging with colors
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
customFormat
)
})
]
});
// Handle unhandled exceptions and promises
logger.exceptions.handle(
new winston.transports.File({ filename: path.join(process.cwd(), 'logs', 'exceptions.log') })
);
logger.rejections.handle(
new winston.transports.File({ filename: path.join(process.cwd(), 'logs', 'rejections.log') })
);
// Logger method types
type LogLevel = 'error' | 'warn' | 'info' | 'debug';
type LogMethod = (tag: string, message: string, context?: Record<string, unknown>) => void;
// Create methods for different log levels
const createLogMethod = (level: LogLevel): LogMethod => {
return (tag: string, message: string, context?: Record<string, unknown>) => {
logger.log({
level,
message,
tag,
context
} as LogInfo);
};
};
// Export logger interface
export const log = {
error: createLogMethod('error'),
warn: createLogMethod('warn'),
info: createLogMethod('info'),
debug: createLogMethod('debug'),
// Method for logging unhandled errors
fatal: (error: Error, context?: Record<string, unknown>) => {
logger.error({
level: 'error',
message: error.message,
tag: 'uncaught',
context: {
...context,
stack: error.stack
}
} as LogInfo);
}
};
// Configure unhandled error handlers
process.on('uncaughtException', (error) => {
log.fatal(error);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
if (reason instanceof Error) {
log.fatal(reason);
} else {
log.error('unhandledRejection', 'Unhandled promise rejection', { reason });
}
});
export default log;

47
mc_test/src/preload.ts Executable file
View File

@ -0,0 +1,47 @@
import { LogLevel, MachineAction, ElectronAPI } from './types/electron';
import { Machine } from './renderer/types';
const { contextBridge, ipcRenderer } = require('electron');
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld(
'electronAPI', {
// Connect to machine
connectToMachine: (machine: Machine) =>
ipcRenderer.invoke('connect-to-machine', machine),
// Control machine state
controlMachine: (machineId: string, action: MachineAction) =>
ipcRenderer.invoke('control-machine', { machineId, action }),
// Get machine status
getMachineStatus: (machineId: string) =>
ipcRenderer.invoke('get-machine-status', machineId),
// Get machine list
getMachineList: () =>
ipcRenderer.invoke('get-machine-list'),
// Logging
logEvent: (level: LogLevel, tag: string, message: string, context?: Record<string, unknown>) =>
ipcRenderer.send('log-event', { level, tag, message, context }),
// FIXED: Secure storage for JWT tokens (XSS protection)
encryptData: (plaintext: string) =>
ipcRenderer.invoke('encrypt-data', plaintext),
decryptData: (encrypted: string) =>
ipcRenderer.invoke('decrypt-data', encrypted),
// Check safeStorage availability
isEncryptionAvailable: () =>
ipcRenderer.invoke('is-encryption-available'),
} as ElectronAPI
);
// TypeScript types
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}

1544
mc_test/src/renderer/App.tsx Executable file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
import { useState } from 'react'
import { Play, Power, HeartPulse, AlertCircle, Sparkles } from 'lucide-react'
import { Machine } from '../types'
import { log } from '../utils/logger'
import { useMachineStore } from '../store/machineStore'
import { Button } from './shared/Button'
interface ActionPanelProps {
machine: Machine;
onConnect?: () => void;
isConnected?: boolean;
onDisconnect?: () => void;
userRole?: string;
}
function ActionPanel({ machine, onConnect, isConnected, onDisconnect, userRole }: ActionPanelProps) {
const [isLoading, setIsLoading] = useState<{[key: string]: boolean}>({
connect: false,
health: false,
issue: false
});
const { controlMachine: _controlMachine } = useMachineStore();
const setActionLoading = (action: string, loading: boolean) => {
setIsLoading(prev => ({ ...prev, [action]: loading }));
};
const handleConnect = async () => {
if (isConnected && onDisconnect) {
onDisconnect();
} else if (onConnect) {
onConnect();
}
};
const handleHealthCheck = async () => {
setActionLoading('health', true);
try {
log.info('action-panel', 'Starting health check', {
machineId: machine.id,
machineName: machine.name
});
// TODO: Implement health check logic
await new Promise(resolve => setTimeout(resolve, 1500)); // Имитация запроса
log.info('action-panel', 'Health check completed', {
machineId: machine.id,
status: 'healthy' // Пример результата
});
} catch (error) {
log.error('action-panel', 'Health check failed', {
machineId: machine.id,
error: error instanceof Error ? error.message : String(error)
});
} finally {
setActionLoading('health', false);
}
};
const handleCreateIssue = async () => {
setActionLoading('issue', true);
try {
log.info('action-panel', 'Creating issue', {
machineId: machine.id,
machineName: machine.name
});
// TODO: Implement issue creation logic
await new Promise(resolve => setTimeout(resolve, 1000)); // Имитация запроса
log.info('action-panel', 'Issue created successfully', {
machineId: machine.id,
issueId: 'MOCK-123' // Пример ID созданной задачи
});
} catch (error) {
log.error('action-panel', 'Failed to create issue', {
machineId: machine.id,
error: error instanceof Error ? error.message : String(error)
});
} finally {
setActionLoading('issue', false);
}
};
const isGuestRole = userRole === 'GUEST';
const isConnectDisabled = isGuestRole && !isConnected; // Disable only for GUEST when not connected
return (
<div className="bg-white border-t border-kaspersky-border p-4 shadow-kaspersky select-none">
<div className="flex justify-end gap-3">
<Button
variant={isConnected ? 'danger' : 'primary'}
size="md"
onClick={handleConnect}
isLoading={isLoading.connect}
disabled={isConnectDisabled}
leftIcon={isConnected ? <Power size={18} /> : <Play size={18} />}
title={isConnectDisabled ? 'Access denied: GUEST role cannot create connections. Contact your administrator.' : undefined}
>
{isConnected ? 'Disconnect' : 'Connect'}
</Button>
<Button
variant="secondary"
size="md"
onClick={handleHealthCheck}
isLoading={isLoading.health}
leftIcon={<HeartPulse size={18} />}
>
{isLoading.health ? 'Checking...' : 'Health Check'}
</Button>
<Button
variant="ghost"
size="md"
onClick={handleCreateIssue}
isLoading={isLoading.issue}
leftIcon={<AlertCircle size={18} />}
>
{isLoading.issue ? 'Creating...' : 'Create Issue'}
</Button>
{/* LLM Help кнопка - показывается только при активном подключении */}
{isConnected && (
<div className="relative">
<Button
variant="ghost"
size="md"
disabled
leftIcon={<Sparkles size={18} />}
className="opacity-50 cursor-not-allowed"
>
LLM Help
</Button>
{/* Бейдж SOON */}
<span className="absolute -top-1 -right-1 bg-gradient-to-r from-kaspersky-primary to-kaspersky-danger text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wide shadow-sm">
Soon
</span>
</div>
)}
</div>
</div>
)
}
export default ActionPanel

View File

@ -0,0 +1,393 @@
/**
* Modal for adding new machine
*/
import React, { useState } from 'react';
import { X, Save, Plus } from 'lucide-react';
import { useMachineStore } from '../store/machineStore';
import { SavedMachineCreate } from '../services/saved-machines-api';
import { log } from '../utils/logger';
import { Button } from './shared/Button';
interface AddMachineModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const AddMachineModal: React.FC<AddMachineModalProps> = ({
isOpen,
onClose,
onSuccess
}) => {
const { createSavedMachine, setShowSaveConfirmModal, setMachineToSave } = useMachineStore();
const [formData, setFormData] = useState<SavedMachineCreate>({
name: '',
hostname: '',
port: 3389,
protocol: 'rdp',
os: '',
description: '',
tags: [],
is_favorite: false
});
const [tagInput, setTagInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [askToSave, setAskToSave] = useState(true);
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? parseInt(value) : value
}));
};
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: checked
}));
};
const handleAddTag = () => {
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
setFormData(prev => ({
...prev,
tags: [...(prev.tags || []), tagInput.trim()]
}));
setTagInput('');
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData(prev => ({
...prev,
tags: prev.tags?.filter(tag => tag !== tagToRemove) || []
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!formData.name.trim()) {
setError('Название машины обязательно');
return;
}
if (!formData.hostname.trim()) {
setError('Хост обязателен');
return;
}
if (formData.port < 1 || formData.port > 65535) {
setError('Порт должен быть от 1 до 65535');
return;
}
setError(null);
// If "ask before saving" is selected
if (askToSave) {
setMachineToSave(formData);
setShowSaveConfirmModal(true);
onClose();
return;
}
// Otherwise save immediately
setLoading(true);
try {
log.info('AddMachineModal', 'Creating machine', { name: formData.name });
await createSavedMachine(formData);
log.info('AddMachineModal', 'Machine created successfully');
// Reset form
resetForm();
if (onSuccess) {
onSuccess();
}
onClose();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Не удалось создать машину';
log.error('AddMachineModal', 'Failed to create machine', { error: errorMessage });
setError(errorMessage);
} finally {
setLoading(false);
}
};
const resetForm = () => {
setFormData({
name: '',
hostname: '',
port: 3389,
protocol: 'rdp',
os: '',
description: '',
tags: [],
is_favorite: false
});
setTagInput('');
setError(null);
};
const handleClose = () => {
resetForm();
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-kaspersky-border select-none">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-kaspersky-text-dark">Добавить машину</h2>
<button
onClick={handleClose}
className="text-kaspersky-text-light hover:text-kaspersky-danger transition-colors p-1 rounded-lg hover:bg-kaspersky-bg"
disabled={loading}
>
<X size={24} />
</button>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-kaspersky-danger bg-opacity-10 border border-kaspersky-danger rounded-lg text-kaspersky-danger text-sm font-medium animate-shake">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="name" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Название *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleInputChange}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
placeholder="Например: Production Server"
required
disabled={loading}
/>
</div>
{/* Hostname */}
<div>
<label htmlFor="hostname" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Хост (IP или домен) *
</label>
<input
type="text"
id="hostname"
name="hostname"
value={formData.hostname}
onChange={handleInputChange}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
placeholder="192.168.1.100 или example.com"
required
disabled={loading}
/>
</div>
{/* Operating System */}
<div>
<label htmlFor="os" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Операционная система
</label>
<input
type="text"
id="os"
name="os"
value={formData.os}
onChange={handleInputChange}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
placeholder="Windows Server 2019, Ubuntu 22.04, etc."
disabled={loading}
/>
</div>
{/* Protocol and Port */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="protocol" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Протокол *
</label>
<select
id="protocol"
name="protocol"
value={formData.protocol}
onChange={handleInputChange}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
required
disabled={loading}
>
<option value="rdp">RDP</option>
<option value="ssh">SSH</option>
<option value="vnc">VNC</option>
<option value="telnet">Telnet</option>
</select>
</div>
<div>
<label htmlFor="port" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Порт *
</label>
<input
type="number"
id="port"
name="port"
value={formData.port}
onChange={handleInputChange}
min="1"
max="65535"
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
required
disabled={loading}
/>
</div>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Описание
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
rows={3}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white resize-none transition-all duration-200 select-text"
placeholder="Опциональное описание машины"
disabled={loading}
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Теги
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
className="flex-1 px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
placeholder="Введите тег и нажмите Enter"
disabled={loading}
/>
<Button
type="button"
onClick={handleAddTag}
variant="secondary"
disabled={loading || !tagInput.trim()}
leftIcon={<Plus size={16} />}
>
Добавить
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-kaspersky-primary bg-opacity-10 text-kaspersky-primary rounded-lg text-sm font-medium border border-kaspersky-primary border-opacity-30"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-kaspersky-danger transition-colors ml-1"
disabled={loading}
>
<X size={14} />
</button>
</span>
))}
</div>
)}
</div>
{/* Is Favorite */}
<div className="flex items-center gap-3 p-3 bg-kaspersky-bg rounded-lg border border-kaspersky-border">
<input
type="checkbox"
id="is_favorite"
name="is_favorite"
checked={formData.is_favorite}
onChange={handleCheckboxChange}
className="w-5 h-5 text-kaspersky-primary bg-white border-2 border-kaspersky-border rounded focus:ring-2 focus:ring-kaspersky-primary cursor-pointer"
disabled={loading}
/>
<label htmlFor="is_favorite" className="text-sm font-medium text-kaspersky-text-dark cursor-pointer flex-1">
Добавить в избранное
</label>
</div>
{/* Ask to save */}
<div className="flex items-center gap-3 p-3 bg-kaspersky-bg rounded-lg border border-kaspersky-border">
<input
type="checkbox"
id="askToSave"
checked={askToSave}
onChange={(e) => setAskToSave(e.target.checked)}
className="w-5 h-5 text-kaspersky-primary bg-white border-2 border-kaspersky-border rounded focus:ring-2 focus:ring-kaspersky-primary cursor-pointer"
disabled={loading}
/>
<label htmlFor="askToSave" className="text-sm font-medium text-kaspersky-text-dark cursor-pointer flex-1">
💬 Спросить подтверждение перед сохранением
</label>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button
type="submit"
variant="primary"
fullWidth
disabled={loading}
isLoading={loading}
leftIcon={!loading ? <Save size={18} /> : undefined}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button
type="button"
variant="secondary"
onClick={handleClose}
disabled={loading}
leftIcon={<X size={18} />}
>
Отмена
</Button>
</div>
</form>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,120 @@
/**
* BulkActionsToolbar Component
*
* Floating action bar that appears when machines are selected in bulk mode.
* Provides quick access to bulk operations:
* - Health Check
* - Run Command (future)
* - Multi-Connect (future)
* - Update Tags (future)
*/
import { Activity, X, Terminal, Grid, Tag } from 'lucide-react';
import { Button } from './shared/Button';
interface BulkActionsToolbarProps {
selectedCount: number;
onHealthCheck: () => void;
onSSHCommand: () => void;
onClearSelection: () => void;
userRole?: string;
}
export function BulkActionsToolbar({
selectedCount,
onHealthCheck,
onSSHCommand,
onClearSelection,
userRole
}: BulkActionsToolbarProps) {
if (selectedCount === 0) {
return null; // Don't render if no machines selected
}
const isGuestRole = userRole === 'GUEST';
return (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border-2 border-kaspersky-primary px-6 py-4 flex items-center gap-4">
{/* Selection Counter */}
<div className="flex items-center gap-2 pr-4 border-r border-kaspersky-border">
<div className="w-8 h-8 rounded-full bg-kaspersky-primary bg-opacity-10 flex items-center justify-center">
<span className="text-sm font-bold text-kaspersky-primary">{selectedCount}</span>
</div>
<span className="text-sm font-medium text-kaspersky-text-dark">
{selectedCount === 1 ? 'machine selected' : 'machines selected'}
</span>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Health Check */}
<Button
onClick={onHealthCheck}
variant="primary"
size="md"
leftIcon={<Activity size={18} />}
title="Check availability of selected machines"
>
Health Check
</Button>
{/* Run Command */}
<Button
onClick={onSSHCommand}
variant="secondary"
size="md"
leftIcon={<Terminal size={18} />}
disabled={isGuestRole}
title={isGuestRole ? "GUEST role cannot execute SSH commands" : "Run SSH command on selected machines"}
>
Run Command
</Button>
{/* Multi-Connect (Future - Disabled for now) */}
<Button
onClick={() => {}}
variant="secondary"
size="md"
leftIcon={<Grid size={18} />}
disabled
title="Coming soon: Connect to multiple machines"
>
Multi-Connect
</Button>
{/* Update Tags (Future - Disabled for now) */}
<Button
onClick={() => {}}
variant="secondary"
size="md"
leftIcon={<Tag size={18} />}
disabled
title="Coming soon: Update tags for selected machines"
>
Update Tags
</Button>
</div>
{/* Clear Selection */}
<button
onClick={onClearSelection}
className="ml-4 pl-4 border-l border-kaspersky-border text-kaspersky-text-light hover:text-kaspersky-danger transition-colors"
title="Clear selection"
>
<X size={20} />
</button>
</div>
{/* Guest Warning */}
{isGuestRole && (
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 bg-kaspersky-warning text-white px-4 py-2 rounded-lg text-sm font-medium shadow-lg whitespace-nowrap">
GUEST role: Limited access to bulk operations
</div>
)}
</div>
);
}

View File

@ -0,0 +1,168 @@
/**
* BulkHealthCheckProgressModal Component
*
* Displays real-time progress of bulk health check operations.
* Shows:
* - Total machines being checked
* - Completion percentage
* - Live status updates
* - Cancel button
*/
import { useEffect, useState } from 'react';
import { Activity, X, CheckCircle, XCircle, Clock } from 'lucide-react';
import { Button } from './shared/Button';
interface ProgressData {
total: number;
checked: number;
available: number;
unavailable: number;
currentMachine?: string;
}
interface BulkHealthCheckProgressModalProps {
isOpen: boolean;
progress: ProgressData;
isComplete: boolean;
onClose: () => void;
onCancel?: () => void;
}
export function BulkHealthCheckProgressModal({
isOpen,
progress,
isComplete,
onClose,
onCancel
}: BulkHealthCheckProgressModalProps) {
const [animatedProgress, setAnimatedProgress] = useState(0);
// Animate progress bar
useEffect(() => {
const targetProgress = progress.total > 0 ? (progress.checked / progress.total) * 100 : 0;
const timer = setTimeout(() => setAnimatedProgress(targetProgress), 100);
return () => clearTimeout(timer);
}, [progress.checked, progress.total]);
if (!isOpen) return null;
const percentage = progress.total > 0 ? Math.round((progress.checked / progress.total) * 100) : 0;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-lg w-full mx-4 select-none">
{/* Header */}
<div className="p-6 border-b border-kaspersky-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-kaspersky-primary bg-opacity-10 rounded-full flex items-center justify-center">
<Activity className={`w-6 h-6 text-kaspersky-primary ${!isComplete ? 'animate-pulse' : ''}`} />
</div>
<div>
<h2 className="text-xl font-bold text-kaspersky-text-dark">
{isComplete ? 'Health Check Complete' : 'Checking Machines...'}
</h2>
<p className="text-sm text-kaspersky-text-light">
{isComplete
? `Checked ${progress.total} machine${progress.total !== 1 ? 's' : ''}`
: `${progress.checked} of ${progress.total} machines checked`
}
</p>
</div>
</div>
{isComplete && (
<button
onClick={onClose}
className="text-kaspersky-text-light hover:text-kaspersky-text-dark transition-colors"
>
<X size={24} />
</button>
)}
</div>
</div>
{/* Progress Content */}
<div className="p-6 space-y-4">
{/* Progress Bar */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-kaspersky-text-dark">Progress</span>
<span className="text-sm font-bold text-kaspersky-primary">{percentage}%</span>
</div>
<div className="w-full h-3 bg-kaspersky-bg rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
isComplete
? 'bg-kaspersky-success'
: 'bg-kaspersky-primary'
}`}
style={{ width: `${animatedProgress}%` }}
/>
</div>
</div>
{/* Current Machine */}
{!isComplete && progress.currentMachine && (
<div className="flex items-center gap-2 text-sm text-kaspersky-text-light">
<Clock size={16} className="animate-spin" />
<span>Checking: <span className="font-medium text-kaspersky-text-dark">{progress.currentMachine}</span></span>
</div>
)}
{/* Statistics */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-kaspersky-bg p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-kaspersky-text-dark">{progress.total}</div>
<div className="text-xs text-kaspersky-text-light mt-1">Total</div>
</div>
<div className="bg-green-50 p-3 rounded-lg text-center">
<div className="flex items-center justify-center gap-1">
<CheckCircle size={16} className="text-kaspersky-success" />
<span className="text-2xl font-bold text-kaspersky-success">{progress.available}</span>
</div>
<div className="text-xs text-kaspersky-success mt-1">Available</div>
</div>
<div className="bg-red-50 p-3 rounded-lg text-center">
<div className="flex items-center justify-center gap-1">
<XCircle size={16} className="text-kaspersky-danger" />
<span className="text-2xl font-bold text-kaspersky-danger">{progress.unavailable}</span>
</div>
<div className="text-xs text-kaspersky-danger mt-1">Unavailable</div>
</div>
</div>
</div>
{/* Footer */}
<div className="p-6 border-t border-kaspersky-border flex justify-end gap-3">
{!isComplete && onCancel && (
<Button
onClick={onCancel}
variant="secondary"
leftIcon={<X size={18} />}
>
Cancel
</Button>
)}
{isComplete && (
<Button
onClick={onClose}
variant="primary"
leftIcon={<CheckCircle size={18} />}
>
View Results
</Button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,290 @@
/**
* BulkResultsModal Component
*
* Displays detailed results of bulk operations.
* Features:
* - Tabbed view (All / Available / Unavailable)
* - Sortable table
* - Export to CSV
* - Retry failed operations
*/
import { useState, useMemo } from 'react';
import { X, CheckCircle, XCircle, Clock, Download, AlertCircle } from 'lucide-react';
import { Button } from './shared/Button';
import { BulkHealthCheckResponse } from '../services/bulk-operations-api';
interface BulkResultsModalProps {
isOpen: boolean;
results: BulkHealthCheckResponse | null;
onClose: () => void;
onRetryFailed?: () => void;
}
type FilterTab = 'all' | 'available' | 'unavailable';
export function BulkResultsModal({
isOpen,
results,
onClose,
onRetryFailed
}: BulkResultsModalProps) {
const [activeTab, setActiveTab] = useState<FilterTab>('all');
const [sortBy, setSortBy] = useState<'name' | 'status' | 'response_time'>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// Filter and sort results
const filteredResults = useMemo(() => {
if (!results) return [];
let filtered = results.results;
// Apply filter
switch (activeTab) {
case 'available':
filtered = filtered.filter(r => r.available);
break;
case 'unavailable':
filtered = filtered.filter(r => !r.available);
break;
default:
// all
break;
}
// Apply sort
return [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.machine_name.localeCompare(b.machine_name);
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'response_time':
comparison = (a.response_time_ms || 9999) - (b.response_time_ms || 9999);
break;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
}, [results, activeTab, sortBy, sortOrder]);
// Export to CSV
const handleExportCSV = () => {
if (!results) return;
const csvHeaders = ['Machine Name', 'Hostname', 'Status', 'Available', 'Response Time (ms)', 'Error'];
const csvRows = results.results.map(r => [
r.machine_name,
r.hostname,
r.status,
r.available ? 'Yes' : 'No',
r.response_time_ms?.toString() || 'N/A',
r.error || ''
]);
const csvContent = [
csvHeaders.join(','),
...csvRows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `health-check-results-${new Date().toISOString()}.csv`;
link.click();
};
// Toggle sort
const handleSort = (column: typeof sortBy) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('asc');
}
};
if (!isOpen || !results) return null;
const hasFailures = results.unavailable > 0;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-4xl w-full mx-4 max-h-[90vh] flex flex-col select-none">
{/* Header */}
<div className="p-6 border-b border-kaspersky-border">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-kaspersky-text-dark">Health Check Results</h2>
<p className="text-sm text-kaspersky-text-light mt-1">
Completed in {(results.execution_time_ms / 1000).toFixed(2)}s
</p>
</div>
<button
onClick={onClose}
className="text-kaspersky-text-light hover:text-kaspersky-text-dark transition-colors"
>
<X size={24} />
</button>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-3 mt-4">
<div className="bg-kaspersky-bg p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-kaspersky-text-dark">{results.total}</div>
<div className="text-xs text-kaspersky-text-light mt-1">Total</div>
</div>
<div className="bg-green-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-kaspersky-success">{results.available}</div>
<div className="text-xs text-kaspersky-success mt-1">Available</div>
</div>
<div className="bg-red-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-kaspersky-danger">{results.unavailable}</div>
<div className="text-xs text-kaspersky-danger mt-1">Unavailable</div>
</div>
<div className="bg-blue-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-blue-600">{results.success}</div>
<div className="text-xs text-blue-600 mt-1">Success</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-kaspersky-border px-6">
<button
onClick={() => setActiveTab('all')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'all'
? 'border-kaspersky-primary text-kaspersky-primary'
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
}`}
>
All ({results.total})
</button>
<button
onClick={() => setActiveTab('available')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'available'
? 'border-kaspersky-success text-kaspersky-success'
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
}`}
>
Available ({results.available})
</button>
<button
onClick={() => setActiveTab('unavailable')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'unavailable'
? 'border-kaspersky-danger text-kaspersky-danger'
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
}`}
>
Unavailable ({results.unavailable})
</button>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto p-6">
<table className="w-full">
<thead>
<tr className="border-b border-kaspersky-border">
<th className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark">Status</th>
<th
className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark cursor-pointer hover:text-kaspersky-primary"
onClick={() => handleSort('name')}
>
Machine Name {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark">Hostname</th>
<th
className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark cursor-pointer hover:text-kaspersky-primary"
onClick={() => handleSort('response_time')}
>
Response {sortBy === 'response_time' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark">Error</th>
</tr>
</thead>
<tbody>
{filteredResults.map((result) => (
<tr key={result.machine_id} className="border-b border-kaspersky-border hover:bg-kaspersky-bg transition-colors">
<td className="p-2">
<div title={result.available ? "Available" : result.status === 'timeout' ? "Timeout" : "Unavailable"}>
{result.available ? (
<CheckCircle size={18} className="text-kaspersky-success" />
) : result.status === 'timeout' ? (
<Clock size={18} className="text-kaspersky-warning" />
) : (
<XCircle size={18} className="text-kaspersky-danger" />
)}
</div>
</td>
<td className="p-2 text-sm font-medium text-kaspersky-text-dark">{result.machine_name}</td>
<td className="p-2 text-sm text-kaspersky-text-light">{result.hostname}</td>
<td className="p-2 text-sm text-kaspersky-text-light">
{result.response_time_ms ? `${result.response_time_ms}ms` : 'N/A'}
</td>
<td className="p-2 text-sm text-kaspersky-danger">
{result.error && (
<div className="flex items-center gap-1" title={result.error}>
<AlertCircle size={14} />
<span className="truncate max-w-[200px]">{result.error}</span>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredResults.length === 0 && (
<div className="text-center py-8 text-kaspersky-text-light">
No results to display
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-kaspersky-border flex justify-between items-center">
<div className="flex gap-2">
{hasFailures && onRetryFailed && (
<Button
onClick={onRetryFailed}
variant="secondary"
size="sm"
leftIcon={<AlertCircle size={16} />}
>
Retry Failed ({results.unavailable})
</Button>
)}
</div>
<div className="flex gap-2">
<Button
onClick={handleExportCSV}
variant="secondary"
size="sm"
leftIcon={<Download size={16} />}
>
Export CSV
</Button>
<Button
onClick={onClose}
variant="primary"
size="sm"
>
Close
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,378 @@
/**
* BulkSSHCommandModal Component
*
* Modal for executing SSH command on multiple machines.
*
* 2 credential modes:
* 1. 'global' - Single credentials for all machines (enter manually)
* 2. 'custom' - Individual credentials for each machine
*
* Passwords are NOT saved and transmitted via HTTPS
*/
import { useState, useEffect } from 'react';
import { Terminal, X, Users, User } from 'lucide-react';
import { Button } from './shared/Button';
import { Machine } from '../types';
type CredentialsMode = 'global' | 'custom';
interface GlobalCredentials {
username: string;
password: string;
}
interface CustomCredentials {
[machineId: string]: {
username: string;
password: string;
};
}
interface BulkSSHCommandModalProps {
isOpen: boolean;
selectedMachines: Machine[];
onClose: () => void;
onExecute: (command: string, mode: CredentialsMode, globalCreds?: GlobalCredentials, customCreds?: CustomCredentials) => void;
userRole?: string;
}
export function BulkSSHCommandModal({
isOpen,
selectedMachines,
onClose,
onExecute,
userRole
}: BulkSSHCommandModalProps) {
const [command, setCommand] = useState('');
const [credentialsMode, setCredentialsMode] = useState<CredentialsMode>('global');
const [globalCredentials, setGlobalCredentials] = useState<GlobalCredentials>({
username: '',
password: ''
});
const [customCredentials, setCustomCredentials] = useState<CustomCredentials>({});
// Command whitelist for USER role
const isUserRole = userRole === 'USER';
const allowedCommands = [
'uptime',
'df -h',
'free -m',
'top -bn1',
'systemctl status',
'docker ps',
'ps aux',
'ls -la',
'cat /etc/os-release',
'hostname'
];
// Check if command is allowed
const isCommandAllowed = !isUserRole || allowedCommands.some(cmd =>
command.trim().startsWith(cmd)
);
// Initialize custom credentials for machines
useEffect(() => {
if (credentialsMode === 'custom') {
const initialCreds: CustomCredentials = {};
selectedMachines.forEach(machine => {
if (!customCredentials[machine.id]) {
initialCreds[machine.id] = { username: '', password: '' };
}
});
setCustomCredentials(prev => ({ ...prev, ...initialCreds }));
}
}, [credentialsMode, selectedMachines]);
// Handle execute
const handleExecute = () => {
if (!command.trim()) {
return;
}
if (!isCommandAllowed) {
return;
}
if (credentialsMode === 'global') {
if (!globalCredentials.username || !globalCredentials.password) {
return;
}
onExecute(command, 'global', globalCredentials);
} else {
// custom mode
// Validate all machines have credentials
const allHaveCreds = selectedMachines.every(m =>
customCredentials[m.id]?.username && customCredentials[m.id]?.password
);
if (!allHaveCreds) {
return;
}
onExecute(command, 'custom', undefined, customCredentials);
}
};
// Handle custom credential change
const handleCustomCredChange = (machineId: string, field: 'username' | 'password', value: string) => {
setCustomCredentials(prev => ({
...prev,
[machineId]: {
...prev[machineId],
[field]: value
}
}));
};
// Copy global credentials to all machines
const copyToAllMachines = () => {
if (!globalCredentials.username || !globalCredentials.password) {
return;
}
const newCreds: CustomCredentials = {};
selectedMachines.forEach(machine => {
newCreds[machine.id] = {
username: globalCredentials.username,
password: globalCredentials.password
};
});
setCustomCredentials(newCreds);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-3xl w-full mx-4 max-h-[90vh] flex flex-col select-none">
{/* Header */}
<div className="p-6 border-b border-kaspersky-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-kaspersky-primary bg-opacity-10 rounded-full flex items-center justify-center">
<Terminal className="w-6 h-6 text-kaspersky-primary" />
</div>
<div>
<h2 className="text-xl font-bold text-kaspersky-text-dark">Execute SSH Command</h2>
<p className="text-sm text-kaspersky-text-light">
Run command on {selectedMachines.length} machine{selectedMachines.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-kaspersky-text-light hover:text-kaspersky-text-dark transition-colors"
>
<X size={24} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Command Input */}
<div>
<label className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Command *
</label>
<input
type="text"
value={command}
onChange={e => setCommand(e.target.value)}
placeholder={isUserRole ? "e.g. uptime, df -h, free -m" : "e.g. systemctl restart nginx"}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 font-mono select-text"
/>
{/* Command validation message */}
{isUserRole && !isCommandAllowed && command.trim() && (
<p className="mt-2 text-sm text-kaspersky-danger">
This command is not in the whitelist. Allowed: {allowedCommands.join(', ')}
</p>
)}
{isUserRole && (
<p className="mt-2 text-sm text-kaspersky-text-light">
USER role: Only whitelist commands allowed
</p>
)}
</div>
{/* Credentials Mode Selector */}
<div>
<label className="block text-sm font-semibold text-kaspersky-text-dark mb-3">
Credentials Mode *
</label>
<div className="grid grid-cols-2 gap-3">
{/* Mode 1: Global */}
<button
onClick={() => setCredentialsMode('global')}
className={`p-4 rounded-lg border-2 transition-all ${
credentialsMode === 'global'
? 'border-kaspersky-primary bg-kaspersky-primary bg-opacity-10'
: 'border-kaspersky-border bg-kaspersky-bg hover:border-kaspersky-primary hover:border-opacity-50'
}`}
>
<div className="flex flex-col items-center gap-2">
<Users size={24} className={credentialsMode === 'global' ? 'text-kaspersky-primary' : 'text-kaspersky-text-light'} />
<span className="text-sm font-medium text-kaspersky-text-dark">Global</span>
<span className="text-xs text-kaspersky-text-light text-center">
Same for all machines
</span>
</div>
</button>
{/* Mode 2: Custom */}
<button
onClick={() => setCredentialsMode('custom')}
className={`p-4 rounded-lg border-2 transition-all ${
credentialsMode === 'custom'
? 'border-kaspersky-primary bg-kaspersky-primary bg-opacity-10'
: 'border-kaspersky-border bg-kaspersky-bg hover:border-kaspersky-primary hover:border-opacity-50'
}`}
>
<div className="flex flex-col items-center gap-2">
<User size={24} className={credentialsMode === 'custom' ? 'text-kaspersky-primary' : 'text-kaspersky-text-light'} />
<span className="text-sm font-medium text-kaspersky-text-dark">Custom</span>
<span className="text-xs text-kaspersky-text-light text-center">
Per-machine credentials
</span>
</div>
</button>
</div>
</div>
{/* Global Credentials Input */}
{credentialsMode === 'global' && (
<div className="bg-kaspersky-bg border border-kaspersky-border rounded-lg p-4 space-y-3">
<h4 className="font-semibold text-kaspersky-text-dark">Global Credentials</h4>
<p className="text-sm text-kaspersky-text-light">
These credentials will be used for all {selectedMachines.length} machines
</p>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-kaspersky-text-dark mb-1">Username</label>
<input
type="text"
value={globalCredentials.username}
onChange={e => setGlobalCredentials(prev => ({ ...prev, username: e.target.value }))}
placeholder="root"
className="w-full px-3 py-2 bg-white border border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary select-text"
/>
</div>
<div>
<label className="block text-sm font-medium text-kaspersky-text-dark mb-1">Password</label>
<input
type="password"
value={globalCredentials.password}
onChange={e => setGlobalCredentials(prev => ({ ...prev, password: e.target.value }))}
placeholder="••••••••"
className="w-full px-3 py-2 bg-white border border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary select-text"
/>
</div>
</div>
</div>
)}
{/* Custom Credentials Input */}
{credentialsMode === 'custom' && (
<div className="bg-kaspersky-bg border border-kaspersky-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-kaspersky-text-dark">Custom Credentials Per Machine</h4>
<Button
onClick={copyToAllMachines}
variant="secondary"
size="sm"
disabled={!globalCredentials.username || !globalCredentials.password}
>
Copy from Global
</Button>
</div>
{/* Quick global fill (helper) */}
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 space-y-2">
<p className="text-xs text-yellow-800 font-medium">Quick Fill (optional)</p>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={globalCredentials.username}
onChange={e => setGlobalCredentials(prev => ({ ...prev, username: e.target.value }))}
placeholder="Username"
className="px-2 py-1 text-sm bg-white border border-yellow-300 rounded select-text"
/>
<input
type="password"
value={globalCredentials.password}
onChange={e => setGlobalCredentials(prev => ({ ...prev, password: e.target.value }))}
placeholder="Password"
className="px-2 py-1 text-sm bg-white border border-yellow-300 rounded select-text"
/>
</div>
</div>
{/* Machine credentials list */}
<div className="max-h-60 overflow-y-auto space-y-3">
{selectedMachines.map(machine => (
<div key={machine.id} className="bg-white border border-kaspersky-border rounded p-3 space-y-2">
<div className="font-medium text-sm text-kaspersky-text-dark">{machine.name}</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={customCredentials[machine.id]?.username || ''}
onChange={e => handleCustomCredChange(machine.id, 'username', e.target.value)}
placeholder="Username"
className="px-2 py-1 text-sm bg-kaspersky-bg border border-kaspersky-border rounded focus:outline-none focus:border-kaspersky-primary select-text"
/>
<input
type="password"
value={customCredentials[machine.id]?.password || ''}
onChange={e => handleCustomCredChange(machine.id, 'password', e.target.value)}
placeholder="Password"
className="px-2 py-1 text-sm bg-kaspersky-bg border border-kaspersky-border rounded focus:outline-none focus:border-kaspersky-primary select-text"
/>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-kaspersky-border flex justify-between items-center">
<div className="text-sm text-kaspersky-text-light">
{credentialsMode === 'global' && `Same credentials for all ${selectedMachines.length} machines`}
{credentialsMode === 'custom' && `Custom credentials for each machine`}
</div>
<div className="flex gap-2">
<Button
onClick={onClose}
variant="secondary"
leftIcon={<X size={18} />}
>
Cancel
</Button>
<Button
onClick={handleExecute}
variant="primary"
leftIcon={<Terminal size={18} />}
disabled={!command.trim() || !isCommandAllowed ||
(credentialsMode === 'global' && (!globalCredentials.username || !globalCredentials.password)) ||
(credentialsMode === 'custom' && !selectedMachines.every(m => customCredentials[m.id]?.username && customCredentials[m.id]?.password))
}
>
Execute on {selectedMachines.length} Machine{selectedMachines.length !== 1 ? 's' : ''}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,367 @@
/**
* BulkSSHResultsModal Component
*
* Displays detailed results of bulk SSH command execution.
* Features:
* - Tabbed view (All / Success / Failed)
* - Expandable stdout/stderr
* - Export to CSV
* - Retry failed
*/
import { useState, useMemo } from 'react';
import { X, CheckCircle, XCircle, Terminal, Download, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from './shared/Button';
interface SSHResult {
machine_id: string;
machine_name: string;
hostname: string;
status: 'success' | 'failed' | 'timeout' | 'no_credentials';
exit_code?: number;
stdout?: string;
stderr?: string;
error?: string;
execution_time_ms?: number;
executed_at: string;
}
interface SSHResponse {
total: number;
success: number;
failed: number;
results: SSHResult[];
execution_time_ms: number;
command: string;
started_at: string;
completed_at: string;
}
interface BulkSSHResultsModalProps {
isOpen: boolean;
results: SSHResponse | null;
onClose: () => void;
onRetryFailed?: () => void;
}
type FilterTab = 'all' | 'success' | 'failed';
export function BulkSSHResultsModal({
isOpen,
results,
onClose,
onRetryFailed
}: BulkSSHResultsModalProps) {
const [activeTab, setActiveTab] = useState<FilterTab>('all');
const [expandedMachines, setExpandedMachines] = useState<Set<string>>(new Set());
// Filter results
const filteredResults = useMemo(() => {
if (!results) return [];
switch (activeTab) {
case 'success':
return results.results.filter(r => r.status === 'success');
case 'failed':
return results.results.filter(r => r.status !== 'success');
default:
return results.results;
}
}, [results, activeTab]);
// Toggle expand/collapse for machine
const toggleExpand = (machineId: string) => {
setExpandedMachines(prev => {
const newSet = new Set(prev);
if (newSet.has(machineId)) {
newSet.delete(machineId);
} else {
newSet.add(machineId);
}
return newSet;
});
};
// Expand all
const expandAll = () => {
setExpandedMachines(new Set(filteredResults.map(r => r.machine_id)));
};
// Collapse all
const collapseAll = () => {
setExpandedMachines(new Set());
};
// Export to CSV
const handleExportCSV = () => {
if (!results) return;
const csvHeaders = ['Machine Name', 'Hostname', 'Status', 'Exit Code', 'Stdout', 'Stderr', 'Error'];
const csvRows = results.results.map(r => [
r.machine_name,
r.hostname,
r.status,
r.exit_code?.toString() || 'N/A',
r.stdout || '',
r.stderr || '',
r.error || ''
]);
const csvContent = [
csvHeaders.join(','),
...csvRows.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `ssh-results-${new Date().toISOString()}.csv`;
link.click();
};
if (!isOpen || !results) return null;
const hasFailures = results.failed > 0;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-5xl w-full mx-4 max-h-[90vh] flex flex-col select-none">
{/* Header */}
<div className="p-6 border-b border-kaspersky-border">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-kaspersky-text-dark">SSH Command Results</h2>
<p className="text-sm text-kaspersky-text-light mt-1">
Completed in {(results.execution_time_ms / 1000).toFixed(2)}s
</p>
<code className="text-xs bg-kaspersky-bg px-2 py-1 rounded mt-2 inline-block">
{results.command}
</code>
</div>
<button
onClick={onClose}
className="text-kaspersky-text-light hover:text-kaspersky-text-dark transition-colors"
>
<X size={24} />
</button>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-3 mt-4">
<div className="bg-kaspersky-bg p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-kaspersky-text-dark">{results.total}</div>
<div className="text-xs text-kaspersky-text-light mt-1">Total</div>
</div>
<div className="bg-green-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-kaspersky-success">{results.success}</div>
<div className="text-xs text-kaspersky-success mt-1">Success</div>
</div>
<div className="bg-red-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-kaspersky-danger">{results.failed}</div>
<div className="text-xs text-kaspersky-danger mt-1">Failed</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-kaspersky-border px-6 justify-between items-center">
<div className="flex">
<button
onClick={() => setActiveTab('all')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'all'
? 'border-kaspersky-primary text-kaspersky-primary'
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
}`}
>
All ({results.total})
</button>
<button
onClick={() => setActiveTab('success')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'success'
? 'border-kaspersky-success text-kaspersky-success'
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
}`}
>
Success ({results.success})
</button>
<button
onClick={() => setActiveTab('failed')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'failed'
? 'border-kaspersky-danger text-kaspersky-danger'
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
}`}
>
Failed ({results.failed})
</button>
</div>
{/* Expand/Collapse controls */}
<div className="flex gap-2">
<button
onClick={expandAll}
className="text-xs text-kaspersky-primary hover:underline"
>
Expand All
</button>
<span className="text-kaspersky-text-light">|</span>
<button
onClick={collapseAll}
className="text-xs text-kaspersky-primary hover:underline"
>
Collapse All
</button>
</div>
</div>
{/* Results List */}
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-2">
{filteredResults.map((result) => {
const isExpanded = expandedMachines.has(result.machine_id);
const hasOutput = result.stdout || result.stderr;
return (
<div
key={result.machine_id}
className="border border-kaspersky-border rounded-lg overflow-hidden"
>
{/* Machine Header */}
<button
onClick={() => hasOutput && toggleExpand(result.machine_id)}
className="w-full p-4 flex items-center justify-between hover:bg-kaspersky-bg transition-colors"
>
<div className="flex items-center gap-3">
{/* Status Icon */}
<div title={result.status}>
{result.status === 'success' ? (
<CheckCircle size={20} className="text-kaspersky-success" />
) : result.status === 'timeout' ? (
<AlertCircle size={20} className="text-kaspersky-warning" />
) : (
<XCircle size={20} className="text-kaspersky-danger" />
)}
</div>
{/* Machine Info */}
<div className="text-left">
<div className="font-medium text-kaspersky-text-dark">{result.machine_name}</div>
<div className="text-sm text-kaspersky-text-light">{result.hostname}</div>
</div>
{/* Exit Code Badge */}
{result.exit_code !== undefined && (
<span className={`px-2 py-1 rounded text-xs font-mono ${
result.exit_code === 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
Exit: {result.exit_code}
</span>
)}
{/* Error Badge */}
{result.error && (
<span className="px-2 py-1 rounded text-xs bg-red-100 text-red-800 max-w-xs truncate">
{result.error}
</span>
)}
{/* Execution Time */}
{result.execution_time_ms && (
<span className="text-xs text-kaspersky-text-light">
{result.execution_time_ms}ms
</span>
)}
</div>
{/* Expand/Collapse Icon */}
{hasOutput && (
<div>
{isExpanded ? (
<ChevronUp size={20} className="text-kaspersky-text-light" />
) : (
<ChevronDown size={20} className="text-kaspersky-text-light" />
)}
</div>
)}
</button>
{/* Expanded Output */}
{isExpanded && hasOutput && (
<div className="border-t border-kaspersky-border bg-kaspersky-bg p-4 space-y-3">
{/* Stdout */}
{result.stdout && (
<div>
<div className="text-xs font-semibold text-kaspersky-text-dark mb-1">STDOUT:</div>
<pre className="bg-white border border-kaspersky-border rounded p-3 text-xs overflow-x-auto font-mono whitespace-pre-wrap">
{result.stdout}
</pre>
</div>
)}
{/* Stderr */}
{result.stderr && (
<div>
<div className="text-xs font-semibold text-kaspersky-danger mb-1">STDERR:</div>
<pre className="bg-red-50 border border-red-200 rounded p-3 text-xs overflow-x-auto font-mono whitespace-pre-wrap text-red-800">
{result.stderr}
</pre>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{filteredResults.length === 0 && (
<div className="text-center py-8 text-kaspersky-text-light">
No results to display
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-kaspersky-border flex justify-between items-center">
<div className="flex gap-2">
{hasFailures && onRetryFailed && (
<Button
onClick={onRetryFailed}
variant="secondary"
size="sm"
leftIcon={<Terminal size={16} />}
>
Retry Failed ({results.failed})
</Button>
)}
</div>
<div className="flex gap-2">
<Button
onClick={handleExportCSV}
variant="secondary"
size="sm"
leftIcon={<Download size={16} />}
>
Export CSV
</Button>
<Button
onClick={onClose}
variant="primary"
size="sm"
>
Close
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,145 @@
import React, { useMemo } from 'react';
import { Wifi, WifiOff, Clock, AlertCircle, Shield } from 'lucide-react';
import { Machine } from '../types';
import { ProtocolHelper, OSInfo } from '../utils/protocolHelper';
interface ConnectionStatusBadgeProps {
machine: Machine;
isConnected: boolean;
connectionTime?: Date;
protocol?: string;
className?: string;
}
const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = ({
machine,
isConnected,
connectionTime,
protocol,
className = ''
}) => {
const osInfo: OSInfo = useMemo(() => {
return ProtocolHelper.parseOS(machine.os);
}, [machine.os]);
const getProtocolIcon = (proto?: string) => {
if (!proto) return null;
const protocolIcons: Record<string, string> = {
rdp: '🖥️',
vnc: '🖱️',
ssh: '⌨️',
telnet: '📡'
};
return protocolIcons[proto.toLowerCase()] || '🔗';
};
const formatConnectionTime = (time: Date) => {
const now = new Date();
const diff = Math.floor((now.getTime() - time.getTime()) / 1000);
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
};
const getStatusConfig = () => {
if (isConnected) {
return {
icon: <Wifi size={14} className="text-green-600" />,
text: 'Connected',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
textColor: 'text-green-800',
animation: 'animate-pulse'
};
}
switch (machine.status) {
case 'running':
return {
icon: <WifiOff size={14} className="text-gray-500" />,
text: 'Available',
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
textColor: 'text-gray-700'
};
case 'stopped':
return {
icon: <AlertCircle size={14} className="text-red-500" />,
text: 'Offline',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
textColor: 'text-red-700'
};
default:
return {
icon: <Clock size={14} className="text-yellow-500" />,
text: 'Unknown',
bgColor: 'bg-yellow-50',
borderColor: 'border-yellow-200',
textColor: 'text-yellow-700'
};
}
};
const statusConfig = getStatusConfig();
const osIcon = osInfo.family === 'windows' ? '🖥️' :
osInfo.family === 'linux' ? '🐧' :
osInfo.family === 'macos' ? '🍎' : '💻';
return (
<div className={`inline-flex items-center gap-2 ${className}`}>
{/* Main status badge */}
<div
className={`
flex items-center gap-2 px-3 py-1.5 rounded-full border
${statusConfig.bgColor} ${statusConfig.borderColor} ${statusConfig.textColor}
${statusConfig.animation || ''}
transition-all duration-200
`}
>
{statusConfig.icon}
<span className="text-xs font-medium">{statusConfig.text}</span>
{isConnected && connectionTime && (
<>
<span className="text-gray-400"></span>
<span className="text-xs">{formatConnectionTime(connectionTime)}</span>
</>
)}
</div>
{/* Protocol indicator */}
{protocol && isConnected && (
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<span>{getProtocolIcon(protocol)}</span>
<span className="font-medium">{protocol.toUpperCase()}</span>
</div>
)}
{/* OS indicator */}
<div
className="flex items-center gap-1 px-2 py-1 bg-gray-100 border border-gray-200 rounded text-xs text-gray-600"
title={machine.os}
>
<span>{osIcon}</span>
<span className="hidden sm:inline capitalize">{osInfo.family}</span>
</div>
{/* Security indicator for secure protocols */}
{protocol && ['ssh', 'rdp'].includes(protocol.toLowerCase()) && (
<div
className="flex items-center px-2 py-1 bg-green-50 border border-green-200 rounded"
title="Secure connection"
>
<Shield size={12} className="text-green-600" />
</div>
)}
</div>
);
};
export default ConnectionStatusBadge;

View File

@ -0,0 +1,98 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { log } from '../utils/logger';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
errorId?: string;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorId: Math.random().toString(36).substr(2, 9)
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const errorId = this.state.errorId;
// Log error
log.error('error-boundary', 'React Error Boundary caught an error', {
errorId,
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
// Call callback if provided
this.props.onError?.(error, errorInfo);
}
private handleTryAgain = () => {
this.setState({ hasError: false, error: undefined, errorId: undefined });
};
private handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md w-full">
<div className="flex items-center mb-4">
<svg className="w-6 h-6 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h3 className="text-lg font-medium text-red-800">Something went wrong</h3>
</div>
<p className="text-red-700 mb-4">
An unexpected error occurred. Our team has been notified.
</p>
<div className="flex gap-3">
<button
onClick={this.handleTryAgain}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Try Again
</button>
<button
onClick={this.handleReload}
className="px-4 py-2 border border-red-300 text-red-700 rounded hover:bg-red-50 transition-colors"
>
Reload Page
</button>
</div>
{process.env.NODE_ENV === 'development' && (
<details className="mt-4">
<summary className="text-sm text-red-600 cursor-pointer hover:text-red-700">
Error Details (Development)
</summary>
<pre className="mt-2 text-xs text-red-800 bg-red-100 p-2 rounded overflow-auto max-h-32">
{this.state.error?.stack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from 'react';
import { Machine } from '../types';
import { log } from '../utils/logger';
interface GuacamoleViewerProps {
machine: Machine;
connectionUrl: string;
enableSftp?: boolean;
onError?: (error: Error) => void;
onDisconnect?: () => void;
}
/**
* GuacamoleViewer component
*
* KNOWN BEHAVIOR: You may see "DELETE /guacamole/api/session 403 Forbidden" in DevTools.
* This is EXPECTED and NOT an error:
*
* - Guacamole Client checks localStorage for old sessions when loading
* - It tries to clean up expired sessions via DELETE /guacamole/api/session
* - Old session tokens are invalid, resulting in 403 Forbidden
* - This is "best effort cleanup" and doesn't affect functionality
* - We manage all connections properly through our API endpoints
*
* This can be safely ignored - it's standard Guacamole Client behavior.
*/
function GuacamoleViewer({ machine, connectionUrl, enableSftp, onError, onDisconnect }: GuacamoleViewerProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isConsoleActive, setIsConsoleActive] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Effect for loading iframe (only on first initialization)
useEffect(() => {
const connectToGuacamole = async () => {
try {
setLoading(true);
setError(null);
log.info('guacamole-viewer', 'Initializing Guacamole session', {
machineId: machine.id,
machineName: machine.name,
connectionUrl: connectionUrl
});
if (iframeRef.current) {
// Load URL only if iframe is empty
if (!iframeRef.current.src || iframeRef.current.src === 'about:blank') {
log.debug('guacamole-viewer', 'Loading connection URL', {
url: connectionUrl
});
iframeRef.current.src = connectionUrl;
} else {
log.debug('guacamole-viewer', 'iframe already loaded, keeping existing session');
setLoading(false);
return;
}
}
// Add handler to track iframe loading
const handleLoad = () => {
setLoading(false);
log.info('guacamole-viewer', 'Guacamole session loaded', {
machineId: machine.id,
});
// Automatically activate console after loading
setTimeout(() => {
if (iframeRef.current) {
iframeRef.current.focus();
setIsConsoleActive(true);
}
}, 500);
};
const handleError = () => {
const error = new Error('Failed to load Guacamole session');
setError(error);
setLoading(false);
onError?.(error);
};
if (iframeRef.current) {
iframeRef.current.addEventListener('load', handleLoad);
iframeRef.current.addEventListener('error', handleError);
return () => {
iframeRef.current?.removeEventListener('load', handleLoad);
iframeRef.current?.removeEventListener('error', handleError);
};
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error occurred');
log.error('guacamole-viewer', 'Connection failed', {
machineId: machine.id,
error: error.message
});
setError(error);
onError?.(error);
setLoading(false);
}
};
connectToGuacamole();
return () => {
log.info('guacamole-viewer', 'Cleaning up connection', {
machineId: machine.id
});
setIsConsoleActive(false);
};
}, [machine.id]);
// Deactivate console on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node) && isConsoleActive) {
setIsConsoleActive(false);
if (iframeRef.current) {
iframeRef.current.blur();
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isConsoleActive]);
if (error) {
return (
<div className="flex-1 flex items-center justify-center bg-red-50 text-red-600 p-4 rounded-kaspersky">
<div className="text-center">
<p className="font-medium mb-2">Connection Error</p>
<p className="text-sm mb-4">{error.message}</p>
<div className="flex gap-2 justify-center">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-red-600 text-white rounded-kaspersky hover:bg-red-700 transition-colors"
>
Retry
</button>
<button
onClick={onDisconnect}
className="px-4 py-2 bg-gray-600 text-white rounded-kaspersky hover:bg-gray-700 transition-colors"
>
Disconnect
</button>
</div>
</div>
</div>
);
}
return (
<div className="w-full h-full flex flex-col bg-kaspersky-bg">
{/* Область для Guacamole - занимает всё пространство */}
<div
ref={containerRef}
className={`flex-1 relative bg-kaspersky-bg overflow-hidden ${
isConsoleActive ? 'ring-2 ring-kaspersky-primary ring-opacity-50' : ''
}`}
>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-kaspersky-bg bg-opacity-75 z-10">
<div className="text-center">
<div className="w-8 h-8 border-4 border-kaspersky-base border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="mt-2 text-kaspersky-text">Connecting to {machine.name}...</p>
</div>
</div>
)}
{/* Прозрачный overlay для активации консоли при клике */}
{!loading && !isConsoleActive && (
<div
className="absolute inset-0 z-10 cursor-pointer"
onClick={() => {
if (iframeRef.current) {
iframeRef.current.focus();
setIsConsoleActive(true);
}
}}
title="Click to activate console"
/>
)}
{/* Индикатор фокуса консоли с подсказками */}
{!loading && (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-20 pointer-events-none select-none">
<div className="flex flex-col items-center gap-2">
{/* Индикатор статуса консоли */}
<div
className={`flex items-center gap-3 px-4 py-2 rounded-lg text-sm font-semibold transition-all duration-300 ${
isConsoleActive
? 'bg-kaspersky-primary text-white shadow-kaspersky-lg'
: 'bg-kaspersky-secondary bg-opacity-90 text-kaspersky-text-white'
}`}
>
<div className={`w-3 h-3 rounded-full transition-all duration-300 ${
isConsoleActive ? 'bg-kaspersky-danger animate-pulse' : 'bg-kaspersky-text-lighter'
}`}></div>
<span>{isConsoleActive ? 'Console Active' : 'Console Inactive'}</span>
</div>
{/* Подсказка с клавиатурными сочетаниями */}
<div className="bg-kaspersky-secondary bg-opacity-75 backdrop-blur-sm text-kaspersky-text-white px-3 py-1.5 rounded-md text-xs font-medium">
{enableSftp ? (
// Tooltip for SSH with SFTP
<div className="flex items-center gap-2">
<span className="opacity-75">Settings & SFTP:</span>
<kbd className="px-1.5 py-0.5 bg-kaspersky-base bg-opacity-50 rounded text-[10px] font-mono">
Ctrl+Shift+Alt
</kbd>
</div>
) : (
// Tooltip for settings only
<div className="flex items-center gap-2">
<span className="opacity-75">Settings:</span>
<kbd className="px-1.5 py-0.5 bg-kaspersky-base bg-opacity-50 rounded text-[10px] font-mono">
Ctrl+Shift+Alt
</kbd>
</div>
)}
</div>
</div>
</div>
)}
<iframe
ref={iframeRef}
className="w-full h-full border-0"
title={`Remote Desktop - ${machine.name}`}
allow="clipboard-read; clipboard-write; keyboard-map *; pointer-lock"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock allow-modals allow-downloads"
onFocus={() => setIsConsoleActive(true)}
onBlur={() => setIsConsoleActive(false)}
/>
</div>
</div>
);
}
export default GuacamoleViewer;

View File

@ -0,0 +1,53 @@
import { lazy, Suspense } from 'react';
import React from 'react';
/**
* Loading spinner component
*/
const LoadingSpinner: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => (
<div className="flex flex-col items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-kaspersky-base mb-3"></div>
<p className="text-kaspersky-text text-sm">{message}</p>
</div>
);
/**
* Lazy loading of heavy components
*/
export const LazyGuacamoleViewer = lazy(() =>
import('./GuacamoleViewer').then((module) => ({ default: module.default }))
);
export const LazyMachineCredentialsModal = lazy(() =>
import('./MachineCredentialsModal').then((module) => ({ default: module.default }))
);
/**
* HOC for wrapping components in Suspense
*/
export const withSuspense = <P extends object>(
Component: React.ComponentType<P>,
loadingMessage?: string
) => {
const WrappedComponent: React.FC<P> = (props) => (
<Suspense fallback={<LoadingSpinner message={loadingMessage} />}>
<Component {...props} />
</Suspense>
);
WrappedComponent.displayName = `withSuspense(${Component.displayName || Component.name})`;
return WrappedComponent;
};
/**
* Ready-to-use components with Suspense
*/
export const GuacamoleViewerWithSuspense = withSuspense(
LazyGuacamoleViewer,
'Loading remote connection...'
);
export const MachineCredentialsModalWithSuspense = withSuspense(
LazyMachineCredentialsModal,
'Loading connection form...'
);

View File

@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { InputSanitizer } from '../utils/sanitizer';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onLogin: (username: string, password: string) => Promise<void>;
}
const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onLogin }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
// Sanitize and validate data before sending
const sanitizedUsername = InputSanitizer.sanitizeUsername(username);
InputSanitizer.validatePassword(password);
await onLogin(sanitizedUsername, password);
// Clear form after successful login
setUsername('');
setPassword('');
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-xl">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-kaspersky-text">Login to Remote Access</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 transition-colors"
disabled={isLoading}
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-kaspersky-base focus:border-transparent"
required
disabled={isLoading}
autoFocus
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-kaspersky-base focus:border-transparent"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded-md text-sm">
{error}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={isLoading}
className="flex-1 bg-kaspersky-base text-white py-2 px-4 rounded-md hover:bg-kaspersky-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
};
export default LoginModal;

View File

@ -0,0 +1,317 @@
import React, { useState, useEffect, useRef } from 'react';
import { Shield, Lock, User, AlertCircle, Clock, Building2 } from 'lucide-react';
import { InputSanitizer } from '../utils/sanitizer';
interface LoginScreenProps {
onLogin: (username: string, password: string) => Promise<void>;
}
const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [focusedField, setFocusedField] = useState<'username' | 'password' | null>(null);
const [showInactivityWarning, setShowInactivityWarning] = useState(false);
// Timer for auto-clearing form on inactivity
const inactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
// Function to clear form on inactivity
const clearFormDueToInactivity = () => {
if (username || password) {
setUsername('');
setPassword('');
setError(null);
setShowInactivityWarning(true);
// Hide warning after 5 seconds
setTimeout(() => {
setShowInactivityWarning(false);
}, 5000);
}
};
// Reset timer on user activity
const resetInactivityTimer = () => {
// Clear previous timer
if (inactivityTimerRef.current) {
clearTimeout(inactivityTimerRef.current);
}
// Set new 5 minute timer
inactivityTimerRef.current = setTimeout(() => {
clearFormDueToInactivity();
}, INACTIVITY_TIMEOUT);
};
// Track activity
const handleActivity = () => {
setShowInactivityWarning(false);
resetInactivityTimer();
};
// Set timer on mount and clear on unmount
useEffect(() => {
resetInactivityTimer();
// Cleanup on unmount
return () => {
if (inactivityTimerRef.current) {
clearTimeout(inactivityTimerRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Сбрасываем таймер при изменении полей формы
useEffect(() => {
if (username || password) {
resetInactivityTimer();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [username, password]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
// Санитизируем и валидируем данные перед отправкой
const sanitizedUsername = InputSanitizer.sanitizeUsername(username);
InputSanitizer.validatePassword(password);
await onLogin(sanitizedUsername, password);
// Очищаем форму после успешного входа
setUsername('');
setPassword('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed. Please check your credentials and try again.');
} finally {
setIsLoading(false);
}
};
return (
<div
className="min-h-screen w-full bg-gradient-to-br from-kaspersky-secondary via-kaspersky-primary-dark to-kaspersky-secondary animate-gradient flex items-center justify-center p-4 overflow-hidden"
onMouseMove={handleActivity}
onClick={handleActivity}
onKeyDown={handleActivity}
>
{/* Animated background pattern */}
<div className="absolute inset-0 opacity-10 overflow-hidden">
<div className="absolute inset-0 w-full h-full animate-pattern" style={{
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(0, 168, 142, 0.4) 1px, transparent 0)`,
backgroundSize: '40px 40px'
}} />
</div>
{/* Login Card */}
<div className="relative z-10 w-full max-w-md select-none">
{/* Logo/Header Section */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 bg-kaspersky-primary rounded-2xl shadow-kaspersky-xl mb-6 transform hover:scale-105 transition-transform duration-300">
<Shield size={48} className="text-white" strokeWidth={1.5} />
</div>
<h1 className="text-4xl font-bold text-white mb-2 tracking-tight">
URG Duty
</h1>
<p className="text-kaspersky-text-lighter text-lg">
Remote Access Control Center
</p>
</div>
{/* Login Form Card */}
<div className="bg-white/95 backdrop-blur-sm rounded-2xl shadow-kaspersky-xl p-8">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-semibold text-kaspersky-text-dark mb-2">
Sign In
</h2>
<p className="text-kaspersky-text-light text-sm">
Enter your credentials to access the system
</p>
</div>
{/* Inactivity Warning Message */}
{showInactivityWarning && (
<div className="mb-6 bg-orange-50 border-l-4 border-orange-500 rounded-lg p-4 flex items-start gap-3 animate-fadeIn select-none">
<Clock size={20} className="text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-orange-700">
Форма очищена
</p>
<p className="text-sm text-kaspersky-text-light mt-1">
Форма была очищена из-за неактивности (5 минут)
</p>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-kaspersky-danger rounded-lg p-4 flex items-start gap-3 animate-shake select-none">
<AlertCircle size={20} className="text-kaspersky-danger flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-kaspersky-danger">
Authentication Failed
</p>
<p className="text-sm text-kaspersky-text-light mt-1">
{error}
</p>
</div>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username Field */}
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-kaspersky-text-dark mb-2"
>
Username
</label>
<div className={`relative transition-all duration-300 ${
focusedField === 'username' ? 'transform scale-[1.02]' : ''
}`}>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User
size={18}
className={`transition-colors duration-300 ${
focusedField === 'username' ? 'text-kaspersky-primary' : 'text-kaspersky-text-lighter'
}`}
/>
</div>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onFocus={() => setFocusedField('username')}
onBlur={() => setFocusedField(null)}
className={`w-full pl-10 pr-4 py-3 border-2 rounded-lg font-kaspersky
transition-all duration-300 outline-none
${focusedField === 'username'
? 'border-kaspersky-primary bg-kaspersky-bg-card shadow-kaspersky'
: 'border-kaspersky-border bg-kaspersky-bg-card hover:border-kaspersky-border-dark'
}
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100
`}
placeholder="Enter your username"
required
disabled={isLoading}
autoFocus
autoComplete="username"
/>
</div>
</div>
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-kaspersky-text-dark mb-2"
>
Password
</label>
<div className={`relative transition-all duration-300 ${
focusedField === 'password' ? 'transform scale-[1.02]' : ''
}`}>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock
size={18}
className={`transition-colors duration-300 ${
focusedField === 'password' ? 'text-kaspersky-primary' : 'text-kaspersky-text-lighter'
}`}
/>
</div>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setFocusedField('password')}
onBlur={() => setFocusedField(null)}
className={`w-full pl-10 pr-4 py-3 border-2 rounded-lg font-kaspersky
transition-all duration-300 outline-none
${focusedField === 'password'
? 'border-kaspersky-primary bg-kaspersky-bg-card shadow-kaspersky'
: 'border-kaspersky-border bg-kaspersky-bg-card hover:border-kaspersky-border-dark'
}
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100
`}
placeholder="Enter your password"
required
disabled={isLoading}
autoComplete="current-password"
/>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading || !username || !password}
className={`
w-full py-3 px-6 rounded-lg font-semibold text-white
transition-all duration-300 transform
${isLoading || !username || !password
? 'bg-kaspersky-text-lighter cursor-not-allowed opacity-60'
: 'bg-kaspersky-primary hover:bg-kaspersky-primary-dark hover:shadow-kaspersky-lg active:scale-[0.98]'
}
disabled:transform-none
focus:outline-none focus:ring-4 focus:ring-kaspersky-primary/30
`}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Authenticating...
</span>
) : (
'Sign In'
)}
</button>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white/95 backdrop-blur-sm text-gray-500">or</span>
</div>
</div>
{/* ADFS Button (Coming Soon) */}
<div className="relative">
<button
type="button"
disabled
className="w-full py-3 px-6 rounded-lg font-semibold text-gray-500 bg-gray-200 cursor-not-allowed opacity-60 flex items-center justify-center gap-3"
>
<Building2 size={20} className="text-gray-500" />
<span>Sign in with ADFS</span>
</button>
{/* Бейдж SOON */}
<span className="absolute -top-1 -right-1 bg-gradient-to-r from-kaspersky-primary to-kaspersky-danger text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wide shadow-sm">
Soon
</span>
</div>
</form>
</div>
</div>
</div>
);
};
export default LoginScreen;

View File

@ -0,0 +1,332 @@
import React, { useState, useMemo, useEffect } from 'react';
import { X, Info, AlertTriangle, CheckCircle, LogIn } from 'lucide-react';
import { Machine } from '../types';
import { ProtocolHelper, ProtocolOption, OSInfo } from '../utils/protocolHelper';
import { InputSanitizer } from '../utils/sanitizer';
import { Button } from './shared/Button';
interface MachineCredentialsModalProps {
isOpen: boolean;
machine: Machine | null;
onClose: () => void;
onSubmit: (
username: string,
password: string,
protocol: string,
sftpOptions?: {
enableSftp: boolean;
sftpRootDirectory: string;
}
) => Promise<void>;
}
const MachineCredentialsModal: React.FC<MachineCredentialsModalProps> = ({
isOpen,
machine,
onClose,
onSubmit
}) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [protocol, setProtocol] = useState('');
const [port, setPort] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// SFTP parameters for SSH
const [enableSftp, setEnableSftp] = useState(true);
const [sftpRootDirectory, setSftpRootDirectory] = useState('/');
// Parse machine OS and get recommended protocols
const osInfo: OSInfo = useMemo(() => {
return machine ? ProtocolHelper.parseOS(machine.os) : { family: 'unknown' };
}, [machine?.os]);
const availableProtocols: ProtocolOption[] = useMemo(() => {
return ProtocolHelper.getRecommendedProtocols(osInfo);
}, [osInfo]);
const compatibilityInfo = useMemo(() => {
return ProtocolHelper.getCompatibilityInfo(osInfo);
}, [osInfo]);
const selectedProtocolInfo = useMemo(() => {
return availableProtocols.find(p => p.value === protocol);
}, [protocol, availableProtocols]);
// Set default protocol and port when machine changes
useEffect(() => {
if (machine && availableProtocols.length > 0) {
const defaultProtocol = ProtocolHelper.getDefaultProtocol(osInfo);
setProtocol(defaultProtocol.value);
setPort(defaultProtocol.defaultPort.toString());
}
}, [machine, osInfo, availableProtocols]);
// Update port when protocol changes
useEffect(() => {
if (selectedProtocolInfo) {
setPort(selectedProtocolInfo.defaultPort.toString());
}
}, [selectedProtocolInfo]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
// Санитизируем данные перед отправкой
const sanitizedUsername = InputSanitizer.sanitizeUsername(username);
InputSanitizer.validatePassword(password);
const sanitizedProtocol = InputSanitizer.sanitizeProtocol(protocol);
const portNumber = InputSanitizer.validatePort(port);
// Валидируем порт для выбранного протокола
const portValidation = ProtocolHelper.validatePort(protocol, portNumber);
if (!portValidation.valid) {
throw new Error('Invalid port for selected protocol');
}
// Передаем SFTP параметры, если протокол SSH
const sftpOptions = protocol === 'ssh' ? {
enableSftp,
sftpRootDirectory: sftpRootDirectory.trim() || '/'
} : undefined;
await onSubmit(sanitizedUsername, password, sanitizedProtocol, sftpOptions);
// Clear form after successful connection
setUsername('');
setPassword('');
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection failed');
} finally {
setIsLoading(false);
}
};
if (!isOpen || !machine) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 backdrop-blur-sm">
<div className="bg-kaspersky-bg-card rounded-lg p-6 w-full max-w-md shadow-2xl border border-kaspersky-border select-none">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-kaspersky-text-dark">
Connect to {machine.name}
</h2>
<button
onClick={onClose}
className="text-kaspersky-text-light hover:text-kaspersky-danger transition-colors p-1 rounded-lg hover:bg-kaspersky-bg"
disabled={isLoading}
>
<X size={24} />
</button>
</div>
<div className="mb-4 p-4 bg-kaspersky-bg rounded-lg border border-kaspersky-border">
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl">{osInfo.family === 'windows' ? '🖥️' : osInfo.family === 'linux' ? '🐧' : osInfo.family === 'macos' ? '🍎' : '💻'}</span>
<div>
<p className="text-sm font-semibold text-kaspersky-text-dark">{machine.name}</p>
<p className="text-xs text-kaspersky-text-light">{machine.ip} {machine.os}</p>
</div>
</div>
{/* Compatibility info */}
{compatibilityInfo.recommendations.length > 0 && (
<div className="mt-3 p-3 bg-kaspersky-primary bg-opacity-10 border border-kaspersky-primary border-opacity-30 rounded-lg text-xs">
<div className="flex items-center gap-1.5 mb-1">
<CheckCircle size={14} className="text-kaspersky-primary" />
<span className="font-semibold text-kaspersky-primary">Recommendation</span>
</div>
<p className="text-kaspersky-text-dark">{compatibilityInfo.recommendations[0]}</p>
</div>
)}
{compatibilityInfo.warnings.length > 0 && (
<div className="mt-2 p-3 bg-kaspersky-warning bg-opacity-10 border border-kaspersky-warning border-opacity-30 rounded-lg text-xs">
<div className="flex items-center gap-1.5 mb-1">
<AlertTriangle size={14} className="text-kaspersky-warning" />
<span className="font-semibold text-kaspersky-warning">Warning</span>
</div>
<p className="text-kaspersky-text-dark">{compatibilityInfo.warnings[0]}</p>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="protocol" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Connection Protocol
</label>
<select
id="protocol"
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
disabled={isLoading}
>
{availableProtocols.map((protocolOption) => (
<option key={protocolOption.value} value={protocolOption.value}>
{protocolOption.icon} {protocolOption.label}
</option>
))}
</select>
{/* Protocol info */}
{selectedProtocolInfo && (
<div className="mt-2 p-3 bg-kaspersky-bg border border-kaspersky-border rounded-lg text-xs">
<div className="flex items-center gap-1.5 mb-1">
<Info size={14} className="text-kaspersky-primary" />
<span className="font-semibold text-kaspersky-text-dark">Protocol Info</span>
</div>
<p className="text-kaspersky-text mb-1">{selectedProtocolInfo.description}</p>
{selectedProtocolInfo.requirements && (
<div>
<span className="font-semibold text-kaspersky-text-dark">Requirements:</span>
<ul className="list-disc list-inside text-kaspersky-text mt-1">
{selectedProtocolInfo.requirements.map((req, index) => (
<li key={index}>{req}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
<div>
<label htmlFor="port" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Port
</label>
<input
type="number"
id="port"
value={port}
onChange={(e) => setPort(e.target.value)}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
required
disabled={isLoading}
min="1"
max="65535"
placeholder={selectedProtocolInfo?.defaultPort.toString()}
/>
{selectedProtocolInfo && (
<p className="text-xs text-kaspersky-text-light mt-1">
Default port for {selectedProtocolInfo.label}: {selectedProtocolInfo.defaultPort}
</p>
)}
</div>
<div>
<label htmlFor="machine-username" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Machine Username
</label>
<input
type="text"
id="machine-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
required
disabled={isLoading}
autoFocus
placeholder="Administrator"
/>
</div>
<div>
<label htmlFor="machine-password" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
Machine Password
</label>
<input
type="password"
id="machine-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
required
disabled={isLoading}
placeholder="••••••••"
/>
</div>
{/* SFTP параметры - показываем только для SSH */}
{protocol === 'ssh' && (
<div className="space-y-3 p-4 bg-kaspersky-primary bg-opacity-10 border border-kaspersky-primary border-opacity-30 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-kaspersky-primary">📁 SFTP File Transfer</span>
</div>
<div className="flex items-center gap-3 p-3 bg-kaspersky-bg-card rounded-lg border border-kaspersky-border">
<input
type="checkbox"
id="enable-sftp"
checked={enableSftp}
onChange={(e) => setEnableSftp(e.target.checked)}
className="w-5 h-5 text-kaspersky-primary bg-white border-2 border-kaspersky-border rounded focus:ring-2 focus:ring-kaspersky-primary cursor-pointer"
disabled={isLoading}
/>
<label htmlFor="enable-sftp" className="text-sm font-medium text-kaspersky-text-dark cursor-pointer flex-1">
Enable file browser (download/upload with drag'n'drop)
</label>
</div>
{enableSftp && (
<div>
<label htmlFor="sftp-root-directory" className="block text-xs font-semibold text-kaspersky-text-dark mb-2">
Root Directory (optional)
</label>
<input
type="text"
id="sftp-root-directory"
value={sftpRootDirectory}
onChange={(e) => setSftpRootDirectory(e.target.value)}
className="w-full px-3 py-2 text-sm bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
disabled={isLoading}
placeholder="/"
/>
<p className="text-xs text-kaspersky-text-light mt-1">
Restrict SFTP access to this directory (default: /)
</p>
</div>
)}
</div>
)}
{error && (
<div className="bg-kaspersky-danger bg-opacity-10 border border-kaspersky-danger text-kaspersky-danger px-4 py-3 rounded-lg text-sm font-medium animate-shake">
{error}
</div>
)}
<div className="flex gap-3 pt-2">
<Button
type="submit"
variant="primary"
fullWidth
disabled={isLoading}
isLoading={isLoading}
leftIcon={!isLoading ? <LogIn size={18} /> : undefined}
>
{isLoading ? 'Connecting...' : 'Connect'}
</Button>
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={isLoading}
leftIcon={<X size={18} />}
>
Cancel
</Button>
</div>
</form>
</div>
</div>
);
};
export default MachineCredentialsModal;

View File

@ -0,0 +1,352 @@
import { Machine } from '../types'
import { Trash2, Clock } from 'lucide-react'
import { useMachineStore } from '../store/machineStore'
import { useState, useEffect } from 'react'
import { log } from '../utils/logger'
interface MachineDetailsProps {
machine: Machine;
machineStatus?: Machine['status'];
isConnected?: boolean;
connectionInfo?: {
connectionId: string;
connectedAt: Date;
expiresAt: Date;
};
onDelete?: () => void;
onExtendConnection?: (additionalMinutes?: number) => Promise<void>;
}
function MachineDetails({ machine, machineStatus, isConnected = false, connectionInfo, onDelete, onExtendConnection }: MachineDetailsProps) {
// Use passed status or fallback to machine status
const currentStatus = machineStatus || machine.status;
const { deleteSavedMachine } = useMachineStore();
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Connection timer state (updates every second)
const [connectionDuration, setConnectionDuration] = useState<string>('');
// Time until TTL expiry state
const [timeUntilExpiry, setTimeUntilExpiry] = useState<{ minutes: number; isWarning: boolean }>({
minutes: 0,
isWarning: false,
});
// Extension process state
const [isExtending, setIsExtending] = useState(false);
// Determine if machine is "saved" (not mock data)
const isSavedMachine = machine.hypervisor === 'Saved';
// Update connection timer every second
useEffect(() => {
if (!isConnected || !connectionInfo) {
setConnectionDuration('');
return;
}
const updateDuration = () => {
const now = Date.now();
const connectedAt = connectionInfo.connectedAt.getTime();
const diffMs = now - connectedAt;
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours > 0) {
setConnectionDuration(`${diffHours}h ${diffMinutes % 60}m`);
} else if (diffMinutes > 0) {
setConnectionDuration(`${diffMinutes}m ${diffSeconds % 60}s`);
} else if (diffSeconds > 5) {
setConnectionDuration(`${diffSeconds}s`);
} else {
setConnectionDuration('just now');
}
};
// Update immediately
updateDuration();
// And every second
const interval = setInterval(updateDuration, 1000);
return () => clearInterval(interval);
}, [isConnected, connectionInfo]);
// Track time until TTL expiry
useEffect(() => {
if (!isConnected || !connectionInfo?.expiresAt) {
setTimeUntilExpiry({ minutes: 0, isWarning: false });
return;
}
const updateTimeUntilExpiry = () => {
const now = Date.now();
const expiresAt = connectionInfo.expiresAt.getTime();
const diffMs = expiresAt - now;
const minutesRemaining = Math.floor(diffMs / 60000);
setTimeUntilExpiry({
minutes: minutesRemaining,
isWarning: minutesRemaining <= 10 && minutesRemaining > 0, // Warning if 10 minutes or less remaining
});
};
// Update immediately
updateTimeUntilExpiry();
// And every 30 seconds (doesn't need to be as frequent as duration)
const interval = setInterval(updateTimeUntilExpiry, 30000);
return () => clearInterval(interval);
}, [isConnected, connectionInfo]);
// Connection extension handler
const handleExtend = async () => {
if (!onExtendConnection || isExtending) return;
setIsExtending(true);
try {
await onExtendConnection(60); // Extend by 60 minutes by default
log.info('machine-details', 'Connection extended successfully');
} catch (error) {
log.error('machine-details', 'Failed to extend connection', { error });
} finally {
setIsExtending(false);
}
};
const handleDelete = async () => {
if (!isSavedMachine) return;
setIsDeleting(true);
try {
log.info('machine-details', 'Deleting saved machine', { machineId: machine.id });
await deleteSavedMachine(machine.id);
log.info('machine-details', 'Saved machine deleted successfully');
setShowDeleteConfirm(false);
if (onDelete) {
onDelete();
}
} catch (error) {
log.error('machine-details', 'Failed to delete machine', { error });
} finally {
setIsDeleting(false);
}
};
return (
<div className="flex-1 p-6 overflow-auto select-none">
<div className="bg-kaspersky-bg-card rounded-lg p-6 shadow-kaspersky">
<div className="mb-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-semibold text-kaspersky-text-dark break-words">{machine.name}</h1>
<div className="flex items-center mt-2 gap-4 flex-wrap">
{/* If connected - show only Connected block with status indicator color */}
{isConnected && connectionInfo ? (
<>
<div className="flex items-center px-3 py-1.5 bg-kaspersky-primary bg-opacity-10 border border-kaspersky-primary border-opacity-30 rounded-lg">
{/* Indicator color reflects machine availability status */}
<div
className={`w-2.5 h-2.5 rounded-full mr-2 shadow-sm ${
currentStatus === 'running' ? 'bg-kaspersky-success animate-pulse' :
currentStatus === 'stopped' ? 'bg-kaspersky-danger' :
currentStatus === 'checking' ? 'bg-kaspersky-warning animate-pulse' :
currentStatus === 'unknown' ? 'bg-gray-400' :
'bg-kaspersky-primary animate-pulse'
}`}
/>
<span className="text-xs text-kaspersky-primary font-semibold">
Connected {connectionDuration ? `(${connectionDuration})` : ''}
</span>
</div>
{/* Кнопка продления - показываем всегда когда подключены */}
{onExtendConnection && (
<button
onClick={handleExtend}
disabled={isExtending}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all duration-200 ${
timeUntilExpiry.isWarning
? 'bg-kaspersky-warning bg-opacity-10 border border-kaspersky-warning border-opacity-30 text-kaspersky-warning hover:bg-opacity-20 animate-pulse'
: 'bg-kaspersky-success bg-opacity-10 border border-kaspersky-success border-opacity-30 text-kaspersky-success hover:bg-opacity-20'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={`Extend connection by 60 minutes (${timeUntilExpiry.minutes}m remaining)`}
>
<Clock className="w-3.5 h-3.5" />
<span className="text-xs font-semibold">
{isExtending ? 'Extending...' : `Extend (${timeUntilExpiry.minutes}m left)`}
</span>
</button>
)}
</>
) : (
/* If not connected - show regular availability status */
<div className="flex items-center">
<div
className={`w-3 h-3 rounded-full mr-2 shadow-sm ${
currentStatus === 'running' ? 'bg-kaspersky-success' :
currentStatus === 'stopped' ? 'bg-kaspersky-danger' :
currentStatus === 'checking' ? 'bg-kaspersky-warning animate-pulse' :
currentStatus === 'unknown' ? 'bg-gray-400' :
'bg-kaspersky-warning'
}`}
/>
<span className="text-sm text-kaspersky-text capitalize font-medium">
{currentStatus === 'checking' ? 'Checking...' : currentStatus}
</span>
</div>
)}
</div>
</div>
{/* Delete Button для сохраненных машин */}
{isSavedMachine && (
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting || isConnected}
className="flex-shrink-0 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title={isConnected ? "Disconnect first to delete" : "Delete saved machine"}
>
<Trash2 size={18} />
<span className="text-sm font-medium">Delete</span>
</button>
)}
</div>
</div>
<div className={isSavedMachine ? "flex flex-col gap-6" : "grid grid-cols-2 gap-6"}>
<div>
<h2 className="text-lg font-semibold mb-4 text-kaspersky-primary">System Information</h2>
<div className="space-y-3">
<div>
<label className="text-sm text-kaspersky-text-light font-medium">Operating System</label>
<div className="text-kaspersky-text-dark font-medium">{machine.os}</div>
</div>
<div>
<label className="text-sm text-kaspersky-text-light font-medium">Hostname</label>
<div className="text-kaspersky-text-dark font-medium">{machine.ip}</div>
</div>
{machine.port && (
<div>
<label className="text-sm text-kaspersky-text-light font-medium">Port</label>
<div className="text-kaspersky-text-dark font-medium">{machine.port}</div>
</div>
)}
{machine.protocol && (
<div>
<label className="text-sm text-kaspersky-text-light font-medium">Protocol</label>
<div className="text-kaspersky-text-dark font-medium">{machine.protocol.toUpperCase()}</div>
</div>
)}
{/* Показываем Hypervisor только для НЕ сохраненных машин */}
{!isSavedMachine && (
<div>
<label className="text-sm text-kaspersky-text-light font-medium">Hypervisor</label>
<div className="text-kaspersky-text-dark font-medium">{machine.hypervisor}</div>
</div>
)}
</div>
</div>
{/* Показываем Hardware Specifications только для НЕ сохраненных машин */}
{!isSavedMachine && (
<div>
<h2 className="text-lg font-semibold mb-4 text-kaspersky-primary">Hardware Specifications</h2>
<div className="space-y-3">
<div>
<label className="text-sm text-kaspersky-text-light font-medium">CPU</label>
<div className="text-kaspersky-text-dark font-medium">{machine.specs.cpu}</div>
</div>
<div>
<label className="text-sm text-kaspersky-text-light font-medium">Memory</label>
<div className="text-kaspersky-text-dark font-medium">{machine.specs.ram}</div>
</div>
<div>
<label className="text-sm text-kaspersky-text-light font-medium">Storage</label>
<div className="text-kaspersky-text-dark font-medium">{machine.specs.disk}</div>
</div>
</div>
</div>
)}
</div>
<div className="mt-6">
<h2 className="text-lg font-medium mb-4 text-kaspersky-primary">Test Links</h2>
<div className="flex flex-wrap gap-2">
{machine.testLinks?.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-block px-3 py-1 bg-kaspersky-primary text-white rounded-lg hover:bg-kaspersky-primary-dark hover:shadow-kaspersky transition-all duration-300"
>
{link.name}
</a>
))}
</div>
</div>
<div className="mt-6">
<h2 className="text-lg font-semibold mb-4 text-kaspersky-primary">Execution Log</h2>
<div className="bg-kaspersky-bg rounded-lg p-4 font-mono text-sm max-h-48 overflow-auto border border-kaspersky-border">
{machine.logs?.map((log, index) => (
<div key={index} className="text-kaspersky-text mb-1">
<span className="text-kaspersky-text-light">[{new Date(log.timestamp).toLocaleString()}]</span>
<span className={`ml-2 font-medium ${
log.level === 'error' ? 'text-kaspersky-danger' :
log.level === 'warn' ? 'text-kaspersky-warning' :
'text-kaspersky-text-dark'
}`}>
[{log.level.toUpperCase()}] {log.message}
</span>
</div>
))}
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-kaspersky-bg-card rounded-lg p-6 max-w-md w-full shadow-2xl border border-kaspersky-border">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-red-600 bg-opacity-20 rounded-full">
<Trash2 size={24} className="text-red-500" />
</div>
<h3 className="text-xl font-semibold text-kaspersky-text-dark">Delete Machine</h3>
</div>
<p className="text-kaspersky-text mb-6">
Are you sure you want to delete <strong className="text-kaspersky-text-dark">{machine.name}</strong>?
This action cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
className="flex-1 px-4 py-2 bg-kaspersky-bg hover:bg-kaspersky-border text-kaspersky-text-dark rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed font-medium border border-kaspersky-border"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default MachineDetails

View File

@ -0,0 +1,216 @@
import React, { useState, useMemo } from 'react';
import { MoreVertical, Square, RotateCcw, Settings, Info, Wifi, Terminal, Monitor } from 'lucide-react';
import { Machine } from '../types';
import { ProtocolHelper, ProtocolOption, OSInfo } from '../utils/protocolHelper';
interface QuickActionsMenuProps {
machine: Machine;
isConnected?: boolean;
onConnect: (protocol: ProtocolOption) => void;
onDisconnect?: () => void;
onRestart?: () => void;
onPowerOff?: () => void;
onSettings?: () => void;
onInfo?: () => void;
className?: string;
}
const QuickActionsMenu: React.FC<QuickActionsMenuProps> = ({
machine,
isConnected = false,
onConnect,
onDisconnect,
onRestart,
onPowerOff,
onSettings,
onInfo,
className = ''
}) => {
const [isOpen, setIsOpen] = useState(false);
const osInfo: OSInfo = useMemo(() => {
return ProtocolHelper.parseOS(machine.os);
}, [machine.os]);
const availableProtocols = useMemo(() => {
return ProtocolHelper.getRecommendedProtocols(osInfo);
}, [osInfo]);
const isRunning = machine.status === 'running';
const handleActionClick = (action: () => void) => {
action();
setIsOpen(false);
};
const handleProtocolConnect = (protocol: ProtocolOption) => {
onConnect(protocol);
setIsOpen(false);
};
// Close menu on outside click
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('.quick-actions-menu')) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
return (
<div className={`relative quick-actions-menu ${className}`}>
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
title="Quick Actions"
>
<MoreVertical size={16} />
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-50 overflow-hidden">
{/* Connection Actions */}
{!isConnected && isRunning && (
<div className="border-b border-gray-100">
<div className="px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
Connect via
</div>
{availableProtocols.map((protocol) => (
<button
key={protocol.value}
onClick={() => handleProtocolConnect(protocol)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
>
<span className="text-lg">{protocol.icon}</span>
<div className="font-medium text-gray-900">{protocol.label}</div>
</button>
))}
</div>
)}
{/* Disconnect Action */}
{isConnected && onDisconnect && (
<div className="border-b border-gray-100">
<button
onClick={() => handleActionClick(onDisconnect)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3 text-red-600"
>
<Wifi size={16} />
<span>Disconnect</span>
</button>
</div>
)}
{/* Quick Actions */}
<div className="border-b border-gray-100">
<div className="px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
Quick Actions
</div>
{/* Remote Desktop shortcut */}
{osInfo.family === 'windows' && (
<button
onClick={() => handleProtocolConnect(availableProtocols.find(p => p.value === 'rdp')!)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
disabled={!isRunning}
>
<Monitor size={16} className={isRunning ? 'text-blue-600' : 'text-gray-400'} />
<span className={isRunning ? 'text-gray-900' : 'text-gray-400'}>
Remote Desktop
</span>
</button>
)}
{/* SSH Terminal shortcut */}
{['linux', 'macos', 'unix'].includes(osInfo.family) && (
<button
onClick={() => handleProtocolConnect(availableProtocols.find(p => p.value === 'ssh')!)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
disabled={!isRunning}
>
<Terminal size={16} className={isRunning ? 'text-green-600' : 'text-gray-400'} />
<span className={isRunning ? 'text-gray-900' : 'text-gray-400'}>
SSH Terminal
</span>
</button>
)}
</div>
{/* Power Management */}
<div className="border-b border-gray-100">
<div className="px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
Power Management
</div>
{onRestart && (
<button
onClick={() => handleActionClick(onRestart)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
disabled={!isRunning}
>
<RotateCcw size={16} className={isRunning ? 'text-orange-600' : 'text-gray-400'} />
<span className={isRunning ? 'text-gray-900' : 'text-gray-400'}>
Restart
</span>
</button>
)}
{onPowerOff && (
<button
onClick={() => handleActionClick(onPowerOff)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
disabled={!isRunning}
>
<Square size={16} className={isRunning ? 'text-red-600' : 'text-gray-400'} />
<span className={isRunning ? 'text-gray-900' : 'text-gray-400'}>
Power Off
</span>
</button>
)}
</div>
{/* Settings and Info */}
<div>
{onSettings && (
<button
onClick={() => handleActionClick(onSettings)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
>
<Settings size={16} className="text-gray-600" />
<span>Settings</span>
</button>
)}
{onInfo && (
<button
onClick={() => handleActionClick(onInfo)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
>
<Info size={16} className="text-blue-600" />
<span>Machine Info</span>
</button>
)}
</div>
{/* Machine status footer */}
<div className="bg-gray-50 px-3 py-2 text-xs text-gray-500">
Status: <span className={`font-medium ${
machine.status === 'running' ? 'text-green-600' :
machine.status === 'stopped' ? 'text-red-600' : 'text-yellow-600'
}`}>
{machine.status}
</span>
</div>
</div>
)}
</div>
);
};
export default QuickActionsMenu;

View File

@ -0,0 +1,253 @@
import React, { useState, useEffect } from 'react';
import { X, Monitor, Clock, RefreshCw, XCircle, CheckSquare, Square } from 'lucide-react';
import type { ActiveConnection } from '../services/guacamole-api';
interface RestoreConnectionsModalProps {
isOpen: boolean;
connections: ActiveConnection[];
onRestore: (connection: ActiveConnection) => void;
onRestoreMultiple: (connections: ActiveConnection[]) => void;
onClose: () => void;
onSkip: () => void;
}
const RestoreConnectionsModal: React.FC<RestoreConnectionsModalProps> = ({
isOpen,
connections,
onRestore,
onRestoreMultiple,
onClose,
onSkip
}) => {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Reset selection when modal closes
useEffect(() => {
if (!isOpen) {
setSelectedIds(new Set());
}
}, [isOpen]);
if (!isOpen) return null;
const toggleSelection = (connectionId: string) => {
setSelectedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(connectionId)) {
newSet.delete(connectionId);
} else {
newSet.add(connectionId);
}
return newSet;
});
};
const selectAll = () => {
setSelectedIds(new Set(connections.map(c => c.connection_id)));
};
const deselectAll = () => {
setSelectedIds(new Set());
};
const handleRestoreSelected = () => {
const selectedConnections = connections.filter(c => selectedIds.has(c.connection_id));
if (selectedConnections.length > 0) {
onRestoreMultiple(selectedConnections);
setSelectedIds(new Set());
}
};
const handleRestoreAll = () => {
onRestoreMultiple(connections);
setSelectedIds(new Set());
};
const getProtocolColor = (protocol: string) => {
switch (protocol.toLowerCase()) {
case 'rdp':
return 'bg-blue-100 text-blue-700 border-blue-200';
case 'ssh':
return 'bg-green-100 text-green-700 border-green-200';
case 'vnc':
return 'bg-purple-100 text-purple-700 border-purple-200';
default:
return 'bg-gray-100 text-gray-700 border-gray-200';
}
};
const formatTimeRemaining = (minutes: number): string => {
if (minutes < 60) {
return `${minutes} мин`;
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}ч ${mins}м` : `${hours}ч`;
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col select-none">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-kaspersky-primary/10 rounded-xl flex items-center justify-center">
<RefreshCw className="text-kaspersky-primary" size={24} />
</div>
<div>
<h2 className="text-2xl font-bold text-kaspersky-text-dark">
Восстановить подключения
</h2>
<p className="text-sm text-kaspersky-text-light mt-1">
У вас есть {connections.length} активных {connections.length === 1 ? 'подключение' : 'подключения'}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-gray-100 rounded-lg"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-kaspersky-text">
Выберите подключения для восстановления:
</p>
<div className="flex gap-2">
<button
onClick={selectAll}
className="text-xs text-kaspersky-primary hover:text-kaspersky-primary-dark font-medium transition-colors"
>
Выбрать все
</button>
<span className="text-gray-300">|</span>
<button
onClick={deselectAll}
className="text-xs text-kaspersky-text-light hover:text-kaspersky-text font-medium transition-colors"
>
Снять выбор
</button>
</div>
</div>
<div className="space-y-3">
{connections.map((connection) => {
const isSelected = selectedIds.has(connection.connection_id);
return (
<div
key={connection.connection_id}
className={`border-2 rounded-xl p-4 transition-all duration-200 hover:shadow-md cursor-pointer group ${
isSelected
? 'border-kaspersky-primary bg-kaspersky-primary/5'
: 'border-kaspersky-border hover:border-kaspersky-primary'
}`}
onClick={() => toggleSelection(connection.connection_id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
{/* Чекбокс */}
<div className="flex-shrink-0 pt-1">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
isSelected
? 'bg-kaspersky-primary border-kaspersky-primary'
: 'border-gray-300 group-hover:border-kaspersky-primary'
}`}>
{isSelected ? (
<CheckSquare size={16} className="text-white" strokeWidth={3} />
) : (
<Square size={16} className="text-transparent" />
)}
</div>
</div>
<div className="w-10 h-10 bg-kaspersky-bg rounded-lg flex items-center justify-center flex-shrink-0 group-hover:bg-kaspersky-primary/10 transition-colors">
<Monitor className="text-kaspersky-primary" size={20} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-kaspersky-text-dark text-lg mb-1 truncate">
{connection.hostname}
</h3>
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded-md text-xs font-medium border ${getProtocolColor(connection.protocol)}`}>
{connection.protocol.toUpperCase()}
</span>
<span className="text-xs text-kaspersky-text-light flex items-center gap-1">
<Clock size={12} />
Осталось: {formatTimeRemaining(connection.remaining_minutes)}
</span>
</div>
<p className="text-xs text-kaspersky-text-lighter">
Создано: {new Date(connection.created_at).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRestore(connection);
}}
className="ml-4 px-4 py-2 bg-kaspersky-primary hover:bg-kaspersky-primary-dark text-white rounded-lg transition-colors font-medium text-sm flex items-center gap-2 whitespace-nowrap"
>
<RefreshCw size={16} />
Восстановить
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 p-6 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-kaspersky-text-light">
{selectedIds.size > 0
? `Выбрано: ${selectedIds.size} из ${connections.length}`
: 'Выберите подключения или создайте новое'
}
</p>
<div className="flex gap-3">
<button
onClick={onSkip}
className="px-6 py-2.5 bg-white border-2 border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium flex items-center gap-2"
>
<XCircle size={18} />
Создать новое
</button>
<button
onClick={handleRestoreAll}
className="px-6 py-2.5 bg-kaspersky-secondary hover:bg-kaspersky-secondary/90 text-white rounded-lg transition-colors font-medium flex items-center gap-2"
>
<RefreshCw size={18} />
Восстановить всё
</button>
<button
onClick={handleRestoreSelected}
disabled={selectedIds.size === 0}
className={`px-6 py-2.5 rounded-lg transition-colors font-medium flex items-center gap-2 ${
selectedIds.size === 0
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-kaspersky-primary hover:bg-kaspersky-primary-dark text-white'
}`}
>
<RefreshCw size={18} />
Восстановить выбранные
</button>
</div>
</div>
</div>
</div>
);
};
export default RestoreConnectionsModal;

View File

@ -0,0 +1,176 @@
/**
* Machine save confirmation modal
*/
import React, { useState } from 'react';
import { Save, X } from 'lucide-react';
import { useMachineStore } from '../store/machineStore';
import { log } from '../utils/logger';
import { Button } from './shared/Button';
interface SaveConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const SaveConfirmationModal: React.FC<SaveConfirmationModalProps> = ({
isOpen,
onClose,
onSuccess
}) => {
const {
machineToSave,
createSavedMachine,
setMachineToSave,
setShowSaveConfirmModal
} = useMachineStore();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleConfirm = async () => {
if (!machineToSave) {
setError('Нет данных для сохранения');
return;
}
setLoading(true);
setError(null);
try {
log.info('SaveConfirmationModal', 'Saving machine', { name: machineToSave.name });
await createSavedMachine(machineToSave);
log.info('SaveConfirmationModal', 'Machine saved successfully');
// Сбрасываем состояние
setMachineToSave(null);
setShowSaveConfirmModal(false);
if (onSuccess) {
onSuccess();
}
onClose();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Не удалось сохранить машину';
log.error('SaveConfirmationModal', 'Failed to save machine', { error: errorMessage });
setError(errorMessage);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
setMachineToSave(null);
setShowSaveConfirmModal(false);
setError(null);
onClose();
};
if (!isOpen || !machineToSave) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-md w-full select-none">
<div className="p-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="flex-shrink-0 w-12 h-12 bg-kaspersky-primary bg-opacity-10 rounded-full flex items-center justify-center border-2 border-kaspersky-primary border-opacity-30">
<Save className="w-6 h-6 text-kaspersky-primary" />
</div>
<div>
<h2 className="text-xl font-bold text-kaspersky-text-dark">Сохранить машину?</h2>
<p className="text-sm text-kaspersky-text-light">Подтвердите сохранение в профиль</p>
</div>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-kaspersky-danger bg-opacity-10 border border-kaspersky-danger rounded text-kaspersky-danger text-sm">
{error}
</div>
)}
{/* Machine Info */}
<div className="mb-6 p-4 bg-kaspersky-bg rounded-lg border border-kaspersky-border space-y-3">
<div className="flex justify-between items-center">
<span className="text-kaspersky-text-light text-sm font-medium">Название:</span>
<span className="text-kaspersky-text-dark font-semibold">{machineToSave.name}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-kaspersky-text-light text-sm font-medium">Хост:</span>
<span className="text-kaspersky-text-dark font-mono text-sm">{machineToSave.hostname}:{machineToSave.port}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-kaspersky-text-light text-sm font-medium">Протокол:</span>
<span className="text-kaspersky-primary uppercase text-sm font-bold">{machineToSave.protocol}</span>
</div>
{machineToSave.description && (
<div className="pt-3 border-t border-kaspersky-border">
<span className="text-kaspersky-text-light text-sm font-medium block mb-1">Описание:</span>
<span className="text-kaspersky-text text-sm">{machineToSave.description}</span>
</div>
)}
{machineToSave.tags && machineToSave.tags.length > 0 && (
<div className="pt-3 border-t border-kaspersky-border">
<span className="text-kaspersky-text-light text-sm font-medium block mb-2">Теги:</span>
<div className="flex flex-wrap gap-1.5">
{machineToSave.tags.map((tag, index) => (
<span
key={index}
className="px-2.5 py-1 bg-kaspersky-primary bg-opacity-20 text-kaspersky-primary rounded-md text-xs font-medium border border-kaspersky-primary border-opacity-30"
>
{tag}
</span>
))}
</div>
</div>
)}
{machineToSave.is_favorite && (
<div className="pt-3 border-t border-kaspersky-border flex items-center gap-2">
<svg className="w-5 h-5 text-kaspersky-warning" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span className="text-kaspersky-warning text-sm font-semibold">В избранном</span>
</div>
)}
</div>
{/* Description */}
<div className="mb-6 p-3 bg-kaspersky-primary bg-opacity-5 rounded-lg border border-kaspersky-primary border-opacity-20">
<p className="text-kaspersky-text text-sm">
💾 Машина будет сохранена в вашем профиле и доступна из любой сессии.
Учетные данные для подключения будут запрашиваться отдельно при каждом подключении.
</p>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
onClick={handleConfirm}
disabled={loading}
variant="primary"
fullWidth
isLoading={loading}
leftIcon={!loading ? <Save size={18} /> : undefined}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button
onClick={handleCancel}
disabled={loading}
variant="secondary"
leftIcon={<X size={18} />}
>
Отмена
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,315 @@
import { useEffect, memo, useMemo, useCallback, useState } from 'react'
import { Search, Plus } from 'lucide-react'
import { log } from '../utils/logger'
import { useMachineStore } from '../store/machineStore'
import SmartConnectionButton from './SmartConnectionButton'
import { ProtocolOption } from '../utils/protocolHelper'
import { Machine } from '../types'
import { Button } from './shared/Button'
interface SidebarProps {
onConnect?: (machine: Machine, protocol?: ProtocolOption) => void;
machineStatuses?: Map<string, Machine['status']>;
onMachineSelected?: (machine: Machine) => void;
machineConnections?: Map<string, any>;
userRole?: string;
onBulkSelectionChange?: (selectedMachineIds: string[]) => void;
}
function Sidebar({ onConnect, machineStatuses, onMachineSelected, machineConnections, userRole, onBulkSelectionChange }: SidebarProps = {}) {
const {
machines,
selectedMachine,
selectMachine,
fetchMachines,
loading,
searchQuery,
setSearchQuery,
filteredMachines,
setShowAddMachineModal
} = useMachineStore();
// ========================================================================================
// Bulk Selection State
// ========================================================================================
const [selectedMachineIds, setSelectedMachineIds] = useState<Set<string>>(new Set());
const [isBulkMode, setIsBulkMode] = useState(false);
// Memoized filtered machines (declared early to avoid usage before declaration)
const memoizedFilteredMachines = useMemo(() => filteredMachines, [filteredMachines]);
// Toggle bulk selection mode
const toggleBulkMode = useCallback(() => {
setIsBulkMode(prev => !prev);
if (isBulkMode) {
// Exiting bulk mode - clear selection
setSelectedMachineIds(new Set());
onBulkSelectionChange?.([]);
}
}, [isBulkMode, onBulkSelectionChange]);
// Toggle individual machine selection
const toggleMachineSelection = useCallback((machineId: string) => {
setSelectedMachineIds(prev => {
const newSelection = new Set(prev);
if (newSelection.has(machineId)) {
newSelection.delete(machineId);
} else {
newSelection.add(machineId);
}
onBulkSelectionChange?.(Array.from(newSelection));
return newSelection;
});
}, [onBulkSelectionChange]);
// Select all machines
const selectAllMachines = useCallback(() => {
const allIds = new Set(memoizedFilteredMachines.map(m => m.id));
setSelectedMachineIds(allIds);
onBulkSelectionChange?.(Array.from(allIds));
}, [memoizedFilteredMachines, onBulkSelectionChange]);
// Deselect all machines
const deselectAllMachines = useCallback(() => {
setSelectedMachineIds(new Set());
onBulkSelectionChange?.([]);
}, [onBulkSelectionChange]);
// Memoized: are all machines selected?
const allSelected = useMemo(() => {
if (memoizedFilteredMachines.length === 0) return false;
return memoizedFilteredMachines.every(m => selectedMachineIds.has(m.id));
}, [memoizedFilteredMachines, selectedMachineIds]);
// Clear selection when filteredMachines changes
useEffect(() => {
if (isBulkMode) {
setSelectedMachineIds(new Set());
onBulkSelectionChange?.([]);
}
}, [searchQuery]); // Reset selection on search change
/**
* Get machine status (from props or use default)
*/
const getMachineStatus = useCallback(
(machineId: string): Machine['status'] => {
return machineStatuses?.get(machineId) || 'unknown';
},
[machineStatuses]
);
/**
* Get status indicator color
*/
const getStatusColor = useCallback((status: Machine['status']): string => {
switch (status) {
case 'running':
return 'bg-kaspersky-success shadow-green-300 shadow-sm';
case 'stopped':
return 'bg-kaspersky-danger shadow-red-300 shadow-sm';
case 'checking':
return 'bg-kaspersky-warning shadow-yellow-300 shadow-sm animate-pulse';
case 'error':
return 'bg-kaspersky-warning shadow-yellow-300 shadow-sm';
case 'unknown':
default:
return 'bg-gray-400 shadow-gray-300 shadow-sm';
}
}, []);
/**
* Debug log on component mount
*/
useEffect(() => {
log.info('sidebar', 'Component mounted');
return () => {
log.info('sidebar', 'Component unmounted');
};
}, []);
/**
* Debug log on machines change
*/
useEffect(() => {
log.info('sidebar', 'Machines updated', {
count: machines.length,
machines: machines.map((m) => ({ id: m.id, name: m.name, status: m.status })),
});
}, [machines]);
/**
* Initial load and periodic refresh
*/
useEffect(() => {
log.info('sidebar', 'Starting initial fetch');
fetchMachines();
// Refresh machine list every 30 seconds
const interval = setInterval(fetchMachines, 30000);
return () => clearInterval(interval);
}, [fetchMachines]);
const handleSearch = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(event.target.value);
},
[setSearchQuery]
);
const handleConnect = useCallback(
(machine: Machine, protocol?: ProtocolOption) => {
selectMachine(machine);
onConnect?.(machine, protocol);
},
[selectMachine, onConnect]
);
return (
<div className="w-96 bg-white border-r border-kaspersky-border shadow-kaspersky select-none">
<div className="p-4">
<div className="relative">
<input
type="text"
placeholder="Search machines..."
value={searchQuery}
onChange={handleSearch}
className="w-full pl-10 pr-4 py-2 bg-kaspersky-bg text-kaspersky-text rounded-lg border-2 border-kaspersky-border focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-300 select-text"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">
<Search size={18} className="text-kaspersky-text-lighter" />
</div>
</div>
{/* Add Machine Button */}
<div className="mt-3">
<Button
variant="primary"
size="md"
fullWidth
onClick={() => setShowAddMachineModal(true)}
leftIcon={<Plus size={18} />}
>
Add Custom Machine
</Button>
</div>
</div>
{/* Bulk Selection Controls */}
<div className="border-t border-kaspersky-border pt-3 px-4 pb-2">
<div className="flex items-center justify-between">
<button
onClick={toggleBulkMode}
className={`text-sm font-medium transition-colors ${
isBulkMode
? 'text-kaspersky-primary'
: 'text-kaspersky-text-light hover:text-kaspersky-text-dark'
}`}
>
{isBulkMode ? '✓ Bulk Mode' : 'Bulk Select'}
</button>
{isBulkMode && memoizedFilteredMachines.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-sm text-kaspersky-text-light">
{selectedMachineIds.size} selected
</span>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={allSelected}
onChange={() => allSelected ? deselectAllMachines() : selectAllMachines()}
className="w-4 h-4 text-kaspersky-primary bg-kaspersky-bg border-kaspersky-border rounded focus:ring-kaspersky-primary focus:ring-2"
/>
<span className="text-sm text-kaspersky-text-light">All</span>
</label>
</div>
)}
</div>
</div>
<div className="overflow-y-auto h-[calc(100vh-11rem)]">
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="w-8 h-8 border-4 border-kaspersky-primary border-t-transparent rounded-full animate-spin"></div>
</div>
) : (
<div className="space-y-1 p-2">
{memoizedFilteredMachines.map((machine) => (
<div
key={machine.id}
className={`rounded-lg transition-all duration-200 ${
selectedMachine?.id === machine.id
? 'bg-kaspersky-primary/10 border-2 border-kaspersky-primary shadow-kaspersky'
: 'hover:bg-kaspersky-bg border-2 border-transparent hover:shadow-sm'
}`}
>
<div className="p-3">
<div className="flex items-center justify-between">
{/* Bulk Selection Checkbox */}
{isBulkMode && (
<label
className="flex-shrink-0 mr-2 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selectedMachineIds.has(machine.id)}
onChange={() => toggleMachineSelection(machine.id)}
className="w-4 h-4 text-kaspersky-primary bg-kaspersky-bg border-kaspersky-border rounded focus:ring-kaspersky-primary focus:ring-2"
/>
</label>
)}
<button
onClick={() => {
if (!isBulkMode) {
selectMachine(machine);
// Trigger availability check on machine click
if (onMachineSelected) {
onMachineSelected(machine);
}
} else {
// In bulk mode, clicking selects/deselects
toggleMachineSelection(machine.id);
}
}}
className="flex items-center text-left flex-1 min-w-0"
>
{!isBulkMode && (
<div
className={`w-3 h-3 rounded-full mr-3 flex-shrink-0 ${getStatusColor(getMachineStatus(machine.id))}`}
title={`Status: ${getMachineStatus(machine.id)}`}
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-kaspersky-text-dark truncate">{machine.name}</div>
<div className="text-sm text-kaspersky-text-light">
<span className="truncate">{machine.ip}</span>
</div>
</div>
</button>
{onConnect && !isBulkMode && (
<div className="ml-3 flex-shrink-0">
<SmartConnectionButton
machine={machine}
onConnect={handleConnect}
isConnected={machineConnections?.has(machine.id)}
userRole={userRole}
size="sm"
className="w-auto"
/>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default memo(Sidebar)

View File

@ -0,0 +1,96 @@
import React, { useMemo } from 'react';
import { Play, AlertCircle } from 'lucide-react';
import { Machine } from '../types';
import { ProtocolHelper, ProtocolOption, OSInfo } from '../utils/protocolHelper';
interface SmartConnectionButtonProps {
machine: Machine;
onConnect: (machine: Machine, suggestedProtocol?: ProtocolOption) => void;
isConnected?: boolean;
userRole?: string;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
const SmartConnectionButton: React.FC<SmartConnectionButtonProps> = ({
machine,
onConnect,
isConnected = false,
userRole,
className = '',
size = 'md'
}) => {
const osInfo: OSInfo = useMemo(() => {
return ProtocolHelper.parseOS(machine.os);
}, [machine.os]);
const recommendedProtocol = useMemo(() => {
return ProtocolHelper.getDefaultProtocol(osInfo);
}, [osInfo]);
const compatibilityInfo = useMemo(() => {
return ProtocolHelper.getCompatibilityInfo(osInfo);
}, [osInfo]);
const hasWarnings = compatibilityInfo.warnings.length > 0;
const isGuestRole = userRole === 'GUEST';
const isDisabled = isGuestRole && !isConnected; // Disable only for GUEST when not connected
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-3 py-2 text-sm',
lg: 'px-4 py-3 text-base'
};
const iconSizes = {
sm: 12,
md: 16,
lg: 20
};
const handleClick = () => {
if (!isDisabled) {
onConnect(machine, recommendedProtocol);
}
};
return (
<div className="relative">
<button
onClick={handleClick}
disabled={isDisabled}
className={`
flex items-center gap-2
min-w-[125px]
${isDisabled
? 'bg-gray-400 cursor-not-allowed opacity-60'
: isConnected
? 'bg-green-600 hover:bg-green-700'
: 'bg-kaspersky-base hover:bg-kaspersky-dark'
}
text-white rounded-md transition-all duration-200
font-medium
${sizeClasses[size]}
${hasWarnings && !isDisabled ? 'ring-2 ring-yellow-400 ring-opacity-50' : ''}
${className}
`}
title={
isDisabled
? 'Access denied: GUEST role cannot create connections. Contact your administrator.'
: isConnected
? `Active session via ${recommendedProtocol.label}`
: `Connect via ${recommendedProtocol.label} (${recommendedProtocol.description})`
}
>
<span className="text-lg w-6 flex justify-center">{recommendedProtocol.icon}</span>
<Play size={iconSizes[size]} className={isConnected ? 'text-green-200' : ''} />
<span className="flex-1 text-left">{isConnected ? 'Active' : 'Connect'}</span>
{hasWarnings && !isConnected && <AlertCircle size={iconSizes[size]} className="text-yellow-300" />}
</button>
</div>
);
};
export default SmartConnectionButton;

View File

@ -0,0 +1,91 @@
import React from 'react';
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-react';
import { Toast, useToast } from './ToastProvider';
const ToastItem: React.FC<{ toast: Toast }> = ({ toast }) => {
const { removeToast } = useToast();
const getToastStyles = (type: Toast['type']) => {
const baseStyles = "flex items-start p-4 mb-3 rounded-lg shadow-kaspersky border-2 transition-all duration-300 ease-in-out transform hover:scale-[1.02] animate-fadeIn";
switch (type) {
case 'success':
return `${baseStyles} bg-kaspersky-success bg-opacity-10 border-kaspersky-success text-kaspersky-success`;
case 'error':
return `${baseStyles} bg-kaspersky-danger bg-opacity-10 border-kaspersky-danger text-kaspersky-danger`;
case 'warning':
return `${baseStyles} bg-kaspersky-warning bg-opacity-10 border-kaspersky-warning text-kaspersky-warning`;
case 'info':
return `${baseStyles} bg-kaspersky-primary bg-opacity-10 border-kaspersky-primary text-kaspersky-primary`;
default:
return `${baseStyles} bg-kaspersky-bg-card border-kaspersky-border text-kaspersky-text`;
}
};
const getIcon = (type: Toast['type']) => {
const iconClasses = "w-5 h-5 mr-3 mt-0.5 flex-shrink-0";
switch (type) {
case 'success':
return <CheckCircle2 className={`${iconClasses} text-kaspersky-success`} />;
case 'error':
return <XCircle className={`${iconClasses} text-kaspersky-danger`} />;
case 'warning':
return <AlertTriangle className={`${iconClasses} text-kaspersky-warning`} />;
case 'info':
return <Info className={`${iconClasses} text-kaspersky-primary`} />;
default:
return null;
}
};
const handleClose = () => {
removeToast(toast.id);
};
return (
<div className={getToastStyles(toast.type)}>
{getIcon(toast.type)}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm text-kaspersky-text-dark">{toast.title}</h4>
{toast.message && (
<p className="mt-1 text-sm text-kaspersky-text opacity-90">{toast.message}</p>
)}
{toast.action && (
<button
onClick={toast.action.onClick}
className="mt-2 text-sm font-semibold underline hover:no-underline transition-all hover:opacity-80"
>
{toast.action.label}
</button>
)}
</div>
<button
onClick={handleClose}
className="ml-3 flex-shrink-0 p-1 rounded-lg hover:bg-black hover:bg-opacity-10 transition-all duration-200"
aria-label="Close notification"
>
<X size={16} />
</button>
</div>
);
};
export const ToastContainer: React.FC = () => {
const { toasts } = useToast();
if (toasts.length === 0) {
return null;
}
return (
<div className="fixed top-4 right-4 z-50 max-w-sm w-full space-y-2">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} />
))}
</div>
);
};

View File

@ -0,0 +1,99 @@
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
import { ToastContainer } from './ToastContainer';
export interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
interface ToastContextType {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => string;
removeToast: (id: string) => void;
clearToasts: () => void;
updateToast: (id: string, updates: Partial<Toast>) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((toast: Omit<Toast, 'id'>): string => {
const id = Math.random().toString(36).substr(2, 9);
const newToast: Toast = { ...toast, id };
setToasts(prev => [...prev, newToast]);
// Auto remove after duration
const duration = toast.duration ?? (toast.type === 'error' ? 8000 : 4000);
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id;
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const clearToasts = useCallback(() => {
setToasts([]);
}, []);
const updateToast = useCallback((id: string, updates: Partial<Toast>) => {
setToasts(prev => prev.map(toast =>
toast.id === id ? { ...toast, ...updates } : toast
));
}, []);
const value: ToastContextType = {
toasts,
addToast,
removeToast,
clearToasts,
updateToast
};
return (
<ToastContext.Provider value={value}>
{children}
<ToastContainer />
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
};
// Helpers for quick toast creation
export const useToastHelpers = () => {
const { addToast } = useToast();
return {
success: (title: string, message?: string, duration?: number) =>
addToast({ type: 'success', title, message, duration }),
error: (title: string, message?: string, duration?: number) =>
addToast({ type: 'error', title, message, duration }),
warning: (title: string, message?: string, duration?: number) =>
addToast({ type: 'warning', title, message, duration }),
info: (title: string, message?: string, duration?: number) =>
addToast({ type: 'info', title, message, duration }),
};
};

View File

@ -0,0 +1,96 @@
import React, { ButtonHTMLAttributes } from 'react';
import { Loader2 } from 'lucide-react';
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: `
bg-kaspersky-primary text-white
hover:bg-kaspersky-primary-dark hover:shadow-kaspersky-lg
active:bg-kaspersky-primary-dark active:scale-[0.98]
disabled:bg-kaspersky-text-lighter disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none
focus:outline-none focus:ring-4 focus:ring-kaspersky-primary/30
`,
secondary: `
bg-kaspersky-secondary text-white
hover:bg-opacity-90 hover:shadow-kaspersky
active:bg-opacity-80 active:scale-[0.98]
disabled:bg-kaspersky-text-lighter disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none
focus:outline-none focus:ring-4 focus:ring-kaspersky-secondary/30
`,
danger: `
bg-kaspersky-danger text-white
hover:bg-red-700 hover:shadow-kaspersky-lg
active:bg-red-800 active:scale-[0.98]
disabled:bg-kaspersky-text-lighter disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none
focus:outline-none focus:ring-4 focus:ring-kaspersky-danger/30
`,
success: `
bg-kaspersky-success text-white
hover:bg-green-600 hover:shadow-kaspersky-lg
active:bg-green-700 active:scale-[0.98]
disabled:bg-kaspersky-text-lighter disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none
focus:outline-none focus:ring-4 focus:ring-kaspersky-success/30
`,
ghost: `
bg-transparent text-kaspersky-text-dark border-2 border-kaspersky-border
hover:bg-kaspersky-bg hover:border-kaspersky-border-dark
active:bg-kaspersky-bg-card active:scale-[0.98]
disabled:text-kaspersky-text-lighter disabled:border-kaspersky-border-light disabled:cursor-not-allowed disabled:opacity-60
focus:outline-none focus:ring-4 focus:ring-kaspersky-primary/20
`,
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon,
rightIcon,
fullWidth = false,
className = '',
children,
disabled,
...props
}) => {
return (
<button
className={`
${variantClasses[variant]}
${sizeClasses[size]}
${fullWidth ? 'w-full' : ''}
font-semibold rounded-lg
transition-all duration-300 transform
flex items-center justify-center gap-2
disabled:transform-none
${className}
`.trim().replace(/\s+/g, ' ')}
disabled={disabled || isLoading}
{...props}
>
{isLoading && <Loader2 size={16} className="animate-spin" />}
{!isLoading && leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
<span>{children}</span>
{!isLoading && rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
</button>
);
};
export default Button;

View File

@ -0,0 +1,37 @@
// API Configuration
export const API_CONFIG = {
// Client on test.exbytestudios.com, API on mc.exbytestudios.com
BASE_URL: import.meta.env.VITE_API_URL || 'https://mc.exbytestudios.com',
ENDPOINTS: {
AUTH: {
LOGIN: '/api/auth/login',
REVOKE: '/api/auth/revoke',
},
CONNECTIONS: {
CREATE: '/api/connections',
LIST: '/api/connections',
DELETE: (id: string) => `/api/connections/${id}`,
EXTEND: (id: string) => `/api/connections/${id}/extend`,
},
MACHINES: {
CHECK_AVAILABILITY: '/api/machines/check-availability',
SAVED: '/api/machines/saved',
SAVED_BY_ID: (id: string) => `/api/machines/saved/${id}`,
SAVED_CONNECT: (id: string) => `/api/machines/saved/${id}/connect`,
},
BULK: {
HEALTH_CHECK: '/api/bulk/health-check',
SSH_COMMAND: '/api/bulk/ssh-command',
},
SECURITY: {
CERTIFICATE_PINS: '/api/security/certificate-pins',
},
HEALTH: {
CHECK: '/api/health',
},
},
TIMEOUTS: {
DEFAULT: 30000, // 30 seconds
LONG: 60000, // 60 seconds
},
};

27
mc_test/src/renderer/main.tsx Executable file
View File

@ -0,0 +1,27 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
import { CSPManager } from './utils/csp'
// Debug: output that main.tsx is loading
console.log('🚀 main.tsx loading...');
// Apply CSP automatically on initialization
console.log('🔒 Applying Content Security Policy...');
CSPManager.applyCSP();
CSPManager.setupCSPReporting();
try {
const root = ReactDOM.createRoot(document.getElementById('root')!);
console.log('✅ ReactDOM.createRoot successfully created');
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
console.log('✅ App successfully rendered');
} catch (error) {
console.error('❌ Rendering error:', error);
}

View File

@ -0,0 +1,117 @@
import type { Machine } from '../types';
export const mockMachines: Machine[] = [
{
id: '00000000-0000-0000-0000-000000000001',
name: 'EP-SN-01',
status: 'running',
os: 'Ubuntu 22.04 LTS',
ip: '192.168.200.2',
hypervisor: 'VMware ESXi',
specs: {
cpu: 'Intel Core i7-9700K',
ram: '16 GB',
disk: '512 GB SSD'
},
testLinks: [
{ name: 'RDP Connection', url: 'ssh://192.168.200.3:22' },
{ name: 'Web Server', url: 'http://192.168.200.3:8080' },
{ name: 'Admin Panel', url: 'http://192.168.200.3:8081/admin' }
],
logs: [
{
timestamp: new Date().toISOString(),
level: 'info',
message: 'Machine started successfully'
},
{
timestamp: new Date(Date.now() - 300000).toISOString(),
level: 'info',
message: 'RDP service is running'
}
]
},
{
id: '00000000-0000-0000-0000-000000000002',
name: 'EP-Test-01',
status: 'running',
os: 'Ubuntu 22.04 LTS',
ip: import.meta.env.VITE_DEV_HOST || '192.168.200.10',
hypervisor: 'KVM',
specs: {
cpu: 'AMD Ryzen 7 5700G',
ram: '8 GB',
disk: '256 GB SSD'
},
testLinks: [
{ name: 'SSH Connection', url: `ssh://${import.meta.env.VITE_DEV_HOST || '192.168.200.10'}:22` },
{ name: 'Web Server', url: `http://${import.meta.env.VITE_DEV_HOST || '192.168.200.10'}:80` },
{ name: 'SFTP', url: `sftp://${import.meta.env.VITE_DEV_HOST || '192.168.200.10'}:22` }
],
logs: [
{
timestamp: new Date().toISOString(),
level: 'info',
message: 'SSH service is running'
},
{
timestamp: new Date(Date.now() - 600000).toISOString(),
level: 'info',
message: 'System update completed'
}
]
},
{
id: '00000000-0000-0000-0000-000000000003',
name: 'VNC Desktop',
status: 'running',
os: 'CentOS 8',
ip: '192.168.1.100',
hypervisor: 'VMware vSphere',
specs: {
cpu: 'Intel Xeon E5-2680',
ram: '12 GB',
disk: '1 TB HDD'
},
testLinks: [
{ name: 'VNC Connection', url: 'vnc://192.168.1.100:5901' },
{ name: 'Desktop Environment', url: 'http://192.168.1.100:6080' }
],
logs: [
{
timestamp: new Date().toISOString(),
level: 'info',
message: 'VNC server started'
},
{
timestamp: new Date(Date.now() - 900000).toISOString(),
level: 'warn',
message: 'High CPU usage detected'
}
]
},
{
id: '00000000-0000-0000-0000-000000000004',
name: 'Local Test Machine',
status: 'running',
os: 'Windows 11 Pro',
ip: '127.0.0.1',
hypervisor: 'Local',
specs: {
cpu: 'Intel Core i5-12400',
ram: '16 GB',
disk: '512 GB NVMe'
},
testLinks: [
{ name: 'Local RDP', url: 'rdp://127.0.0.1:3389' },
{ name: 'Localhost Web', url: 'http://127.0.0.1:8080' }
],
logs: [
{
timestamp: new Date().toISOString(),
level: 'info',
message: 'Local machine ready for testing'
}
]
}
];

View File

@ -0,0 +1,468 @@
/**
* Certificate Pinning Service for MITM attack protection
*/
import { API_CONFIG } from '../config/api';
export interface CertificateInfo {
fingerprint: string;
algorithm: string;
issuer: string;
subject: string;
validFrom: string;
validTo: string;
}
export interface PinnedCertificate {
hostname: string;
fingerprint: string;
algorithm: string;
lastVerified: string;
}
interface CertificatePinConfig {
pins: Record<string, string[]>;
backup_pins: Record<string, string[]>;
rotation_schedule: {
primary_pin_ttl_days: number;
backup_pin_ttl_days: number;
next_rotation: string;
};
fallback_enabled: boolean;
version: string;
}
interface PinCache {
config: CertificatePinConfig;
lastUpdated: number;
ttl: number;
}
const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24;
const CERTIFICATE_CONSTANTS = {
CACHE_TTL: 24 * 60 * 60 * 1000, // 24 hours
MAX_AGE_DAYS: 365,
HTTPS_PROTOCOL: 'https:',
} as const;
const HASH_ALGORITHM = {
SHA256: 'sha256',
SHA1: 'sha1',
} as const;
const HEX_FORMAT = {
RADIX: 16,
PAD_LENGTH: 2,
PAD_CHAR: '0',
} as const;
export class CertificatePinningService {
public static pinCache: PinCache | null = null;
public static readonly CACHE_TTL = CERTIFICATE_CONSTANTS.CACHE_TTL;
/**
* INSTRUCTIONS FOR OBTAINING CERTIFICATE FINGERPRINT:
*
* 1. To get SHA-256 fingerprint of certificate, run:
* openssl s_client -connect DOMAIN:443 -servername DOMAIN | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
*
* 2. Alternative method via curl:
* curl -sI https://DOMAIN | openssl s_client -connect DOMAIN:443 -servername DOMAIN | openssl x509 -fingerprint -sha256 -noout
*
* 3. For browser, use Developer Tools > Security > Certificate Details
*
* IMPORTANT: Replace PLACEHOLDER values with real fingerprints before production deployment!
*/
private static readonly PINNED_CERTIFICATES: PinnedCertificate[] = [
{
hostname: import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com',
fingerprint: 'PLACEHOLDER_REAL_CERTIFICATE_FINGERPRINT_HERE',
algorithm: HASH_ALGORITHM.SHA256,
lastVerified: new Date().toISOString(),
},
{
hostname: import.meta.env.VITE_BACKUP_DOMAIN || 'backup.mc.exbytestudios.com',
fingerprint: 'PLACEHOLDER_BACKUP_CERTIFICATE_FINGERPRINT_HERE',
algorithm: HASH_ALGORITHM.SHA256,
lastVerified: new Date().toISOString(),
},
];
private static readonly MAX_CERTIFICATE_AGE_DAYS = CERTIFICATE_CONSTANTS.MAX_AGE_DAYS;
/**
* Verify certificate for host
*/
static async verifyCertificate(hostname: string): Promise<boolean> {
try {
if (typeof window !== 'undefined' && (window as any).electronAPI) {
const certificate = await (window as any).electronAPI.getCertificate(hostname);
return this.validateCertificate(hostname, certificate);
}
return this.validateCertificateBrowser(hostname);
} catch (error) {
console.error('Certificate verification failed:', error);
return false;
}
}
/**
* Validate certificate against pinned certificates
*/
private static validateCertificate(hostname: string, certificate: CertificateInfo): boolean {
const pinnedCert = this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname);
if (!pinnedCert) {
console.warn(`No pinned certificate found for hostname: ${hostname}`);
return true;
}
if (certificate.fingerprint.toLowerCase() !== pinnedCert.fingerprint.toLowerCase()) {
console.error(`Certificate fingerprint mismatch for ${hostname}:`, {
expected: pinnedCert.fingerprint,
actual: certificate.fingerprint,
});
return false;
}
if (certificate.algorithm !== pinnedCert.algorithm) {
console.error(`Certificate algorithm mismatch for ${hostname}:`, {
expected: pinnedCert.algorithm,
actual: certificate.algorithm,
});
return false;
}
const validTo = new Date(certificate.validTo);
const now = new Date();
if (validTo < now) {
console.error(`Certificate expired for ${hostname}:`, {
validTo: certificate.validTo,
now: now.toISOString(),
});
return false;
}
const validFrom = new Date(certificate.validFrom);
const ageInDays = (now.getTime() - validFrom.getTime()) / MILLISECONDS_PER_DAY;
if (ageInDays > this.MAX_CERTIFICATE_AGE_DAYS) {
console.warn(`Certificate is too old for ${hostname}:`, {
ageInDays,
maxAge: this.MAX_CERTIFICATE_AGE_DAYS,
});
}
console.log(`Certificate validation passed for ${hostname}`);
return true;
}
/**
* Validation for browser environment (limited)
*/
private static validateCertificateBrowser(hostname: string): boolean {
if (window.location.protocol !== CERTIFICATE_CONSTANTS.HTTPS_PROTOCOL) {
console.error('Non-HTTPS connection detected');
return false;
}
const expectedHostname = this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname);
if (!expectedHostname) {
return true;
}
console.log(`HTTPS validation passed for ${hostname}`);
return true;
}
/**
* Get pinned certificate information for host
*/
static getPinnedCertificate(hostname: string): PinnedCertificate | null {
return this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname) || null;
}
/**
* Add new pinned certificate
*/
static addPinnedCertificate(certificate: PinnedCertificate): void {
const existingIndex = this.PINNED_CERTIFICATES.findIndex(
(cert) => cert.hostname === certificate.hostname
);
if (existingIndex >= 0) {
this.PINNED_CERTIFICATES[existingIndex] = certificate;
} else {
this.PINNED_CERTIFICATES.push(certificate);
}
console.log(`Added pinned certificate for ${certificate.hostname}`);
}
/**
* Remove pinned certificate
*/
static removePinnedCertificate(hostname: string): boolean {
const index = this.PINNED_CERTIFICATES.findIndex((cert) => cert.hostname === hostname);
if (index >= 0) {
this.PINNED_CERTIFICATES.splice(index, 1);
console.log(`Removed pinned certificate for ${hostname}`);
return true;
}
return false;
}
/**
* Get all pinned certificates
*/
static getAllPinnedCertificates(): PinnedCertificate[] {
return [...this.PINNED_CERTIFICATES];
}
/**
* Check if pinned certificate exists for host
*/
static hasPinnedCertificate(hostname: string): boolean {
return this.PINNED_CERTIFICATES.some((cert) => cert.hostname === hostname);
}
/**
* Update certificate fingerprint
*/
static updateCertificateFingerprint(hostname: string, newFingerprint: string): boolean {
const certificate = this.getPinnedCertificate(hostname);
if (certificate) {
certificate.fingerprint = newFingerprint;
certificate.lastVerified = new Date().toISOString();
console.log(`Updated certificate fingerprint for ${hostname}`);
return true;
}
return false;
}
/**
* Validate all pinned certificates
*/
static async validateAllCertificates(): Promise<Record<string, boolean>> {
const results: Record<string, boolean> = {};
for (const certificate of this.PINNED_CERTIFICATES) {
try {
results[certificate.hostname] = await this.verifyCertificate(certificate.hostname);
} catch (error) {
console.error(`Failed to validate certificate for ${certificate.hostname}:`, error);
results[certificate.hostname] = false;
}
}
return results;
}
/**
* Get pinned certificates statistics
*/
static getStatistics(): {
totalCertificates: number;
certificatesByAlgorithm: Record<string, number>;
oldestCertificate: PinnedCertificate | null;
newestCertificate: PinnedCertificate | null;
} {
const certificates = this.PINNED_CERTIFICATES;
const certificatesByAlgorithm = certificates.reduce(
(acc, cert) => {
acc[cert.algorithm] = (acc[cert.algorithm] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const sortedByDate = [...certificates].sort(
(a, b) => new Date(a.lastVerified).getTime() - new Date(b.lastVerified).getTime()
);
return {
totalCertificates: certificates.length,
certificatesByAlgorithm,
oldestCertificate: sortedByDate[0] || null,
newestCertificate: sortedByDate[sortedByDate.length - 1] || null,
};
}
}
const FINGERPRINT_PATTERNS = {
SHA256: /^[a-f0-9]{64}$/,
SHA1: /^[a-f0-9]{40}$/,
} as const;
/**
* Certificate utilities
*/
export class CertificateUtils {
/**
* Compute SHA-256 fingerprint of certificate
*/
static async computeSHA256Fingerprint(certificateData: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', certificateData);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray
.map((b) => b.toString(HEX_FORMAT.RADIX).padStart(HEX_FORMAT.PAD_LENGTH, HEX_FORMAT.PAD_CHAR))
.join(':')
.toUpperCase();
}
/**
* Compute SHA-1 fingerprint of certificate
*/
static async computeSHA1Fingerprint(certificateData: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-1', certificateData);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray
.map((b) => b.toString(HEX_FORMAT.RADIX).padStart(HEX_FORMAT.PAD_LENGTH, HEX_FORMAT.PAD_CHAR))
.join(':')
.toUpperCase();
}
/**
* Format fingerprint for display
*/
static formatFingerprint(fingerprint: string): string {
return fingerprint
.toUpperCase()
.replace(/(.{2})/g, '$1:')
.slice(0, -1);
}
/**
* Validate fingerprint format
*/
static isValidFingerprint(fingerprint: string): boolean {
const cleanFingerprint = fingerprint.replace(/:/g, '').toLowerCase();
return (
FINGERPRINT_PATTERNS.SHA1.test(cleanFingerprint) ||
FINGERPRINT_PATTERNS.SHA256.test(cleanFingerprint)
);
}
/**
* Load current certificate pins from server
*/
async loadDynamicPins(baseUrl: string): Promise<boolean> {
try {
const response = await fetch(
`${baseUrl}${API_CONFIG.ENDPOINTS.SECURITY.CERTIFICATE_PINS}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error(`Failed to load certificate pins: ${response.status}`);
}
const config: CertificatePinConfig = await response.json();
CertificatePinningService.pinCache = {
config,
lastUpdated: Date.now(),
ttl: CertificatePinningService.CACHE_TTL,
};
this.updateStaticPins(config);
console.log('Certificate pins loaded successfully', {
version: config.version,
hosts: Object.keys(config.pins),
fallbackEnabled: config.fallback_enabled,
});
return true;
} catch (error) {
console.error('Failed to load dynamic certificate pins:', error);
return false;
}
}
/**
* Update static pins from configuration
*/
public updateStaticPins(config: CertificatePinConfig): void {
const newPinnedCerts: PinnedCertificate[] = [];
for (const [hostname, pins] of Object.entries(config.pins)) {
for (const pin of pins) {
newPinnedCerts.push({
hostname,
fingerprint: pin,
algorithm: HASH_ALGORITHM.SHA256,
lastVerified: new Date().toISOString(),
});
}
}
if (config.fallback_enabled) {
for (const [hostname, pins] of Object.entries(config.backup_pins)) {
for (const pin of pins) {
newPinnedCerts.push({
hostname,
fingerprint: pin,
algorithm: HASH_ALGORITHM.SHA256,
lastVerified: new Date().toISOString(),
});
}
}
}
(CertificatePinningService as any).PINNED_CERTIFICATES = newPinnedCerts;
}
/**
* Check if pins cache is valid
*/
isPinCacheValid(): boolean {
if (!CertificatePinningService.pinCache) {
return false;
}
const now = Date.now();
return (
now - CertificatePinningService.pinCache.lastUpdated <
CertificatePinningService.pinCache.ttl
);
}
/**
* Get pins cache information
*/
static getPinCacheInfo(): { cached: boolean; lastUpdated?: string; ttl?: number } {
if (!CertificatePinningService.pinCache) {
return { cached: false };
}
return {
cached: true,
lastUpdated: new Date(CertificatePinningService.pinCache.lastUpdated).toISOString(),
ttl: CertificatePinningService.pinCache.ttl,
};
}
/**
* Clear pins cache
*/
static clearPinCache(): void {
CertificatePinningService.pinCache = null;
}
}
export const certificatePinning = new CertificatePinningService();
export const certificateUtils = CertificateUtils;

View File

@ -0,0 +1,261 @@
import { log } from '../utils/logger';
/**
* Client-side password encryption service
* Uses Web Crypto API with AES-256-GCM algorithm
*/
class EncryptionService {
private encryptionKey: CryptoKey | null = null;
/**
* Initialize encryption key
* @param keyBase64 Base64 encoded key from server
*/
async initializeKey(keyBase64: string): Promise<void> {
try {
// this._keyBase64 = keyBase64;
// Decode Base64 key
const keyBuffer = this.base64ToArrayBuffer(keyBase64);
// Import key for use with Web Crypto API
this.encryptionKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'AES-GCM' },
false, // not exportable
['encrypt', 'decrypt']
);
log.info('encryption-service', 'Encryption key initialized successfully');
} catch (error) {
log.error('encryption-service', 'Failed to initialize encryption key', { error });
throw new Error('Failed to initialize encryption key');
}
}
/**
* Encrypt password using AES-GCM and AEAD
* @param password Password in plain text
* @param sessionId Session ID for AAD
* @returns Encrypted password in Base64
*/
async encryptPassword(password: string, sessionId?: string): Promise<{encrypted: string, nonce: string, timestamp: number}> {
if (!this.encryptionKey) {
throw new Error('Encryption key not initialized');
}
try {
// CRITICAL: Generate unique IV (12 bytes for GCM)
const iv = crypto.getRandomValues(new Uint8Array(12));
// Convert password to ArrayBuffer
const passwordBuffer = new TextEncoder().encode(password);
// AAD used ONLY for saved machines (when sessionId exists)
// For temporary connection passwords AAD is NOT needed
const encryptParams: AesGcmParams = {
name: 'AES-GCM',
iv: iv
};
let timestamp = 0;
let clientNonce: Uint8Array | undefined;
// If sessionId provided - use AAD for additional security
if (sessionId) {
timestamp = Date.now();
clientNonce = crypto.getRandomValues(new Uint8Array(16));
const aadData = {
sessionId: sessionId,
timestamp: timestamp,
clientNonce: Array.from(clientNonce)
};
encryptParams.additionalData = new TextEncoder().encode(JSON.stringify(aadData));
}
// Encrypt
const encryptedBuffer = await crypto.subtle.encrypt(
encryptParams,
this.encryptionKey,
passwordBuffer
);
// Combine IV + encrypted data
const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encryptedBuffer), iv.length);
// Convert to Base64
const encrypted = this.arrayBufferToBase64(combined.buffer);
const nonce = clientNonce ? this.arrayBufferToBase64(clientNonce.buffer as ArrayBuffer) : '';
log.debug('encryption-service', 'Password encrypted successfully', {
passwordLength: password.length,
encryptedLength: encrypted.length,
sessionId: sessionId,
usesAAD: !!sessionId
});
return {
encrypted,
nonce,
timestamp
};
} catch (error) {
log.error('encryption-service', 'Failed to encrypt password', { error });
throw new Error('Password encryption failed');
}
}
/**
* Decrypt password (for testing)
* @param encryptedPassword Base64 encrypted string
* @param sessionId Session ID for AAD (optional - for testing)
* @param nonce Client nonce for AAD (optional - for testing)
* @param timestamp Timestamp for AAD (optional - for testing)
* @returns Password in plain text
*/
async decryptPassword(
encryptedPassword: string,
sessionId?: string,
nonce?: string,
timestamp?: number
): Promise<string> {
if (!this.encryptionKey) {
throw new Error('Encryption key not initialized');
}
try {
// Decode Base64
const combined = this.base64ToArrayBuffer(encryptedPassword);
// Extract IV (first 12 bytes) and encrypted data
const iv = combined.slice(0, 12);
const encryptedData = combined.slice(12);
// FIXED: Create AAD if parameters provided
let additionalData: Uint8Array | undefined = undefined;
if (sessionId && nonce && timestamp) {
const clientNonceArray = new Uint8Array(this.base64ToArrayBuffer(nonce));
const aadData = {
sessionId: sessionId,
timestamp: timestamp,
clientNonce: Array.from(clientNonceArray)
};
additionalData = new TextEncoder().encode(JSON.stringify(aadData));
}
// Decrypt
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
...(additionalData && { additionalData }) // Add AAD if exists
},
this.encryptionKey,
encryptedData
);
// Convert back to string
const password = new TextDecoder().decode(decryptedBuffer);
log.debug('encryption-service', 'Password decrypted successfully');
return password;
} catch (error) {
log.error('encryption-service', 'Failed to decrypt password', { error });
throw new Error('Password decryption failed');
}
}
/**
* Check if encryption key is initialized
*/
isInitialized(): boolean {
return this.encryptionKey !== null;
}
/**
* Get current encryption status
*/
getStatus(): {
initialized: boolean;
} {
return {
initialized: this.isInitialized(),
};
}
/**
* Clear encryption key
*/
clearKey(): void {
this.encryptionKey = null;
log.info('encryption-service', 'Encryption key cleared');
}
/**
* Convert Base64 string to ArrayBuffer
*/
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Convert ArrayBuffer to Base64 string
*/
private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Test encryption/decryption
*/
async testEncryption(testPassword: string = 'test123', sessionId: string = 'test-session'): Promise<boolean> {
try {
if (!this.isInitialized()) {
throw new Error('Encryption key not initialized');
}
// Encrypt test password
const encryptionResult = await this.encryptPassword(testPassword, sessionId);
// FIXED: Decrypt with same AAD parameters
const decrypted = await this.decryptPassword(
encryptionResult.encrypted,
sessionId,
encryptionResult.nonce,
encryptionResult.timestamp
);
// Check that we got the same password
const success = decrypted === testPassword;
log.info('encryption-service', 'Encryption test completed', {
success,
testPasswordLength: testPassword.length,
encryptedLength: encryptionResult.encrypted.length
});
return success;
} catch (error) {
log.error('encryption-service', 'Encryption test failed', { error });
return false;
}
}
}
// Export singleton instance
export const encryptionService = new EncryptionService();
export default encryptionService;

View File

@ -0,0 +1,160 @@
import { log } from '../utils/logger';
import * as nacl from 'tweetnacl';
const ED25519_PUBLIC_KEY_SIZE = 32;
const KEY_PREVIEW_LENGTH = 50;
const PEM_HEADER = {
BEGIN: '-----BEGIN PUBLIC KEY-----',
END: '-----END PUBLIC KEY-----',
} as const;
type Environment = 'production' | 'staging' | 'development';
/**
* Service for verifying Ed25519 signatures of server keys
* Uses TweetNaCl for cross-browser compatibility
*/
export class SignatureVerificationService {
private static readonly TRUSTED_SIGNING_KEYS: Record<Environment, string> = {
production:
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
staging:
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
development:
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
};
/**
* Verify Ed25519 signature of server public key using TweetNaCl
* @param serverPublicKeyPem PEM-encoded server public key
* @param signatureBase64 Base64-encoded signature
* @param environment Environment (production, staging, development)
* @returns true if signature is valid, false otherwise
*/
static async verifyServerKeySignature(
serverPublicKeyPem: string,
signatureBase64: string,
environment: Environment = 'production'
): Promise<boolean> {
try {
log.info('signature-verification', 'Starting server key signature verification (TweetNaCl)', {
environment,
serverKeyLength: serverPublicKeyPem.length,
signatureLength: signatureBase64.length,
});
const trustedSigningKeyPem = this.TRUSTED_SIGNING_KEYS[environment];
if (!trustedSigningKeyPem) {
log.error('signature-verification', 'No trusted signing key found for environment', {
environment,
});
return false;
}
const trustedSigningKey = this.extractEd25519PublicKeyFromSPKI(trustedSigningKeyPem);
const signature = this.base64ToUint8Array(signatureBase64);
// CRITICAL: Server sends base64(PEM), need to decode
const serverPublicKeyPemDecoded = atob(serverPublicKeyPem);
// IMPORTANT: Server signs PEM bytes (with headers)
// So we use the same PEM bytes for verification (not DER)
const serverPublicKeyBytes = new TextEncoder().encode(serverPublicKeyPemDecoded);
log.debug('signature-verification', 'Verification data prepared', {
trustedKeyLength: trustedSigningKey.length,
signatureLength: signature.length,
messageLength: serverPublicKeyBytes.length,
});
const isValid = nacl.sign.detached.verify(
serverPublicKeyBytes,
signature,
trustedSigningKey
);
if (isValid) {
log.info('signature-verification', 'Server key signature verification successful');
} else {
log.warn('signature-verification', 'Server key signature verification failed');
}
return isValid;
} catch (error) {
log.error('signature-verification', 'Server key signature verification error', {
error: error instanceof Error ? error.message : String(error),
environment,
});
return false;
}
}
/**
* Extract raw Ed25519 public key (32 bytes) from base64(PEM) SPKI format
*
* SPKI format for Ed25519:
* - Total size: ~44 bytes
* - Last 32 bytes: raw Ed25519 public key
*
* @param pemKeyBase64 Base64-encoded PEM string
* @returns Uint8Array (32 bytes) - raw Ed25519 public key
*/
private static extractEd25519PublicKeyFromSPKI(pemKeyBase64: string): Uint8Array {
try {
const pemString = atob(pemKeyBase64);
log.debug('signature-verification', 'Decoded PEM from base64', {
pemLength: pemString.length,
hasPemHeaders: pemString.includes(PEM_HEADER.BEGIN),
});
const pemContent = pemString
.replace(new RegExp(PEM_HEADER.BEGIN, 'g'), '')
.replace(new RegExp(PEM_HEADER.END, 'g'), '')
.replace(/\s/g, '');
const spkiBytes = this.base64ToUint8Array(pemContent);
log.debug('signature-verification', 'Extracted SPKI bytes', {
spkiLength: spkiBytes.length,
});
// SPKI contains headers (~12 bytes) + raw key (32 bytes)
if (spkiBytes.length < ED25519_PUBLIC_KEY_SIZE) {
throw new Error(
`SPKI too short: ${spkiBytes.length} bytes (expected >=${ED25519_PUBLIC_KEY_SIZE})`
);
}
const rawPublicKey = spkiBytes.slice(spkiBytes.length - ED25519_PUBLIC_KEY_SIZE);
log.info('signature-verification', 'Ed25519 raw public key extracted', {
rawKeyLength: rawPublicKey.length,
});
return rawPublicKey;
} catch (error) {
log.error('signature-verification', 'Failed to extract Ed25519 public key from SPKI', {
error: error instanceof Error ? error.message : String(error),
keyLength: pemKeyBase64.length,
keyPreview: pemKeyBase64.substring(0, KEY_PREVIEW_LENGTH) + '...',
});
throw error;
}
}
/**
* Convert base64 to Uint8Array
*/
private static base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
}
export const signatureVerification = new SignatureVerificationService();

View File

@ -0,0 +1,45 @@
import axios from 'axios';
import { log } from '../../utils/logger';
const api = axios.create({
baseURL: import.meta.env.VITE_GUACAMOLE_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
api.interceptors.request.use(
(config) => {
log.info('guacamole-api', `Request: ${config.method?.toUpperCase()} ${config.url}`, {
headers: config.headers,
params: config.params,
data: config.data,
});
return config;
},
(error) => {
log.error('guacamole-api', 'Request error:', { error: error.message });
return Promise.reject(error);
}
);
// Response interceptor
api.interceptors.response.use(
(response) => {
log.info('guacamole-api', `Response: ${response.status} ${response.config.url}`, {
data: response.data,
});
return response;
},
(error) => {
log.error('guacamole-api', 'Response error:', {
status: error.response?.status,
data: error.response?.data,
message: error.message,
});
return Promise.reject(error);
}
);
export default api;

View File

@ -0,0 +1,377 @@
import { log } from '../utils/logger';
import { API_CONFIG } from '../config/api';
import { ApiClient } from '../utils/apiClient';
interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user_info: {
username: string;
role: string;
permissions: string[];
full_name?: string;
email?: string;
};
}
interface StoredAuth {
token: string;
expiresAt: number;
userInfo: LoginResponse['user_info'];
}
class AuthService {
private static readonly AUTH_KEY = 'mcc_auth';
private tokenCheckInterval: NodeJS.Timeout | null = null;
private onTokenExpiredCallback: (() => void) | null = null;
private onTokenWarningCallback: ((timeLeft: number) => void) | null = null;
// Cache for synchronous access
private currentToken: string | null = null;
private currentUser: StoredAuth['userInfo'] | null = null;
constructor() {
// Restore auth on startup
this.initializeAuth();
}
/**
* Initialize auth on application startup
* Restores token and user from safeStorage
*/
private async initializeAuth() {
try {
const stored = await this.getStoredAuth();
if (stored && Date.now() < stored.expiresAt) {
// CRITICAL CHECK: JWT must start with "eyJ" (base64-encoded JSON header)
// If token is invalid (encrypted blob from old version), clear storage
if (!stored.token || !stored.token.startsWith('eyJ')) {
log.error('auth-service', 'Invalid token format in storage, clearing auth', {
tokenPrefix: stored.token?.substring(0, 10) || 'empty',
tokenLength: stored.token?.length || 0
});
this.clearAuth();
return;
}
this.currentToken = stored.token;
this.currentUser = stored.userInfo;
// Start token monitoring
this.startTokenMonitoring();
log.info('auth-service', 'Auth restored from storage', {
username: stored.userInfo.username,
tokenPrefix: stored.token.substring(0, 20) + '...'
});
} else {
log.debug('auth-service', 'No valid stored auth found');
}
} catch (error) {
log.error('auth-service', 'Failed to initialize auth', { error });
this.clearAuth();
}
}
/**
* User authentication
*/
async login(username: string, password: string): Promise<LoginResponse> {
try {
log.info('auth-service', 'Starting login', { username });
this.clearAuth();
log.info('auth-service', 'Previous session cleared before new login');
const data: LoginResponse = await ApiClient.post(
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`,
{ username, password },
{
timeout: 10000,
retryConfig: { maxRetries: 2 }
}
);
await this.saveAuth(data);
this.startTokenMonitoring();
log.info('auth-service', 'Login successful', {
username: data.user_info.username,
role: data.user_info.role
});
return data;
} catch (error) {
log.error('auth-service', 'Login failed', { error });
throw error;
}
}
/**
* Logout
*/
async logout(): Promise<void> {
try {
// Use cached token
if (this.currentToken) {
// Use new endpoint for token revocation
await ApiClient.post(
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REVOKE}`,
{},
ApiClient.withAuth(this.currentToken, {
timeout: 5000, // Short timeout for logout
retryConfig: { maxRetries: 1 } // Minimum retry for logout
})
).catch(() => {
// Ignore errors on logout
});
log.info('auth-service', 'Token revoked on server');
}
} finally {
// Always clear local storage
this.clearAuth();
this.stopTokenMonitoring();
log.info('auth-service', 'Logged out');
}
}
/**
* Get current token
* FIXED: async for safeStorage support
*/
async getToken(): Promise<string | null> {
const stored = await this.getStoredAuth();
if (!stored) return null;
// Check if token expired
if (Date.now() >= stored.expiresAt) {
this.clearAuth();
return null;
}
return stored.token;
}
/**
* Get current user info (SYNCHRONOUS)
* Used for UI - fast check without async
*/
getCurrentUser(): StoredAuth['userInfo'] | null {
// Check memory cache
if (this.currentUser) {
return this.currentUser;
}
return null;
}
/**
* Check if user is authenticated (SYNCHRONOUS)
* Used for UI - fast check without async
*/
isAuthenticated(): boolean {
return this.currentUser !== null;
}
/**
* Get headers for authenticated requests (SYNCHRONOUS)
* Used for API requests
*/
getAuthHeaders(): Record<string, string> {
if (!this.currentToken) {
log.warn('auth-service', 'No token available for auth headers');
return {};
}
// Check that token is valid before sending
if (!this.currentToken.startsWith('eyJ')) {
log.error('auth-service', 'Current token is invalid (not a JWT), clearing auth', {
tokenPrefix: this.currentToken.substring(0, 10),
tokenLength: this.currentToken.length
});
this.clearAuth();
return {};
}
log.debug('auth-service', 'Providing auth headers', {
tokenPrefix: this.currentToken.substring(0, 30) + '...'
});
return {
'Authorization': `Bearer ${this.currentToken}`,
};
}
/**
* Save authentication data
* FIXED: Uses Electron safeStorage for XSS protection
*/
private async saveAuth(loginResponse: LoginResponse): Promise<void> {
// Validate JWT token before saving
if (!loginResponse.access_token || !loginResponse.access_token.startsWith('eyJ')) {
log.error('auth-service', 'Received invalid JWT token from API', {
tokenPrefix: loginResponse.access_token?.substring(0, 10) || 'empty'
});
throw new Error('Invalid JWT token received from server');
}
const auth: StoredAuth = {
token: loginResponse.access_token,
expiresAt: Date.now() + (loginResponse.expires_in * 1000),
userInfo: loginResponse.user_info,
};
try {
// Check Electron API availability (for dev vs prod)
const hasElectronAPI = typeof window !== 'undefined' &&
window.electronAPI !== undefined &&
typeof window.electronAPI.isEncryptionAvailable === 'function';
if (hasElectronAPI) {
// Check safeStorage availability
const isAvailable = await window.electronAPI.isEncryptionAvailable();
if (isAvailable) {
// SECURED: Use Electron safeStorage
const authJson = JSON.stringify(auth);
const encrypted = await window.electronAPI.encryptData(authJson);
localStorage.setItem(AuthService.AUTH_KEY, encrypted);
log.info('auth-service', 'Auth data saved securely with safeStorage');
} else {
// Fallback for platforms without safeStorage
log.warn('auth-service', 'safeStorage not available, using localStorage (less secure)');
localStorage.setItem(AuthService.AUTH_KEY, JSON.stringify(auth));
}
} else {
// Dev mode: no Electron API, use regular localStorage
log.info('auth-service', 'Running in dev mode, using localStorage');
localStorage.setItem(AuthService.AUTH_KEY, JSON.stringify(auth));
}
// Update cache for synchronous access
this.currentToken = auth.token;
this.currentUser = auth.userInfo;
} catch (error) {
log.error('auth-service', 'Failed to save auth data', { error });
throw error;
}
}
/**
* Get stored authentication data
* FIXED: Decrypts data from safeStorage
*/
private async getStoredAuth(): Promise<StoredAuth | null> {
try {
const stored = localStorage.getItem(AuthService.AUTH_KEY);
if (!stored) return null;
// Check Electron API availability
const hasElectronAPI = typeof window !== 'undefined' &&
window.electronAPI !== undefined &&
typeof window.electronAPI.decryptData === 'function';
// FIXED: Check if data is encrypted by first character
// Plain JSON always starts with '{', encrypted data does not
const isPlainJSON = stored.trim().startsWith('{');
if (!isPlainJSON && hasElectronAPI) {
try {
// Data is encrypted - decrypt via safeStorage
log.debug('auth-service', 'Decrypting stored auth with safeStorage');
const decrypted = await window.electronAPI.decryptData(stored);
return JSON.parse(decrypted);
} catch (decryptError) {
// Possibly corrupted data
log.error('auth-service', 'Failed to decrypt stored auth, clearing storage', { decryptError });
this.clearAuth();
return null;
}
} else {
// Unencrypted data (dev mode, old format or fallback)
log.debug('auth-service', 'Loading plain JSON auth from storage');
return JSON.parse(stored);
}
} catch (error) {
log.error('auth-service', 'Failed to parse stored auth', { error });
return null;
}
}
/**
* Clear authentication data
*/
private clearAuth(): void {
localStorage.removeItem(AuthService.AUTH_KEY);
// Clear cache
this.currentToken = null;
this.currentUser = null;
log.info('auth-service', 'Auth data cleared');
}
/**
* Set callback for token expiration handling
*/
setOnTokenExpired(callback: () => void): void {
this.onTokenExpiredCallback = callback;
}
/**
* Set callback for token expiration warning
*/
setOnTokenWarning(callback: (timeLeft: number) => void): void {
this.onTokenWarningCallback = callback;
}
/**
* Start token monitoring
*/
private startTokenMonitoring(): void {
this.stopTokenMonitoring();
// FIXED: Check token every 30 seconds with async getStoredAuth support
this.tokenCheckInterval = setInterval(async () => {
const stored = await this.getStoredAuth();
if (!stored) return;
// If token expires within next 5 minutes, notify
const timeUntilExpiry = stored.expiresAt - Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (timeUntilExpiry <= fiveMinutes) {
log.warn('auth-service', 'Token expires soon', {
timeUntilExpiry: Math.round(timeUntilExpiry / 1000)
});
if (timeUntilExpiry <= 0) {
log.error('auth-service', 'Token expired, logging out');
this.clearAuth();
this.stopTokenMonitoring();
if (this.onTokenExpiredCallback) {
this.onTokenExpiredCallback();
}
} else if (this.onTokenWarningCallback) {
this.onTokenWarningCallback(Math.round(timeUntilExpiry / 1000));
}
}
}, 30000); // Check every 30 seconds
}
/**
* Stop token monitoring
*/
private stopTokenMonitoring(): void {
if (this.tokenCheckInterval) {
clearInterval(this.tokenCheckInterval);
this.tokenCheckInterval = null;
}
}
}
export const authService = new AuthService();
export default authService;

View File

@ -0,0 +1,232 @@
/**
* Bulk Operations API Service
*
* Handles mass operations on multiple machines:
* - Health checks
* - SSH command execution (future)
* - Multi-connect (future)
*/
import { API_CONFIG } from '../config/api';
import { authService } from './auth-service';
// ========================================================================================
// Constants
// ========================================================================================
const DEFAULT_TIMEOUT = {
HEALTH_CHECK: 5, // seconds
SSH_COMMAND: 30, // seconds
} as const;
const HTTP_STATUS = {
FORBIDDEN: 403,
} as const;
const ROLE_LIMITS = {
GUEST: { maxHealthCheck: 10, maxSSHCommand: 0 },
USER: { maxHealthCheck: 50, maxSSHCommand: 20 },
ADMIN: { maxHealthCheck: 200, maxSSHCommand: 100 },
SUPER_ADMIN: { maxHealthCheck: 200, maxSSHCommand: 100 },
} as const;
const DEFAULT_CHECK_PORT = true;
// ========================================================================================
// Types
// ========================================================================================
export type HealthCheckStatus = 'success' | 'failed' | 'timeout';
export type SSHCommandStatus = 'success' | 'failed' | 'timeout' | 'no_credentials';
export type CredentialsMode = 'global' | 'custom';
export type UserRole = keyof typeof ROLE_LIMITS;
export interface BulkHealthCheckRequest {
machine_ids: string[];
timeout?: number;
check_port?: boolean;
}
export interface BulkHealthCheckResult {
machine_id: string;
machine_name: string;
hostname: string;
status: HealthCheckStatus;
available: boolean;
response_time_ms?: number;
error?: string;
checked_at: string;
}
export interface BulkHealthCheckResponse {
total: number;
success: number;
failed: number;
available: number;
unavailable: number;
results: BulkHealthCheckResult[];
execution_time_ms: number;
started_at: string;
completed_at: string;
}
// SSH Command Types
export interface SSHCredentials {
username: string;
password: string;
}
export interface BulkSSHCommandRequest {
machine_ids: string[];
machine_hostnames?: Record<string, string>; // For non-saved machines
command: string;
credentials_mode: CredentialsMode;
global_credentials?: SSHCredentials;
machine_credentials?: Record<string, SSHCredentials>;
timeout?: number;
}
export interface BulkSSHCommandResult {
machine_id: string;
machine_name: string;
hostname: string;
status: SSHCommandStatus;
exit_code?: number;
stdout?: string;
stderr?: string;
error?: string;
execution_time_ms?: number;
executed_at: string;
}
export interface BulkSSHCommandResponse {
total: number;
success: number;
failed: number;
results: BulkSSHCommandResult[];
execution_time_ms: number;
command: string;
started_at: string;
completed_at: string;
}
// ========================================================================================
// API Service
// ========================================================================================
class BulkOperationsApiService {
/**
* Bulk machine availability check
*
* Role-based limits:
* - GUEST: max 10 machines
* - USER: max 50 machines
* - ADMIN: max 200 machines
*/
async bulkHealthCheck(request: BulkHealthCheckRequest): Promise<BulkHealthCheckResponse> {
try {
const token = await authService.getToken();
if (!token) {
throw new Error('Authentication token not found. Please log in again.');
}
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.BULK.HEALTH_CHECK}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
machine_ids: request.machine_ids,
timeout: request.timeout ?? DEFAULT_TIMEOUT.HEALTH_CHECK,
check_port: request.check_port ?? DEFAULT_CHECK_PORT
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
if (response.status === HTTP_STATUS.FORBIDDEN) {
throw new Error(errorData.detail || 'Permission denied. Check role-based limits.');
}
throw new Error(errorData.detail || `Request failed with status ${response.status}`);
}
const data: BulkHealthCheckResponse = await response.json();
return data;
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to perform bulk health check');
}
}
/**
* Bulk SSH command execution
*
* Role-based limits:
* - GUEST: forbidden
* - USER: max 20 machines, whitelist commands only
* - ADMIN: max 100 machines, any commands
*/
async bulkSSHCommand(request: BulkSSHCommandRequest): Promise<BulkSSHCommandResponse> {
try {
const token = await authService.getToken();
if (!token) {
throw new Error('Authentication token not found. Please log in again.');
}
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.BULK.SSH_COMMAND}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
machine_ids: request.machine_ids,
machine_hostnames: request.machine_hostnames,
command: request.command,
credentials_mode: request.credentials_mode,
global_credentials: request.global_credentials,
machine_credentials: request.machine_credentials,
timeout: request.timeout ?? DEFAULT_TIMEOUT.SSH_COMMAND
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
if (response.status === HTTP_STATUS.FORBIDDEN) {
throw new Error(errorData.detail || 'Permission denied. Check role and command whitelist.');
}
throw new Error(errorData.detail || `Request failed with status ${response.status}`);
}
const data: BulkSSHCommandResponse = await response.json();
return data;
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to execute bulk SSH command');
}
}
/**
* Get role-based limit for bulk operations
*/
getRoleLimits(role: string): { maxHealthCheck: number; maxSSHCommand: number } {
return ROLE_LIMITS[role as UserRole] || ROLE_LIMITS.GUEST;
}
}
export const bulkOperationsApi = new BulkOperationsApiService();

View File

@ -0,0 +1,366 @@
import { log } from '../utils/logger';
import { authService } from './auth-service';
import { Machine } from '../types';
import { API_CONFIG } from '../config/api';
const DEFAULT_TTL_MINUTES = 60;
const DEFAULT_EXTEND_MINUTES = 60;
const MIN_REMAINING_MINUTES = 0;
const HTTP_STATUS = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
} as const;
const SESSION_EXPIRED_PATTERNS = [
'Session expired',
'Invalid session',
'encryption session has expired',
] as const;
const ERROR_MESSAGES = {
SESSION_EXPIRED: 'Your session has expired. Please log in again.',
CONNECTION_NOT_FOUND: 'Connection not found or already expired',
PERMISSION_DENIED: 'You do not have permission to extend this connection',
} as const;
const PROTOCOL_SSH = 'ssh';
const DEFAULT_PROTOCOL = 'rdp';
interface ConnectionRequest {
hostname: string;
protocol: string;
username?: string;
password?: string;
port?: number;
ttl_minutes?: number;
// SFTP parameters for SSH connections
enable_sftp?: boolean;
sftp_root_directory?: string;
sftp_server_alive_interval?: number;
}
interface ConnectionResponse {
connection_id: string;
connection_url: string;
status: string;
expires_at: string;
ttl_minutes: number;
}
interface ActiveConnection {
connection_id: string;
connection_url: string | null;
hostname: string;
protocol: string;
owner_username: string;
owner_role: string;
created_at: string;
expires_at: string;
ttl_minutes: number;
remaining_minutes: number;
status: 'active' | 'expired';
}
interface ConnectionsListResponse {
total_connections: number;
active_connections: number;
connections: ActiveConnection[];
}
class GuacamoleService {
/**
* Check if error detail indicates session expiry
*/
private isSessionExpiredError(errorDetail?: string): boolean {
if (!errorDetail) return false;
return SESSION_EXPIRED_PATTERNS.some(pattern =>
errorDetail.includes(pattern)
);
}
/**
* Handle 401 Unauthorized response
* Checks for session expiry and logs out if needed
*/
private async handle401Error(response: Response): Promise<void> {
if (response.status !== HTTP_STATUS.UNAUTHORIZED) {
return;
}
const errorData = await response.json().catch(() => ({}));
if (this.isSessionExpiredError(errorData.detail)) {
await authService.logout().catch(() => {});
throw new Error(ERROR_MESSAGES.SESSION_EXPIRED);
}
}
/**
* Create connection to machine
*/
async createConnection(
machine: Machine,
credentials?: {
username: string;
password: string;
protocol?: string;
enableSftp?: boolean;
sftpRootDirectory?: string;
sftpServerAliveInterval?: number;
}
): Promise<ConnectionResponse> {
try {
log.info('guacamole-service', 'Creating connection', {
machineId: machine.id,
machineName: machine.name,
ipAddress: machine.ip,
hypervisor: machine.hypervisor,
hostnameToUse: machine.hypervisor === 'Saved' ? machine.ip : machine.name
});
const protocol = credentials?.protocol || DEFAULT_PROTOCOL;
const request: ConnectionRequest = {
hostname: machine.hypervisor === 'Saved' ? machine.ip : machine.name,
protocol: protocol,
ttl_minutes: DEFAULT_TTL_MINUTES,
...(credentials?.username && { username: credentials.username }),
...(credentials?.password && { password: credentials.password }),
// SFTP parameters for SSH (enabled by default for SSH)
...(protocol === PROTOCOL_SSH && {
enable_sftp: credentials?.enableSftp !== undefined ? credentials.enableSftp : true,
...(credentials?.sftpRootDirectory && { sftp_root_directory: credentials.sftpRootDirectory }),
...(credentials?.sftpServerAliveInterval && { sftp_server_alive_interval: credentials.sftpServerAliveInterval })
})
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
};
// CSRF token NOT needed for JWT API
// JWT Bearer tokens are not vulnerable to CSRF attacks
log.info('guacamole-service', 'Sending request', {
url: `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`,
method: 'POST',
headers: Object.keys(headers),
hasAuthHeader: !!headers['Authorization'],
requestBody: request
});
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`, {
method: 'POST',
headers,
body: JSON.stringify(request)
});
if (!response.ok) {
// Handle 401 Unauthorized (Session expired)
if (response.status === HTTP_STATUS.UNAUTHORIZED) {
const errorData = await response.json().catch(() => ({}));
log.error('guacamole-service', 'Session expired or invalid', {
errorDetail: errorData.detail,
machineId: machine.id
});
await this.handle401Error(response);
}
const errorData = await response.json().catch(() => ({}));
log.error('guacamole-service', 'API request failed', {
status: response.status,
statusText: response.statusText,
errorData,
url: `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`,
requestBody: request
});
throw new Error(errorData.detail || `Failed to create connection: ${response.status} ${response.statusText}`);
}
const data: ConnectionResponse = await response.json();
log.info('guacamole-service', 'Connection created successfully', {
machineId: machine.id,
connectionId: data.connection_id,
expiresAt: data.expires_at
});
return data;
} catch (error) {
log.error('guacamole-service', 'Failed to create connection', {
machineId: machine.id,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw error;
}
}
/**
* Get list of active connections
*/
async listConnections(): Promise<any[]> {
try {
const headers: Record<string, string> = {
...authService.getAuthHeaders()
};
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.LIST}`, {
method: 'GET',
headers
});
if (!response.ok) {
await this.handle401Error(response);
throw new Error(`Failed to list connections: ${response.statusText}`);
}
const connections = await response.json();
log.info('guacamole-service', 'Connections list retrieved', {
count: connections.length
});
return connections;
} catch (error) {
log.error('guacamole-service', 'Failed to list connections', { error });
throw error;
}
}
/**
* Get active connections for restoration
* Returns only connections with 'active' status and valid connection_url
*/
async getActiveConnections(): Promise<ActiveConnection[]> {
try {
log.info('guacamole-service', 'Fetching active connections for restoration');
const headers: Record<string, string> = {
...authService.getAuthHeaders()
};
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.LIST}`, {
method: 'GET',
headers
});
if (!response.ok) {
await this.handle401Error(response);
throw new Error(`Failed to get active connections: ${response.statusText}`);
}
const data: ConnectionsListResponse = await response.json();
// Filter only active connections with valid connection_url
const activeConnections = data.connections.filter(
conn => conn.status === 'active' &&
conn.connection_url !== null &&
conn.remaining_minutes > MIN_REMAINING_MINUTES
);
log.info('guacamole-service', 'Active connections retrieved', {
total: data.total_connections,
active: activeConnections.length,
restorable: activeConnections.filter(c => c.connection_url).length
});
return activeConnections;
} catch (error) {
log.error('guacamole-service', 'Failed to get active connections', {
error: error instanceof Error ? error.message : 'Unknown error'
});
throw error;
}
}
/**
* Delete connection
*/
async deleteConnection(connectionId: string): Promise<void> {
try {
const headers: Record<string, string> = {
...authService.getAuthHeaders()
};
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.DELETE(connectionId)}`, {
method: 'DELETE',
headers
});
if (!response.ok) {
await this.handle401Error(response);
throw new Error(`Failed to delete connection: ${response.statusText}`);
}
log.info('guacamole-service', 'Connection deleted', { connectionId });
} catch (error) {
log.error('guacamole-service', 'Failed to delete connection', { connectionId, error });
throw error;
}
}
/**
* Extend TTL of active connection
*/
async extendConnectionTTL(connectionId: string, additionalMinutes: number = DEFAULT_EXTEND_MINUTES): Promise<{ new_expires_at: string; additional_minutes: number }> {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
};
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.EXTEND(connectionId)}`, {
method: 'POST',
headers,
body: JSON.stringify({ additional_minutes: additionalMinutes })
});
if (!response.ok) {
await this.handle401Error(response);
if (response.status === HTTP_STATUS.NOT_FOUND) {
throw new Error(ERROR_MESSAGES.CONNECTION_NOT_FOUND);
}
if (response.status === HTTP_STATUS.FORBIDDEN) {
throw new Error(ERROR_MESSAGES.PERMISSION_DENIED);
}
throw new Error(`Failed to extend connection: ${response.statusText}`);
}
const data = await response.json();
log.info('guacamole-service', 'Connection TTL extended', {
connectionId,
additionalMinutes,
newExpiresAt: data.new_expires_at
});
return {
new_expires_at: data.new_expires_at,
additional_minutes: data.additional_minutes
};
} catch (error) {
log.error('guacamole-service', 'Failed to extend connection TTL', { connectionId, additionalMinutes, error });
throw error;
}
}
/**
* Get connection URL
* This method now simply returns the URL from API response
*/
getConnectionUrl(connectionUrl: string): string {
return connectionUrl;
}
}
export const guacamoleService = new GuacamoleService();
export default guacamoleService;
export type { ActiveConnection, ConnectionResponse, ConnectionsListResponse };

View File

@ -0,0 +1,176 @@
import { API_CONFIG } from '../config/api';
import { authService } from './auth-service';
import { log } from '../utils/logger';
import type { Machine } from '../types';
/**
* Result of machine availability check
*/
export interface MachineAvailabilityResult {
available: boolean;
hostname: string;
port: number;
responseTimeMs?: number;
checkedAt: string;
}
/**
* Request body for availability check
*/
interface AvailabilityCheckRequest {
hostname: string;
port: number;
}
const DEFAULT_PORTS = {
RDP: 3389,
SSH: 22,
} as const;
const OS_IDENTIFIER = {
WINDOWS: 'windows',
} as const;
/**
* Service for checking machine availability
*
* Performs machine availability checks via API server
* to avoid network restrictions on the client side.
*/
export class MachineAvailabilityService {
/**
* Determine default port based on machine OS
*
* Simple logic: Windows → RDP (3389), everything else → SSH (22)
*/
private static getDefaultPort(machine: Machine): number {
const os = machine.os.toLowerCase();
if (os.includes(OS_IDENTIFIER.WINDOWS)) {
return DEFAULT_PORTS.RDP;
}
return DEFAULT_PORTS.SSH;
}
/**
* Create an unavailable result object
*/
private static createUnavailableResult(
hostname: string,
port: number
): MachineAvailabilityResult {
return {
available: false,
hostname,
port,
checkedAt: new Date().toISOString(),
};
}
/**
* Check machine availability
*
* @param machine - Machine to check
* @param port - Port to check (optional, determined by OS)
* @returns Availability check result
*/
static async checkAvailability(
machine: Machine,
port?: number
): Promise<MachineAvailabilityResult> {
try {
const targetPort = port || this.getDefaultPort(machine);
log.info('machine-availability', 'Checking machine availability', {
machineId: machine.id,
hostname: machine.name,
os: machine.os,
port: targetPort,
portSource: port ? 'explicit' : 'auto-detected',
});
const headers = await authService.getAuthHeaders();
const requestBody: AvailabilityCheckRequest = {
hostname: machine.name,
port: targetPort,
};
const response = await fetch(
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.MACHINES.CHECK_AVAILABILITY}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify(requestBody),
}
);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
log.error('machine-availability', 'Check failed', {
hostname: machine.name,
status: response.status,
error: errorText,
});
return this.createUnavailableResult(machine.name, targetPort);
}
const data: MachineAvailabilityResult = await response.json();
log.info('machine-availability', 'Check completed', {
machineId: machine.id,
hostname: data.hostname,
available: data.available,
responseTimeMs: data.responseTimeMs,
});
return data;
} catch (error) {
const targetPort = port || this.getDefaultPort(machine);
log.error('machine-availability', 'Check failed with exception', {
hostname: machine.name,
error,
});
return this.createUnavailableResult(machine.name, targetPort);
}
}
/**
* Check availability of multiple machines simultaneously
*
* @param machines - Array of machines to check
* @param port - Port to check (optional)
* @returns Map with check results (machineId -> result)
*/
static async checkMultiple(
machines: Machine[],
port?: number
): Promise<Map<string, MachineAvailabilityResult>> {
const results = new Map<string, MachineAvailabilityResult>();
const promises = machines.map(async (machine) => {
const result = await this.checkAvailability(machine, port);
return { machineId: machine.id, result };
});
const completed = await Promise.allSettled(promises);
completed.forEach((promise) => {
if (promise.status === 'fulfilled') {
results.set(promise.value.machineId, promise.value.result);
}
});
return results;
}
}
export const machineAvailabilityService = MachineAvailabilityService;

View File

@ -0,0 +1,320 @@
/**
* API Service for working with saved machines
*/
import { ApiClient } from '../utils/apiClient';
import { authService } from './auth-service';
import { log } from '../utils/logger';
import { API_CONFIG } from '../config/api';
export type Protocol = 'rdp' | 'ssh' | 'vnc' | 'telnet';
export interface ConnectionStats {
total_connections: number;
last_connection?: string;
successful_connections: number;
failed_connections: number;
}
export interface ConnectionRecordResponse {
success: boolean;
message: string;
history_id: string;
}
export interface SavedMachineCreate {
name: string;
hostname: string;
port: number;
protocol: Protocol;
os?: string;
description?: string;
tags?: string[];
is_favorite?: boolean;
}
export interface SavedMachineUpdate {
name?: string;
hostname?: string;
port?: number;
protocol?: Protocol;
os?: string;
description?: string;
tags?: string[];
is_favorite?: boolean;
}
export interface SavedMachine {
id: string;
user_id: string;
name: string;
hostname: string;
port: number;
protocol: Protocol;
os?: string;
description?: string;
tags: string[];
is_favorite: boolean;
created_at: string;
updated_at: string;
last_connected_at?: string;
connection_stats?: ConnectionStats;
}
export interface SavedMachineList {
total: number;
machines: SavedMachine[];
}
/**
* Service for working with saved machines
*/
export class SavedMachinesApiService {
private static baseUrl = `${API_CONFIG.BASE_URL}/api/machines/saved`;
/**
* Get list of all user's saved machines
*/
static async getSavedMachines(includeStats: boolean = false): Promise<SavedMachineList> {
try {
const headers = await authService.getAuthHeaders();
log.info('saved-machines-api', 'Fetching saved machines', { includeStats });
const response = await ApiClient.get<SavedMachineList>(
`${this.baseUrl}?include_stats=${includeStats}`,
{ headers }
);
log.info('saved-machines-api', 'Saved machines fetched successfully', {
total: response.total
});
return response;
} catch (error) {
log.error('saved-machines-api', 'Failed to fetch saved machines', { error });
throw error;
}
}
/**
* Get specific machine by ID
*/
static async getSavedMachine(machineId: string): Promise<SavedMachine> {
try {
const headers = await authService.getAuthHeaders();
log.info('saved-machines-api', 'Fetching saved machine', { machineId });
const response = await ApiClient.get<SavedMachine>(
`${this.baseUrl}/${machineId}`,
{ headers }
);
log.info('saved-machines-api', 'Saved machine fetched successfully', {
machineId,
name: response.name
});
return response;
} catch (error) {
log.error('saved-machines-api', 'Failed to fetch saved machine', {
machineId,
error
});
throw error;
}
}
/**
* Create new saved machine
*/
static async createSavedMachine(machine: SavedMachineCreate): Promise<SavedMachine> {
try {
const headers = await authService.getAuthHeaders();
log.info('saved-machines-api', 'Creating saved machine', {
name: machine.name,
hostname: machine.hostname,
protocol: machine.protocol
});
const response = await ApiClient.post<SavedMachine>(
this.baseUrl,
machine,
{ headers }
);
log.info('saved-machines-api', 'Saved machine created successfully', {
machineId: response.id,
name: response.name
});
return response;
} catch (error) {
log.error('saved-machines-api', 'Failed to create saved machine', {
name: machine.name,
error
});
throw error;
}
}
/**
* Update saved machine
*/
static async updateSavedMachine(
machineId: string,
updates: SavedMachineUpdate
): Promise<SavedMachine> {
try {
const headers = await authService.getAuthHeaders();
log.info('saved-machines-api', 'Updating saved machine', {
machineId,
updates: Object.keys(updates)
});
const response = await ApiClient.put<SavedMachine>(
`${this.baseUrl}/${machineId}`,
updates,
{ headers }
);
log.info('saved-machines-api', 'Saved machine updated successfully', {
machineId,
name: response.name
});
return response;
} catch (error) {
log.error('saved-machines-api', 'Failed to update saved machine', {
machineId,
error
});
throw error;
}
}
/**
* Delete saved machine
*/
static async deleteSavedMachine(machineId: string): Promise<void> {
try {
const headers = await authService.getAuthHeaders();
log.info('saved-machines-api', 'Deleting saved machine', { machineId });
await ApiClient.delete(
`${this.baseUrl}/${machineId}`,
{ headers }
);
log.info('saved-machines-api', 'Saved machine deleted successfully', {
machineId
});
} catch (error) {
log.error('saved-machines-api', 'Failed to delete saved machine', {
machineId,
error
});
throw error;
}
}
/**
* Record connection to machine
*/
static async connectToSavedMachine(
machineId: string
): Promise<ConnectionRecordResponse> {
try {
const headers = await authService.getAuthHeaders();
log.info('saved-machines-api', 'Recording connection to saved machine', {
machineId
});
const response = await ApiClient.post<ConnectionRecordResponse>(
`${this.baseUrl}/${machineId}/connect`,
{},
{ headers }
);
log.info('saved-machines-api', 'Connection recorded successfully', {
machineId,
historyId: response.history_id
});
return response;
} catch (error) {
log.error('saved-machines-api', 'Failed to record connection', {
machineId,
error
});
throw error;
}
}
/**
* Toggle favorite status
*/
static async toggleFavorite(
machineId: string,
isFavorite: boolean
): Promise<SavedMachine> {
return this.updateSavedMachine(machineId, { is_favorite: isFavorite });
}
/**
* Update machine tags
*/
static async updateTags(machineId: string, tags: string[]): Promise<SavedMachine> {
return this.updateSavedMachine(machineId, { tags });
}
/**
* Filter machines by tags
*/
static filterByTags(machines: SavedMachine[], tags: string[]): SavedMachine[] {
if (!tags || tags.length === 0) {
return machines;
}
return machines.filter((machine) => tags.some((tag) => machine.tags.includes(tag)));
}
/**
* Filter machines by protocol
*/
static filterByProtocol(machines: SavedMachine[], protocol: string): SavedMachine[] {
return machines.filter((machine) => machine.protocol === protocol);
}
/**
* Filter only favorites
*/
static filterFavorites(machines: SavedMachine[]): SavedMachine[] {
return machines.filter((machine) => machine.is_favorite);
}
/**
* Search by name or hostname
*/
static search(machines: SavedMachine[], query: string): SavedMachine[] {
if (!query) {
return machines;
}
const lowerQuery = query.toLowerCase();
return machines.filter(
(machine) =>
machine.name.toLowerCase().includes(lowerQuery) ||
machine.hostname.toLowerCase().includes(lowerQuery) ||
machine.description?.toLowerCase().includes(lowerQuery)
);
}
}
export const savedMachinesApi = SavedMachinesApiService;

View File

@ -0,0 +1,384 @@
/**
* WebSocket Service for real-time notifications
*
* Events:
* - connection_expired: Connection expired
* - connection_deleted: Connection deleted
* - connection_will_expire: Connection will expire soon (in 5 min)
* - connection_extended: Connection extended
* - jwt_will_expire: JWT will expire soon (in 5 min)
* - jwt_expired: JWT expired
*/
import { log } from '../utils/logger';
import { API_CONFIG } from '../config/api';
export type WebSocketEventType =
| 'connection_expired'
| 'connection_deleted'
| 'connection_will_expire'
| 'connection_extended'
| 'jwt_will_expire'
| 'jwt_expired'
| 'connected'
| 'ping'
| 'pong';
export interface WebSocketEvent {
type: WebSocketEventType;
timestamp: string;
data?: any;
}
export interface WebSocketMessage {
type: string;
token?: string;
timestamp?: string;
}
export interface ConnectionExpiredEvent {
connection_id: string;
hostname: string;
protocol: string;
reason: string;
}
export interface ConnectionWillExpireEvent {
connection_id: string;
hostname: string;
protocol: string;
minutes_remaining: number;
}
export interface ConnectionExtendedEvent {
connection_id: string;
hostname: string;
new_expires_at: string;
additional_minutes: number;
}
type EventHandler = (event: WebSocketEvent) => void;
const WEBSOCKET_CONFIG = {
MAX_RECONNECT_ATTEMPTS: 10,
INITIAL_RECONNECT_DELAY: 1000, // 1 second
MAX_RECONNECT_DELAY: 30000, // 30 seconds
PING_INTERVAL: 25000, // 25 seconds
CONNECTION_TIMEOUT: 5000, // 5 seconds
EXPONENTIAL_BACKOFF_BASE: 2,
} as const;
const WEBSOCKET_PATH = '/ws/notifications';
const ALL_EVENTS_KEY = '*';
const PROTOCOL_REPLACEMENT = {
HTTPS: 'wss://',
HTTP: 'ws://',
} as const;
class WebSocketNotificationService {
private ws: WebSocket | null = null;
private reconnectTimer: NodeJS.Timeout | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = WEBSOCKET_CONFIG.MAX_RECONNECT_ATTEMPTS;
private readonly reconnectDelay = WEBSOCKET_CONFIG.INITIAL_RECONNECT_DELAY;
private readonly maxReconnectDelay = WEBSOCKET_CONFIG.MAX_RECONNECT_DELAY;
private isIntentionallyDisconnected = false;
private pingTimer: NodeJS.Timeout | null = null;
private readonly pingInterval = WEBSOCKET_CONFIG.PING_INTERVAL;
private eventHandlers: Map<string, Set<EventHandler>> = new Map();
/**
* Connect to WebSocket server
*/
connect(token: string): Promise<void> {
return new Promise((resolve, reject) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
log.info('websocket', 'Already connected');
resolve();
return;
}
this.isIntentionallyDisconnected = false;
// Determine WebSocket URL
const wsUrl = API_CONFIG.BASE_URL
.replace('https://', PROTOCOL_REPLACEMENT.HTTPS)
.replace('http://', PROTOCOL_REPLACEMENT.HTTP);
const fullUrl = `${wsUrl}${WEBSOCKET_PATH}`;
log.info('websocket', 'Connecting to WebSocket', { url: fullUrl });
try {
this.ws = new WebSocket(fullUrl);
this.ws.onopen = () => {
log.info('websocket', 'WebSocket connected');
// Send authentication
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'auth',
token
}));
log.info('websocket', 'Sent authentication');
// Wait for connected confirmation
const connectTimeout = setTimeout(() => {
log.error('websocket', 'Connection confirmation timeout');
this.disconnect();
reject(new Error('Connection confirmation timeout'));
}, WEBSOCKET_CONFIG.CONNECTION_TIMEOUT);
// Temporary handler for connected
const tempOnMessage = (event: MessageEvent) => {
try {
const message: WebSocketEvent = JSON.parse(event.data);
if (message.type === 'connected') {
clearTimeout(connectTimeout);
log.info('websocket', 'Authentication confirmed', message.data);
// Reset reconnect attempts
this.reconnectAttempts = 0;
// Start ping timer
this.startPingTimer();
// Set main message handler
if (this.ws) {
this.ws.onmessage = (e) => this.handleMessage(e);
}
resolve();
}
} catch (error) {
log.error('websocket', 'Failed to parse auth response', { error });
}
};
this.ws.onmessage = tempOnMessage;
}
};
this.ws.onerror = (error) => {
log.error('websocket', 'WebSocket error', { error });
reject(error);
};
this.ws.onclose = (event) => {
log.info('websocket', 'WebSocket closed', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
this.stopPingTimer();
// Auto-reconnect if not intentionally disconnected
if (!this.isIntentionallyDisconnected && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect(token);
}
};
} catch (error) {
log.error('websocket', 'Failed to create WebSocket', { error });
reject(error);
}
});
}
/**
* Disconnect from WebSocket server
*/
disconnect(): void {
this.isIntentionallyDisconnected = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopPingTimer();
if (this.ws) {
this.ws.close();
this.ws = null;
}
log.info('websocket', 'Disconnected');
}
/**
* Schedule reconnection with exponential backoff
*/
private scheduleReconnect(token: string): void {
if (this.reconnectTimer) {
return; // Already scheduled
}
this.reconnectAttempts++;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
const delay = Math.min(
this.reconnectDelay * Math.pow(WEBSOCKET_CONFIG.EXPONENTIAL_BACKOFF_BASE, this.reconnectAttempts - 1),
this.maxReconnectDelay
);
log.info('websocket', 'Scheduling reconnect', {
attempt: this.reconnectAttempts,
delay: `${delay}ms`
});
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect(token).catch((error) => {
log.error('websocket', 'Reconnect failed', { error });
});
}, delay);
}
/**
* Start ping timer for keep-alive
*/
private startPingTimer(): void {
this.stopPingTimer();
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'ping',
timestamp: new Date().toISOString()
}));
log.debug('websocket', 'Sent ping');
}
}, this.pingInterval);
}
/**
* Stop ping timer
*/
private stopPingTimer(): void{
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
/**
* Handle incoming message
*/
private handleMessage(event: MessageEvent): void {
try {
const message: WebSocketEvent = JSON.parse(event.data);
if (message.type === 'pong') {
log.debug('websocket', 'Received pong');
return;
}
if (message.type === 'ping') {
// Respond with pong
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'pong',
timestamp: new Date().toISOString()
}));
}
return;
}
log.info('websocket', 'Received event', {
type: message.type,
timestamp: message.timestamp
});
// Call handlers for this event type
const handlers = this.eventHandlers.get(message.type);
if (handlers) {
handlers.forEach(handler => {
try {
handler(message);
} catch (error) {
log.error('websocket', 'Event handler error', {
type: message.type,
error
});
}
});
}
// Also call handlers for '*' (all events)
const allHandlers = this.eventHandlers.get(ALL_EVENTS_KEY);
if (allHandlers) {
allHandlers.forEach(handler => {
try {
handler(message);
} catch (error) {
log.error('websocket', 'Global event handler error', {
type: message.type,
error
});
}
});
}
} catch (error) {
log.error('websocket', 'Failed to parse message', { error });
}
}
/**
* Subscribe to events
*/
on(eventType: string, handler: EventHandler): () => void {
if (!this.eventHandlers.has(eventType)) {
this.eventHandlers.set(eventType, new Set());
}
this.eventHandlers.get(eventType)!.add(handler);
log.info('websocket', 'Registered event handler', { eventType });
// Return unsubscribe function
return () => {
const handlers = this.eventHandlers.get(eventType);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.eventHandlers.delete(eventType);
}
}
};
}
/**
* Unsubscribe from all events
*/
removeAllListeners(): void {
this.eventHandlers.clear();
log.info('websocket', 'Removed all event handlers');
}
/**
* Check connection status
*/
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
/**
* Get number of reconnect attempts
*/
getReconnectAttempts(): number {
return this.reconnectAttempts;
}
}
// Singleton instance
export const websocketNotificationService = new WebSocketNotificationService();

View File

@ -0,0 +1,495 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { Machine, LogEntry } from '../types'
import { log } from '../utils/logger'
import { mockMachines } from '../mocks/mockMachines'
import { guacamoleService } from '../services/guacamole-api'
import { savedMachinesApi, SavedMachine, SavedMachineCreate, SavedMachineUpdate } from '../services/saved-machines-api'
const DEFAULT_OS = 'Unknown';
const SAVED_HYPERVISOR = 'Saved';
const SPEC_NOT_AVAILABLE = 'N/A';
/**
* Convert SavedMachine to Machine for display in Sidebar
*/
function convertSavedMachineToMachine(saved: SavedMachine): Machine {
return {
id: saved.id,
name: saved.name,
status: 'unknown',
os: saved.os && saved.os.trim() !== '' ? saved.os : DEFAULT_OS,
ip: saved.hostname,
hypervisor: SAVED_HYPERVISOR,
port: saved.port,
protocol: saved.protocol,
specs: {
cpu: SPEC_NOT_AVAILABLE,
ram: SPEC_NOT_AVAILABLE,
disk: SPEC_NOT_AVAILABLE
},
// Mark as saved machine to distinguish from mock
testLinks: [{
url: `${saved.protocol}://${saved.hostname}:${saved.port}`,
name: `${saved.protocol.toUpperCase()} (Port ${saved.port})`
}],
logs: []
};
}
interface MachineState {
machines: Machine[];
selectedMachine: Machine | null;
loading: boolean;
error: string | null;
guacamoleVisible: boolean;
searchQuery: string;
filteredMachines: Machine[];
// Saved machines
savedMachines: SavedMachine[];
savedMachinesLoading: boolean;
showAddMachineModal: boolean;
showSaveConfirmModal: boolean;
machineToSave: SavedMachineCreate | null;
// Actions
setMachines: (machines: Machine[]) => void;
selectMachine: (machine: Machine | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setGuacamoleVisible: (visible: boolean) => void;
setSearchQuery: (query: string) => void;
setShowAddMachineModal: (show: boolean) => void;
setShowSaveConfirmModal: (show: boolean) => void;
setMachineToSave: (machine: SavedMachineCreate | null) => void;
// Async actions
fetchMachines: () => Promise<void>;
connectToMachine: (machine: Machine) => Promise<void>;
controlMachine: (machineId: string, action: 'start' | 'stop' | 'restart') => Promise<void>;
connectGuacamole: (machine: Machine) => Promise<void>;
// Saved machines actions
fetchSavedMachines: (includeStats?: boolean) => Promise<void>;
createSavedMachine: (machine: SavedMachineCreate) => Promise<SavedMachine>;
updateSavedMachine: (machineId: string, updates: SavedMachineUpdate) => Promise<SavedMachine>;
deleteSavedMachine: (machineId: string) => Promise<void>;
toggleFavorite: (machineId: string) => Promise<void>;
connectToSavedMachine: (machineId: string) => Promise<void>;
// Utility actions
resetState: () => void;
}
export const useMachineStore = create<MachineState>()(
persist(
(set, get) => {
// Add debug log when creating store
log.info('store', 'Initializing machine store with persistence');
return {
machines: [],
selectedMachine: null,
loading: false,
error: null,
guacamoleVisible: false,
searchQuery: '',
filteredMachines: [],
// Saved machines state
savedMachines: [],
savedMachinesLoading: false,
showAddMachineModal: false,
showSaveConfirmModal: false,
machineToSave: null,
// Synchronous actions
setMachines: (machines) => {
log.info('store', 'Setting machines', { count: machines.length });
set({
machines,
filteredMachines: machines.filter(machine =>
machine.name.toLowerCase().includes(get().searchQuery.toLowerCase()) ||
machine.ip.toLowerCase().includes(get().searchQuery.toLowerCase())
)
});
},
selectMachine: (machine) => set({ selectedMachine: machine }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setGuacamoleVisible: (visible) => set({ guacamoleVisible: visible }),
setShowAddMachineModal: (show) => set({ showAddMachineModal: show }),
setShowSaveConfirmModal: (show) => set({ showSaveConfirmModal: show }),
setMachineToSave: (machine) => set({ machineToSave: machine }),
setSearchQuery: (query) => {
const { machines } = get();
set({
searchQuery: query,
filteredMachines: machines.filter(machine =>
machine.name.toLowerCase().includes(query.toLowerCase()) ||
machine.ip.toLowerCase().includes(query.toLowerCase())
)
});
},
// Asynchronous actions
fetchMachines: async () => {
const { setLoading, setError, savedMachines } = get();
setLoading(true);
try {
if (import.meta.env.DEV) {
// In development mode, merge saved machines with mock data
const savedAsMachines = savedMachines.map(convertSavedMachineToMachine);
const allMachines = [...savedAsMachines, ...mockMachines];
log.info('store', 'Merging saved and mock machines', {
savedCount: savedAsMachines.length,
mockCount: mockMachines.length,
totalCount: allMachines.length
});
set({
machines: allMachines,
filteredMachines: allMachines.filter(machine =>
machine.name.toLowerCase().includes(get().searchQuery.toLowerCase()) ||
machine.ip.toLowerCase().includes(get().searchQuery.toLowerCase())
)
});
setError(null);
} else {
const result = await window.electronAPI.getMachineList();
if (result.success && result.machines) {
log.info('store', 'Machines fetched successfully', {
count: result.machines.length
});
// In production, also merge with saved machines
const savedAsMachines = savedMachines.map(convertSavedMachineToMachine);
const allMachines = [...savedAsMachines, ...result.machines];
set({
machines: allMachines,
filteredMachines: allMachines.filter(machine =>
machine.name.toLowerCase().includes(get().searchQuery.toLowerCase()) ||
machine.ip.toLowerCase().includes(get().searchQuery.toLowerCase())
)
});
setError(null);
} else {
throw new Error(result.message || 'Failed to fetch machines');
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch machines';
log.error('store', 'Failed to fetch machines', { error: errorMessage });
setError(errorMessage);
} finally {
setLoading(false);
}
},
connectToMachine: async (machine) => {
const { setError } = get();
try {
log.info('store', 'Connecting to machine', {
machineId: machine.id,
machineName: machine.name
});
if (import.meta.env.DEV) {
// In development mode, simulate successful connection
log.info('store', 'Mock connection successful');
setError(null);
} else {
const result = await window.electronAPI.connectToMachine(machine);
if (!result.success) {
throw new Error(result.message);
}
log.info('store', 'Connected to machine successfully', {
machineId: machine.id
});
setError(null);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to machine';
log.error('store', 'Connection failed', {
machineId: machine.id,
error: errorMessage
});
setError(errorMessage);
throw error;
}
},
controlMachine: async (machineId, action) => {
const { setError, fetchMachines, machines } = get();
try {
log.info('store', `Controlling machine: ${action}`, { machineId });
if (import.meta.env.DEV) {
// In development mode, simulate state change
const updatedMachines = machines.map(machine => {
if (machine.id === machineId) {
const newLog: LogEntry = {
timestamp: new Date().toISOString(),
message: `Machine ${action} successful`,
level: 'info'
};
return {
...machine,
status: action === 'start' ? 'running' : action === 'stop' ? 'stopped' : machine.status,
logs: [newLog, ...machine.logs]
};
}
return machine;
});
set({ machines: updatedMachines });
log.info('store', `Mock machine ${action} successful`, { machineId });
setError(null);
} else {
const result = await window.electronAPI.controlMachine(machineId, action);
if (!result.success) {
throw new Error(result.message);
}
log.info('store', `Machine ${action} successful`, { machineId });
setError(null);
await fetchMachines();
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : `Failed to ${action} machine`;
log.error('store', `Machine ${action} failed`, {
machineId,
error: errorMessage
});
setError(errorMessage);
throw error;
}
},
connectGuacamole: async (machine) => {
const { setError, setGuacamoleVisible } = get();
try {
log.info('store', 'Connecting to Guacamole', {
machineId: machine.id,
machineName: machine.name
});
// In development mode, just show component
if (import.meta.env.DEV) {
setGuacamoleVisible(true);
return;
}
// In production, create connection
const result = await guacamoleService.createConnection(machine);
if (!result.connection_url) {
throw new Error('Failed to create Guacamole connection');
}
setGuacamoleVisible(true);
setError(null);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to Guacamole';
log.error('store', 'Guacamole connection failed', {
machineId: machine.id,
error: errorMessage
});
setError(errorMessage);
throw error;
}
},
// ========================================================================================
// Saved Machines Actions
// ========================================================================================
fetchSavedMachines: async (includeStats = false) => {
set({ savedMachinesLoading: true });
try {
log.info('store', 'Fetching saved machines', { includeStats });
const result = await savedMachinesApi.getSavedMachines(includeStats);
set({
savedMachines: result.machines,
savedMachinesLoading: false
});
log.info('store', 'Saved machines fetched successfully', {
total: result.total
});
// After loading saved machines, update general list
// (fetchMachines will merge savedMachines with mock/electron data)
await get().fetchMachines();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch saved machines';
log.error('store', 'Failed to fetch saved machines', { error: errorMessage });
set({
error: errorMessage,
savedMachinesLoading: false
});
throw error;
}
},
createSavedMachine: async (machine) => {
try {
log.info('store', 'Creating saved machine', { name: machine.name });
const created = await savedMachinesApi.createSavedMachine(machine);
// Add to savedMachines
set(state => ({
savedMachines: [...state.savedMachines, created]
}));
// Update general list (fetchMachines will merge savedMachines with mock)
await get().fetchMachines();
log.info('store', 'Saved machine created', {
id: created.id,
name: created.name
});
return created;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to create saved machine';
log.error('store', 'Failed to create saved machine', { error: errorMessage });
set({ error: errorMessage });
throw error;
}
},
updateSavedMachine: async (machineId, updates) => {
try {
log.info('store', 'Updating saved machine', { machineId, updates });
const updated = await savedMachinesApi.updateSavedMachine(machineId, updates);
// Update in savedMachines
set(state => ({
savedMachines: state.savedMachines.map(m =>
m.id === machineId ? updated : m
)
}));
// Update general list
await get().fetchMachines();
log.info('store', 'Saved machine updated', { id: updated.id });
return updated;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to update saved machine';
log.error('store', 'Failed to update saved machine', { error: errorMessage });
set({ error: errorMessage });
throw error;
}
},
deleteSavedMachine: async (machineId) => {
try {
log.info('store', 'Deleting saved machine', { machineId });
await savedMachinesApi.deleteSavedMachine(machineId);
// Remove from savedMachines
set(state => ({
savedMachines: state.savedMachines.filter(m => m.id !== machineId)
}));
// Update general list
await get().fetchMachines();
log.info('store', 'Saved machine deleted', { machineId });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to delete saved machine';
log.error('store', 'Failed to delete saved machine', { error: errorMessage });
set({ error: errorMessage });
throw error;
}
},
toggleFavorite: async (machineId) => {
try {
const machine = get().savedMachines.find(m => m.id === machineId);
if (!machine) {
throw new Error('Machine not found');
}
log.info('store', 'Toggling favorite', { machineId, current: machine.is_favorite });
await get().updateSavedMachine(machineId, {
is_favorite: !machine.is_favorite
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to toggle favorite';
log.error('store', 'Failed to toggle favorite', { error: errorMessage });
set({ error: errorMessage });
throw error;
}
},
connectToSavedMachine: async (machineId) => {
try {
log.info('store', 'Connecting to saved machine', { machineId });
// Record connection in history
await savedMachinesApi.connectToSavedMachine(machineId);
// Update local list
await get().fetchSavedMachines();
log.info('store', 'Connected to saved machine', { machineId });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to saved machine';
log.error('store', 'Failed to connect to saved machine', { error: errorMessage });
set({ error: errorMessage });
throw error;
}
},
// ========================================================================================
// Utility Actions
// ========================================================================================
resetState: () => {
log.info('store', 'Resetting all state (logout/user switch)');
set({
machines: [],
selectedMachine: null,
loading: false,
error: null,
guacamoleVisible: false,
searchQuery: '',
filteredMachines: [],
savedMachines: [],
savedMachinesLoading: false,
showAddMachineModal: false,
showSaveConfirmModal: false,
machineToSave: null
});
log.info('store', 'State reset complete');
}
};
},
{
name: 'machine-store', // Unique name for localStorage
storage: createJSONStorage(() => localStorage),
// Choose what to persist (don't persist loading states and modals)
partialize: (state) => ({
machines: state.machines,
selectedMachine: state.selectedMachine,
savedMachines: state.savedMachines,
searchQuery: state.searchQuery,
}),
}
)
);

View File

@ -0,0 +1,77 @@
@font-face {
font-family: 'Kaspersky Sans Text';
src: url('/fonts/KasperskySansText-Regular.woff2') format('woff2'),
url('/fonts/KasperskySansText-Regular.woff') format('woff'),
url('/fonts/KasperskySansText-Regular.ttf') format('truetype'),
url('/fonts/KasperskySansText-Regular.eot') format('embedded-opentype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kaspersky Sans Text';
src: url('/fonts/KasperskySansText-Medium.woff2') format('woff2'),
url('/fonts/KasperskySansText-Medium.woff') format('woff'),
url('/fonts/KasperskySansText-Medium.ttf') format('truetype'),
url('/fonts/KasperskySansText-Medium.eot') format('embedded-opentype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kaspersky Sans Text';
src: url('/fonts/KasperskySansText-DemiBold.woff2') format('woff2'),
url('/fonts/KasperskySansText-DemiBold.woff') format('woff'),
url('/fonts/KasperskySansText-DemiBold.ttf') format('truetype'),
url('/fonts/KasperskySansText-DemiBold.eot') format('embedded-opentype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kaspersky Sans Text';
src: url('/fonts/KasperskySansText-Bold.woff2') format('woff2'),
url('/fonts/KasperskySansText-Bold.woff') format('woff'),
url('/fonts/KasperskySansText-Bold.ttf') format('truetype'),
url('/fonts/KasperskySansText-Bold.eot') format('embedded-opentype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kaspersky Sans Text';
src: url('/fonts/KasperskySansText-ExtraBold.woff2') format('woff2'),
url('/fonts/KasperskySansText-ExtraBold.woff') format('woff'),
url('/fonts/KasperskySansText-ExtraBold.ttf') format('truetype'),
url('/fonts/KasperskySansText-ExtraBold.eot') format('embedded-opentype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
/* Mono variant for logs */
@font-face {
font-family: 'Kaspersky Sans Mono';
src: url('/fonts/KasperskySansMono-Regular.woff2') format('woff2'),
url('/fonts/KasperskySansMono-Regular.woff') format('woff'),
url('/fonts/KasperskySansMono-Regular.ttf') format('truetype'),
url('/fonts/KasperskySansMono-Regular.eot') format('embedded-opentype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Kaspersky Sans Mono';
src: url('/fonts/KasperskySansMono-Bold.woff2') format('woff2'),
url('/fonts/KasperskySansMono-Bold.woff') format('woff'),
url('/fonts/KasperskySansMono-Bold.ttf') format('truetype'),
url('/fonts/KasperskySansMono-Bold.eot') format('embedded-opentype');
font-weight: 700;
font-style: normal;
font-display: swap;
}

View File

@ -0,0 +1,108 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import './fonts.css';
@layer base {
html {
font-family: 'Kaspersky Sans Text', system-ui, sans-serif;
}
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: theme('colors.kaspersky.bg.DEFAULT');
}
::-webkit-scrollbar-thumb {
background: theme('colors.kaspersky.accent');
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: theme('colors.kaspersky.primary');
}
/* Connection panel slide-in animation */
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in {
animation: slide-in 0.5s ease-out;
}
/* Shake animation for errors */
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-8px);
}
20%, 40%, 60%, 80% {
transform: translateX(8px);
}
}
.animate-shake {
animation: shake 0.6s ease-in-out;
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-in;
}
/* Animated gradient background */
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient-shift 15s ease infinite;
}
/* Animated background pattern */
@keyframes pattern-move {
0% {
background-position: 0 0;
}
100% {
background-position: 40px 40px;
}
}
.animate-pattern {
animation: pattern-move 20s linear infinite;
}

28
mc_test/src/renderer/types.ts Executable file
View File

@ -0,0 +1,28 @@
export interface TestLink {
name: string;
url: string;
}
export interface LogEntry {
timestamp: string;
message: string;
level: 'error' | 'warn' | 'info' | 'debug';
}
export interface Machine {
id: string;
name: string;
status: 'running' | 'stopped' | 'error' | 'unknown' | 'checking';
os: string;
ip: string;
hypervisor: string;
specs: {
cpu: string;
ram: string;
disk: string;
};
testLinks: TestLink[];
logs: LogEntry[];
port?: number; // Порт для сохраненных машин (используется при проверке доступности)
protocol?: string; // Протокол для сохраненных машин
}

View File

@ -0,0 +1,26 @@
export interface GuacamoleConnection {
connectionId: string;
name: string;
protocol: 'rdp' | 'vnc' | 'ssh';
status: 'connected' | 'disconnected' | 'error';
lastActive?: string;
}
export interface GuacamoleAuthToken {
authToken: string;
username: string;
dataSource: string;
availableDataSources: string[];
}
export interface GuacamoleError {
statusCode: number;
message: string;
type: 'INVALID_CREDENTIALS' | 'NOT_FOUND' | 'PERMISSION_DENIED' | 'UNKNOWN';
}
export interface GuacamoleConnectionResponse {
success: boolean;
error?: GuacamoleError;
connection?: GuacamoleConnection;
}

View File

@ -0,0 +1,53 @@
export type LogLevel = 'info' | 'warn' | 'error';
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
}
export interface TestLink {
url: string;
name: string;
}
export interface Machine {
id: string;
name: string;
status: 'running' | 'stopped' | 'error' | 'unknown' | 'checking';
os: string;
ip: string; // ✅ Исправлено: было ipAddress, стало ip (соответствует реальным данным)
cpu: string;
memory: number;
storage: number;
testLinks?: TestLink[];
logs?: LogEntry[];
guacamole?: {
connection?: GuacamoleConnection;
lastConnectionTime?: string;
autoReconnect?: boolean;
};
}
export interface GuacamoleAuthToken {
authToken: string;
}
export interface GuacamoleConnection {
connectionId: string;
name: string;
protocol: string;
parameters: Record<string, string>;
}
export interface GuacamoleError {
statusCode: number;
message: string;
type: 'INVALID_CREDENTIALS' | 'PERMISSION_DENIED' | 'NOT_FOUND' | 'UNKNOWN';
}
export interface GuacamoleConnectionResponse {
success: boolean;
connection?: GuacamoleConnection;
error?: GuacamoleError;
}

View File

@ -0,0 +1,300 @@
import { ErrorHandler } from './errorHandler';
import { log } from './logger';
import { certificatePinning } from '../services/CertificatePinning';
import { API_CONFIG } from '../config/api';
interface RetryConfig {
maxRetries: number;
baseDelay: number;
maxDelay: number;
retryCondition?: (error: unknown) => boolean;
}
interface RequestConfig extends RequestInit {
timeout?: number;
retryConfig?: Partial<RetryConfig>;
}
export class ApiClient {
private static defaultRetryConfig: RetryConfig = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 10000,
retryCondition: (error) => {
if (error instanceof Response) {
// Retry on server errors and rate limiting
return error.status >= 500 || error.status === 429;
}
// Retry on network errors
return error instanceof TypeError && error.message.includes('fetch');
}
};
private static defaultTimeout = 30000; // 30 seconds
// CSRF token removed - not needed for JWT authentication
// JWT Bearer tokens are not vulnerable to CSRF attacks
/**
* Execute HTTP request with retry logic and timeout
*/
static async fetchWithRetry<T>(
url: string,
options: RequestConfig = {}
): Promise<T> {
const {
timeout = this.defaultTimeout,
retryConfig = {},
...fetchOptions
} = options;
// Add Authorization token if not explicitly set
if (!(fetchOptions.headers as Record<string, string>)?.['Authorization']) {
try {
const authService = (await import('../services/auth-service')).authService;
const token = authService.getToken();
if (token) {
fetchOptions.headers = {
...fetchOptions.headers,
'Authorization': `Bearer ${token}`
};
}
} catch (error) {
// Ignore errors when getting token
}
}
// CSRF token removed - not needed for JWT API
// Certificate pinning check for production
if (process.env.NODE_ENV === 'production') {
try {
const hostname = new URL(url).hostname;
// Load dynamic pins if cache is invalid
if (!(certificatePinning as any).isPinCacheValid()) {
try {
await (certificatePinning as any).loadDynamicPins(API_CONFIG.BASE_URL);
} catch (error) {
log.warn('api-client', 'Failed to load dynamic certificate pins, using static pins', { error });
}
}
const isValid = await (certificatePinning as any).verifyCertificate(hostname);
if (!isValid) {
const error = new Error(`Certificate pinning validation failed for ${hostname}`);
log.error('api-client', 'Certificate pinning failed', { hostname, url });
throw error;
}
log.debug('api-client', 'Certificate pinning validation passed', { hostname });
} catch (error) {
log.error('api-client', 'Certificate pinning error', { error, url });
throw error;
}
}
const config = { ...this.defaultRetryConfig, ...retryConfig };
let lastError: unknown;
let attempt = 0;
while (attempt <= config.maxRetries) {
try {
log.debug('api-client', `Attempting request (${attempt + 1}/${config.maxRetries + 1})`, {
url,
method: fetchOptions.method || 'GET',
attempt: attempt + 1
});
// Create AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = response.clone();
// Check if request should be retried
if (attempt < config.maxRetries && config.retryCondition?.(error)) {
lastError = error;
const delay = this.calculateDelay(attempt, config);
log.warn('api-client', `Request failed, retrying in ${delay}ms`, {
url,
status: response.status,
attempt: attempt + 1,
nextRetryIn: delay
});
await this.delay(delay);
attempt++;
continue;
}
throw error;
}
// Successful response
log.info('api-client', 'Request successful', {
url,
status: response.status,
attempt: attempt + 1
});
// Try parsing JSON if content exists
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await response.json();
}
// Return empty object for successful requests without JSON
return {} as T;
} catch (error) {
lastError = error;
// Check if request should be retried
if (attempt < config.maxRetries && config.retryCondition?.(error)) {
const delay = this.calculateDelay(attempt, config);
log.warn('api-client', `Request failed with error, retrying in ${delay}ms`, {
url,
error: error instanceof Error ? error.message : String(error),
attempt: attempt + 1,
nextRetryIn: delay
});
await this.delay(delay);
attempt++;
continue;
}
// Maximum retry attempts exhausted
break;
}
}
// Handle final error
log.error('api-client', 'Request failed after all retries', {
url,
totalAttempts: attempt + 1,
error: lastError
});
const appError = await ErrorHandler.handleApiError(lastError);
throw appError;
}
/**
* GET request
*/
static async get<T>(url: string, options: RequestConfig = {}): Promise<T> {
return this.fetchWithRetry<T>(url, {
...options,
method: 'GET'
});
}
/**
* POST request
*/
static async post<T>(
url: string,
body?: unknown,
options: RequestConfig = {}
): Promise<T> {
return this.fetchWithRetry<T>(url, {
...options,
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
}
/**
* PUT request
*/
static async put<T>(
url: string,
body?: unknown,
options: RequestConfig = {}
): Promise<T> {
return this.fetchWithRetry<T>(url, {
...options,
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
}
/**
* DELETE request
*/
static async delete<T>(url: string, options: RequestConfig = {}): Promise<T> {
return this.fetchWithRetry<T>(url, {
...options,
method: 'DELETE'
});
}
/**
* Calculate retry delay with exponential backoff and jitter
*/
private static calculateDelay(attempt: number, config: RetryConfig): number {
// Exponential backoff: baseDelay * 2^attempt
const exponentialDelay = config.baseDelay * Math.pow(2, attempt);
// Add jitter (randomness) up to 10% of delay
const jitter = Math.random() * 0.1 * exponentialDelay;
// Apply maximum delay
return Math.min(exponentialDelay + jitter, config.maxDelay);
}
/**
* Delay
*/
private static delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Create request configuration with authorization
*/
static withAuth(token: string, options: RequestConfig = {}): RequestConfig {
return {
...options,
headers: {
'Authorization': `Bearer ${token}`,
...options.headers
}
};
}
/**
* Check network status (API availability)
*/
static async checkHealth(baseUrl: string): Promise<boolean> {
try {
await this.get(`${baseUrl}/health`, {
timeout: 5000,
retryConfig: { maxRetries: 1 }
});
return true;
} catch (error) {
log.warn('api-client', 'Health check failed', { error });
return false;
}
}
}

312
mc_test/src/renderer/utils/csp.ts Executable file
View File

@ -0,0 +1,312 @@
/**
* Content Security Policy utilities for Electron app
*/
export interface CSPConfig {
defaultSrc: readonly string[];
scriptSrc: readonly string[];
styleSrc: readonly string[];
imgSrc: readonly string[];
fontSrc: readonly string[];
connectSrc: readonly string[];
frameSrc: readonly string[];
objectSrc: readonly string[];
baseUri: readonly string[];
formAction: readonly string[];
frameAncestors: readonly string[];
}
type CSPDirective = keyof CSPConfig;
const NONCE_PLACEHOLDER = "'nonce-{NONCE}'" as const;
const NONCE_SIZE = 16;
export class CSPManager {
private static nonce: string | null = null;
private static readonly DEVELOPMENT_CONFIG: CSPConfig = {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'", // Required for Vite in dev mode
"'unsafe-eval'", // Required for React DevTools
"http://localhost:*"
],
styleSrc: [
"'self'",
"'unsafe-inline'", // Required for styled-components/emotion
"http://localhost:*"
],
imgSrc: [
"'self'",
"data:",
"blob:",
"http://localhost:*"
],
fontSrc: [
"'self'",
"data:",
"http://localhost:*"
],
connectSrc: [
"'self'",
"http://localhost:*",
"https://localhost:*",
"ws://localhost:*",
"wss://localhost:*"
],
frameSrc: [
"'self'",
"http://localhost:8080", // Guacamole in development
"https://localhost:8080"
],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"]
};
private static readonly PRODUCTION_CONFIG: CSPConfig = {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'nonce-{NONCE}'" // Nonce for inline scripts
],
styleSrc: [
"'self'",
"'nonce-{NONCE}'", // Nonce for inline styles
"https://fonts.googleapis.com"
],
imgSrc: [
"'self'",
"data:",
"blob:"
],
fontSrc: [
"'self'",
"data:",
"https://fonts.gstatic.com"
],
connectSrc: [
"'self'",
`https://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}`,
`wss://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}`
],
frameSrc: [
"'self'",
`https://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}`
],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"]
};
/**
* Generate random nonce
*/
static generateNonce(): string {
const array = new Uint8Array(NONCE_SIZE);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
/**
* Set nonce for use in CSP
*/
static setNonce(nonce: string): void {
this.nonce = nonce;
}
/**
* Get current nonce
*/
static getNonce(): string | null {
return this.nonce;
}
/**
* Get CSP configuration for current environment
* Dynamically adds API URL from env variables
*/
static getConfig(): CSPConfig {
const baseConfig = this.getBaseConfig();
const apiUrl = import.meta.env.VITE_API_URL;
if (!apiUrl) {
return baseConfig;
}
return this.addApiUrlToConfig(baseConfig, apiUrl);
}
private static getBaseConfig(): CSPConfig {
return process.env.NODE_ENV === 'development'
? this.DEVELOPMENT_CONFIG
: this.PRODUCTION_CONFIG;
}
private static addApiUrlToConfig(config: CSPConfig, apiUrl: string): CSPConfig {
try {
const url = new URL(apiUrl);
const { domain, wssDomain } = this.buildDomains(url);
const updatedConfig: CSPConfig = { ...config };
if (!config.connectSrc.includes(domain)) {
updatedConfig.connectSrc = [...config.connectSrc, domain, wssDomain];
}
if (!config.frameSrc.includes(domain)) {
updatedConfig.frameSrc = [...config.frameSrc, domain];
}
console.log('CSP updated with API domain:', domain);
return updatedConfig;
} catch (error) {
console.error('Failed to parse VITE_API_URL for CSP:', error);
return config;
}
}
private static buildDomains(url: URL): { domain: string; wssDomain: string } {
const protocol = url.protocol.replace(':', '');
const portSuffix = url.port ? `:${url.port}` : '';
const domain = `${protocol}://${url.hostname}${portSuffix}`;
const wssProtocol = protocol === 'https' ? 'wss' : 'ws';
const wssDomain = `${wssProtocol}://${url.hostname}${portSuffix}`;
return { domain, wssDomain };
}
/**
* Generate CSP string from configuration
*/
static generateCSPString(config: CSPConfig = this.getConfig()): string {
const directives: string[] = [
this.buildDirective('default-src', config.defaultSrc),
this.buildDirective('script-src', config.scriptSrc),
this.buildDirective('style-src', config.styleSrc),
this.buildDirective('img-src', config.imgSrc),
this.buildDirective('font-src', config.fontSrc),
this.buildDirective('connect-src', config.connectSrc),
this.buildDirective('frame-src', config.frameSrc),
this.buildDirective('object-src', config.objectSrc),
this.buildDirective('base-uri', config.baseUri),
this.buildDirective('form-action', config.formAction),
this.buildDirective('frame-ancestors', config.frameAncestors),
];
return directives.join(' ').trim();
}
private static buildDirective(name: string, sources: readonly string[]): string {
const processed = this.processSources([...sources]);
return `${name} ${processed.join(' ')};`;
}
private static processSources(sources: string[]): string[] {
return sources.map((source) => {
if (source === NONCE_PLACEHOLDER && this.nonce) {
return `'nonce-${this.nonce}'`;
}
return source;
});
}
/**
* Apply CSP to document
*/
static applyCSP(config: CSPConfig = this.getConfig()): void {
if (typeof document === 'undefined') return;
// Generate nonce for production
if (process.env.NODE_ENV === 'production' && !this.nonce) {
this.nonce = this.generateNonce();
}
// Remove existing CSP meta tag
const existingCSP = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
if (existingCSP) {
existingCSP.remove();
}
// Create new CSP meta tag
const meta = document.createElement('meta');
meta.setAttribute('http-equiv', 'Content-Security-Policy');
meta.setAttribute('content', this.generateCSPString(config));
document.head.appendChild(meta);
// Add nonce to meta tag for JavaScript access
if (this.nonce) {
const nonceMeta = document.createElement('meta');
nonceMeta.setAttribute('name', 'csp-nonce');
nonceMeta.setAttribute('content', this.nonce);
document.head.appendChild(nonceMeta);
}
}
/**
* Setup CSP violation reporting
*/
static setupCSPReporting(): void {
if (typeof document === 'undefined') return;
document.addEventListener('securitypolicyviolation', (event) => {
console.warn('CSP Violation:', {
directive: event.violatedDirective,
blockedURI: event.blockedURI,
lineNumber: event.lineNumber,
columnNumber: event.columnNumber,
sourceFile: event.sourceFile,
sample: event.sample
});
// In production, send to server
if (process.env.NODE_ENV === 'production') {
// fetch('/api/csp-violation', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// directive: event.violatedDirective,
// blockedURI: event.blockedURI,
// sourceFile: event.sourceFile,
// timestamp: new Date().toISOString()
// })
// });
}
});
}
/**
* Validate CSP configuration
*/
static validateConfig(config: CSPConfig): string[] {
const errors: string[] = [];
// Check for dangerous directives
if (config.scriptSrc.includes("'unsafe-inline'")) {
errors.push("script-src contains 'unsafe-inline' - security risk");
}
if (config.scriptSrc.includes("'unsafe-eval'")) {
errors.push("script-src contains 'unsafe-eval' - security risk");
}
if (config.objectSrc.includes("'self'") || config.objectSrc.length === 0) {
errors.push("object-src should be 'none' for security");
}
if (config.frameAncestors.includes("'self'")) {
errors.push("frame-ancestors should be 'none' for clickjacking protection");
}
return errors;
}
}
// Export convenience functions
export const generateCSP = CSPManager.generateCSPString;
export const applyCSP = CSPManager.applyCSP;
export const setupCSPReporting = CSPManager.setupCSPReporting;

View File

@ -0,0 +1,332 @@
import { log } from './logger';
export enum ErrorType {
NETWORK = 'NETWORK',
AUTH = 'AUTH',
VALIDATION = 'VALIDATION',
PERMISSION = 'PERMISSION',
GUACAMOLE = 'GUACAMOLE',
ENCRYPTION = 'ENCRYPTION',
UNKNOWN = 'UNKNOWN'
}
export interface AppError {
type: ErrorType;
message: string;
code?: string;
details?: unknown;
timestamp: Date;
userMessage: string;
retryable: boolean;
}
interface ErrorData {
detail?: string;
message?: string;
required_role?: string;
[key: string]: unknown;
}
export class ErrorHandler {
/**
* Create standardized application error
*/
static createError(
type: ErrorType,
message: string,
userMessage?: string,
details?: unknown,
retryable: boolean = false
): AppError {
const error: AppError = {
type,
message,
userMessage: userMessage || this.getDefaultUserMessage(type),
details,
timestamp: new Date(),
retryable
};
// Log error
log.error('error-handler', `${type} error occurred`, {
type,
message,
userMessage: error.userMessage,
retryable,
details
});
return error;
}
/**
* Handle API request errors
*/
static async handleApiError(error: unknown): Promise<AppError> {
// Response object with HTTP error
if (error instanceof Response) {
const errorData = await this.parseErrorResponse(error);
return this.handleHttpError(error, errorData);
}
// Network or other errors
return this.handleNonHttpError(error);
}
/**
* Parse error response from API
*/
private static async parseErrorResponse(response: Response): Promise<ErrorData> {
try {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await response.json() as ErrorData;
}
const text = await response.text();
return { message: text };
} catch (parseError) {
log.warn('error-handler', 'Failed to parse error response', { parseError });
return {};
}
}
/**
* Handle HTTP errors
*/
private static handleHttpError(response: Response, errorData: ErrorData): AppError {
switch (response.status) {
case 401:
return this.handle401Error(response, errorData);
case 403:
return this.handle403Error(response, errorData);
case 404:
return this.createError(
ErrorType.NETWORK,
'Resource not found',
'The requested resource was not found.',
{ status: response.status, details: errorData }
);
case 422:
return this.createError(
ErrorType.VALIDATION,
'Validation error',
errorData.detail || 'Please check your input and try again.',
{ status: response.status, details: errorData }
);
case 429:
return this.createError(
ErrorType.NETWORK,
'Too many requests',
'Too many requests. Please wait a moment and try again.',
{ status: response.status, details: errorData },
true
);
case 500:
case 502:
case 503:
case 504:
return this.createError(
ErrorType.NETWORK,
'Server error',
'A server error occurred. Please try again later.',
{ status: response.status, details: errorData },
true
);
default:
return this.createError(
ErrorType.NETWORK,
`HTTP ${response.status}: ${response.statusText}`,
'A network error occurred. Please try again.',
{ status: response.status, details: errorData },
response.status >= 500
);
}
}
/**
* Handle 401 Unauthorized errors
*/
private static handle401Error(response: Response, errorData: ErrorData): AppError {
const isLoginError =
errorData.detail?.includes('Invalid username or password') ||
errorData.detail?.includes('Login blocked');
const userMessage = isLoginError
? errorData.detail!
: 'Your session has expired. Please log in again.';
return this.createError(
ErrorType.AUTH,
'Unauthorized access',
userMessage,
{ status: response.status, details: errorData }
);
}
/**
* Handle 403 Forbidden errors
*/
private static handle403Error(response: Response, errorData: ErrorData): AppError {
let userMessage = 'You don\'t have permission to perform this action.';
if (errorData.message?.includes('create_connections') ||
errorData.required_role?.includes('privileges')) {
userMessage = 'Your account has limited access (GUEST role). You can only view connections but cannot create new ones. Please contact your administrator to upgrade your account to USER role for full access.';
} else if (errorData.message) {
userMessage = `Access denied: ${errorData.message}`;
}
return this.createError(
ErrorType.PERMISSION,
'Forbidden access',
userMessage,
{ status: response.status, details: errorData }
);
}
/**
* Handle non-HTTP errors (network, encryption, etc.)
*/
private static handleNonHttpError(error: unknown): AppError {
if (error instanceof Error) {
if (error.name === 'TypeError' && error.message.includes('fetch')) {
return this.createError(
ErrorType.NETWORK,
'Network connection failed',
'Unable to connect to the server. Please check your internet connection.',
{ originalError: error.message },
true // Retryable
);
}
if (error.name === 'AbortError') {
return this.createError(
ErrorType.NETWORK,
'Request timeout',
'The request took too long. Please try again.',
{ originalError: error.message },
true // Retryable
);
}
// Encryption errors
if (error.message.includes('encryption') || error.message.includes('crypto')) {
return this.createError(
ErrorType.ENCRYPTION,
'Encryption error',
'A security error occurred. Please try logging in again.',
{ originalError: error.message }
);
}
// Guacamole errors
if (error.message.includes('guacamole') || error.message.includes('connection')) {
return this.createError(
ErrorType.GUACAMOLE,
'Connection error',
'Failed to connect to the remote machine. Please check credentials and try again.',
{ originalError: error.message },
true // Retryable
);
}
}
// Unknown error
return this.createError(
ErrorType.UNKNOWN,
error instanceof Error ? error.message : String(error),
'An unexpected error occurred. Please try again.',
{ originalError: error }
);
}
/**
* Check if operation should be retried
*/
static shouldRetry(error: AppError, attempt: number, maxAttempts: number = 3): boolean {
if (attempt >= maxAttempts) {
return false;
}
return error.retryable;
}
/**
* Get default user message
*/
private static getDefaultUserMessage(type: ErrorType): string {
switch (type) {
case ErrorType.NETWORK:
return 'A network error occurred. Please try again.';
case ErrorType.AUTH:
return 'Authentication failed. Please log in again.';
case ErrorType.VALIDATION:
return 'Please check your input and try again.';
case ErrorType.PERMISSION:
return 'You don\'t have permission to perform this action.';
case ErrorType.GUACAMOLE:
return 'Failed to connect to the remote machine. Please try again.';
case ErrorType.ENCRYPTION:
return 'A security error occurred. Please try again.';
default:
return 'An unexpected error occurred. Please try again.';
}
}
/**
* Format error for UI display
*/
static formatErrorForUI(error: AppError): {
title: string;
message: string;
type: 'error' | 'warning';
action?: string;
} {
let title: string;
let type: 'error' | 'warning' = 'error';
let action: string | undefined;
switch (error.type) {
case ErrorType.AUTH:
title = 'Authentication Error';
action = 'Please log in again';
break;
case ErrorType.NETWORK:
title = 'Connection Error';
type = error.retryable ? 'warning' : 'error';
action = error.retryable ? 'Retrying...' : undefined;
break;
case ErrorType.VALIDATION:
title = 'Input Error';
type = 'warning';
break;
case ErrorType.PERMISSION:
title = 'Access Denied';
break;
case ErrorType.GUACAMOLE:
title = 'Connection Failed';
action = 'Check credentials and try again';
break;
case ErrorType.ENCRYPTION:
title = 'Security Error';
action = 'Please restart the application';
break;
default:
title = 'Error';
}
return {
title,
message: error.userMessage,
type,
action
};
}
}

View File

@ -0,0 +1,51 @@
import { LogLevel } from '../../types/electron';
type LogMethod = (tag: string, message: string, context?: Record<string, unknown>) => void;
type ConsoleMethod = 'log' | 'info' | 'warn' | 'error';
class Logger {
private readonly isDevelopment = import.meta.env.DEV;
private getConsoleMethod(level: LogLevel): ConsoleMethod {
return level === 'debug' ? 'log' : level;
}
private createLogMethod(level: LogLevel): LogMethod {
return (tag: string, message: string, context?: Record<string, unknown>): void => {
// Always output to console in development mode
if (this.isDevelopment) {
const contextStr = context ? ` | context: ${JSON.stringify(context)}` : '';
const consoleMethod = this.getConsoleMethod(level);
console[consoleMethod](`[${tag}] ${message}${contextStr}`);
}
// Send to main process via IPC only if electronAPI is available
this.sendToMainProcess(level, tag, message, context);
};
}
private sendToMainProcess(
level: LogLevel,
tag: string,
message: string,
context?: Record<string, unknown>
): void {
try {
if (window.electronAPI?.logEvent) {
window.electronAPI.logEvent(level, tag, message, context);
}
} catch (error) {
// Fallback to console if IPC fails
console.error('Failed to send log to main process:', error);
}
}
public readonly debug = this.createLogMethod('debug');
public readonly info = this.createLogMethod('info');
public readonly warn = this.createLogMethod('warn');
public readonly error = this.createLogMethod('error');
}
export const log = new Logger();
export default log;

View File

@ -0,0 +1,309 @@
export type OSFamily = 'windows' | 'linux' | 'macos' | 'unix' | 'unknown';
export type ProtocolType = 'rdp' | 'vnc' | 'ssh' | 'telnet';
export interface ProtocolOption {
value: ProtocolType;
label: string;
description: string;
icon: string;
defaultPort: number;
requirements?: readonly string[];
}
export interface OSInfo {
family: OSFamily;
version?: string;
architecture?: string;
}
export interface CompatibilityInfo {
recommendations: readonly string[];
warnings: readonly string[];
tips: readonly string[];
}
export interface PortValidation {
valid: boolean;
suggestion?: number;
}
const VERSION_PATTERNS = {
WINDOWS: [
/windows\s*(\d+)/i,
/windows\s*(xp|vista|7|8|8\.1|10|11)/i,
/windows\s*(server\s*\d+)/i,
],
LINUX: [
/ubuntu\s*(\d+\.\d+)/i,
/debian\s*(\d+)/i,
/centos\s*(\d+)/i,
/rhel\s*(\d+)/i,
],
MACOS: [
/macos\s*(\d+\.\d+)/i,
/mac\s*os\s*(\d+\.\d+)/i,
],
} as const;
export class ProtocolHelper {
private static readonly PROTOCOLS: Readonly<Record<ProtocolType, ProtocolOption>> = {
rdp: {
value: 'rdp',
label: 'RDP',
description: 'Remote Desktop Protocol - Native Windows remote access',
icon: '🖥️',
defaultPort: 3389,
requirements: ['Windows OS', 'Remote Desktop enabled']
},
vnc: {
value: 'vnc',
label: 'VNC',
description: 'Virtual Network Computing - Cross-platform remote desktop',
icon: '🖱️',
defaultPort: 5900,
requirements: ['VNC Server installed']
},
ssh: {
value: 'ssh',
label: 'SSH',
description: 'Secure Shell - Command line access',
icon: '⌨️',
defaultPort: 22,
requirements: ['SSH Server enabled']
},
telnet: {
value: 'telnet',
label: 'Telnet',
description: 'Telnet - Legacy command line access (not secure)',
icon: '📡',
defaultPort: 23,
requirements: ['Telnet Server enabled']
}
};
/**
* Parse OS string into structured information
*/
static parseOS(osString: string): OSInfo {
const os = osString.toLowerCase();
if (os.includes('windows')) {
return {
family: 'windows',
version: this.extractWindowsVersion(os),
architecture: this.extractArchitecture(os)
};
}
if (os.includes('ubuntu') || os.includes('debian') || os.includes('centos') ||
os.includes('rhel') || os.includes('linux')) {
return {
family: 'linux',
version: this.extractLinuxVersion(os),
architecture: this.extractArchitecture(os)
};
}
if (os.includes('macos') || os.includes('mac os') || os.includes('darwin')) {
return {
family: 'macos',
version: this.extractMacVersion(os),
architecture: this.extractArchitecture(os)
};
}
if (os.includes('unix') || os.includes('solaris') || os.includes('aix')) {
return {
family: 'unix',
architecture: this.extractArchitecture(os)
};
}
return { family: 'unknown' };
}
/**
* Get recommended protocols for given OS
*/
static getRecommendedProtocols(osInfo: OSInfo): ProtocolOption[] {
const protocols: ProtocolOption[] = [];
switch (osInfo.family) {
case 'windows':
// Windows: RDP first, then VNC and SSH
protocols.push(
this.PROTOCOLS.rdp,
this.PROTOCOLS.vnc,
this.PROTOCOLS.ssh
);
break;
case 'linux':
// Linux: SSH first, then VNC
protocols.push(
this.PROTOCOLS.ssh,
this.PROTOCOLS.vnc
);
// For Linux with GUI, add Telnet as fallback
if (this.hasDesktopEnvironment(osInfo)) {
protocols.push(this.PROTOCOLS.telnet);
}
break;
case 'macos':
// macOS: SSH first, VNC second
protocols.push(
this.PROTOCOLS.ssh,
this.PROTOCOLS.vnc
);
break;
case 'unix':
// Unix systems: SSH and Telnet
protocols.push(
this.PROTOCOLS.ssh,
this.PROTOCOLS.telnet
);
break;
default:
// Unknown OS: show all options
protocols.push(
this.PROTOCOLS.ssh,
this.PROTOCOLS.vnc,
this.PROTOCOLS.rdp,
this.PROTOCOLS.telnet
);
}
return protocols;
}
/**
* Get default protocol for OS
*/
static getDefaultProtocol(osInfo: OSInfo): ProtocolOption {
const recommended = this.getRecommendedProtocols(osInfo);
return recommended[0] || this.PROTOCOLS.ssh;
}
/**
* Get additional compatibility information
*/
static getCompatibilityInfo(osInfo: OSInfo): CompatibilityInfo {
const info: {
recommendations: string[];
warnings: string[];
tips: string[];
} = {
recommendations: [],
warnings: [],
tips: [],
};
switch (osInfo.family) {
case 'windows':
info.recommendations.push('RDP provides the best experience for Windows machines');
info.tips.push('Ensure Remote Desktop is enabled in Windows settings');
if (osInfo.version && osInfo.version.includes('home')) {
info.warnings.push('Windows Home editions may have limited RDP support');
}
break;
case 'linux':
info.recommendations.push('SSH is the most reliable option for Linux systems');
info.tips.push('VNC requires a desktop environment and VNC server installation');
break;
case 'macos':
info.recommendations.push('SSH works best for macOS command line access');
info.tips.push('Enable "Remote Login" in System Preferences for SSH');
info.warnings.push('VNC may require additional setup on macOS');
break;
default:
info.tips.push('Try SSH first as it\'s widely supported');
}
return info;
}
/**
* Validate port for protocol
*/
static validatePort(protocol: string, port: number): PortValidation {
const protocolInfo = this.PROTOCOLS[protocol as ProtocolType];
if (!protocolInfo) {
return { valid: false };
}
if (port === protocolInfo.defaultPort) {
return { valid: true };
}
const alternativePorts = this.getAlternativePorts(protocol);
if (alternativePorts.includes(port)) {
return { valid: true };
}
return {
valid: true,
suggestion: protocolInfo.defaultPort,
};
}
private static extractVersion(os: string, patterns: readonly RegExp[]): string {
for (const pattern of patterns) {
const match = os.match(pattern);
if (match?.[1]) return match[1];
}
return '';
}
private static extractWindowsVersion(os: string): string {
return this.extractVersion(os, VERSION_PATTERNS.WINDOWS);
}
private static extractLinuxVersion(os: string): string {
return this.extractVersion(os, VERSION_PATTERNS.LINUX);
}
private static extractMacVersion(os: string): string {
return this.extractVersion(os, VERSION_PATTERNS.MACOS);
}
private static extractArchitecture(os: string): string {
if (os.includes('x64') || os.includes('x86_64') || os.includes('amd64')) {
return 'x64';
}
if (os.includes('x86') || os.includes('i386')) {
return 'x86';
}
if (os.includes('arm64') || os.includes('aarch64')) {
return 'arm64';
}
if (os.includes('arm')) {
return 'arm';
}
return '';
}
private static hasDesktopEnvironment(osInfo: OSInfo): boolean {
// Simple heuristic for detecting GUI presence
return osInfo.version?.includes('desktop') ||
osInfo.version?.includes('gnome') ||
osInfo.version?.includes('kde') ||
!osInfo.version?.includes('server');
}
private static getAlternativePorts(protocol: string): number[] {
const alternatives: Record<string, number[]> = {
rdp: [3389, 3390, 3391],
vnc: [5900, 5901, 5902, 5903],
ssh: [22, 2222, 2022],
telnet: [23, 2323]
};
return alternatives[protocol] || [];
}
}

View File

@ -0,0 +1,236 @@
const REGEX_PATTERNS = {
IPV4: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
IPV6: /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/,
MACHINE_NAME: /[^a-zA-Z0-9\-_\s]/g,
USERNAME: /[^a-z0-9@._-]/g,
CONTROL_CHARS: /[\x00-\x1f\x7f]/,
TEXT_SANITIZE: /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g,
} as const;
const VALIDATION_LIMITS = {
MACHINE_NAME: { MAX: 50 },
USERNAME: { MIN: 3, MAX: 50 },
PASSWORD: { MIN: 8, MAX: 128 },
TEXT: { MAX: 1000 },
PORT: { MIN: 1, MAX: 65535 },
} as const;
const ALLOWED_PROTOCOLS = ['rdp', 'vnc', 'ssh', 'telnet'] as const;
type AllowedProtocol = typeof ALLOWED_PROTOCOLS[number];
export class InputSanitizer {
/**
* Sanitize and validate IP address
*/
static sanitizeIpAddress(ip: string): string {
const cleanIp = ip.trim().replace(/[^0-9.:a-fA-F]/g, '');
if (REGEX_PATTERNS.IPV4.test(cleanIp) || REGEX_PATTERNS.IPV6.test(cleanIp)) {
return cleanIp;
}
throw new Error('Invalid IP address format');
}
/**
* Sanitize machine name
*/
static sanitizeMachineName(name: string): string {
this.validateStringInput(name, 'Machine name');
const sanitized = name.trim().replace(REGEX_PATTERNS.MACHINE_NAME, '');
if (sanitized.length === 0) {
throw new Error('Machine name cannot be empty');
}
if (sanitized.length > VALIDATION_LIMITS.MACHINE_NAME.MAX) {
throw new Error(`Machine name too long (max ${VALIDATION_LIMITS.MACHINE_NAME.MAX} characters)`);
}
return sanitized;
}
/**
* Sanitize username
*/
static sanitizeUsername(username: string): string {
this.validateStringInput(username, 'Username');
const sanitized = username.trim().toLowerCase().replace(REGEX_PATTERNS.USERNAME, '');
if (sanitized.length < VALIDATION_LIMITS.USERNAME.MIN) {
throw new Error(`Username too short (min ${VALIDATION_LIMITS.USERNAME.MIN} characters)`);
}
if (sanitized.length > VALIDATION_LIMITS.USERNAME.MAX) {
throw new Error(`Username too long (max ${VALIDATION_LIMITS.USERNAME.MAX} characters)`);
}
return sanitized;
}
/**
* Validate password (no sanitization, only validation)
*/
static validatePassword(password: string): boolean {
this.validateStringInput(password, 'Password');
if (password.length < VALIDATION_LIMITS.PASSWORD.MIN) {
throw new Error(`Password too short (min ${VALIDATION_LIMITS.PASSWORD.MIN} characters)`);
}
if (password.length > VALIDATION_LIMITS.PASSWORD.MAX) {
throw new Error(`Password too long (max ${VALIDATION_LIMITS.PASSWORD.MAX} characters)`);
}
if (REGEX_PATTERNS.CONTROL_CHARS.test(password)) {
throw new Error('Password contains invalid characters');
}
return true;
}
/**
* Sanitize URL for Guacamole
*/
static sanitizeUrl(url: string): string {
if (!url || typeof url !== 'string') {
throw new Error('URL is required');
}
try {
const parsedUrl = new URL(url.trim());
// Allow only HTTP/HTTPS
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('Invalid protocol. Only HTTP and HTTPS are allowed');
}
// Check that host is not localhost in production
if (process.env.NODE_ENV === 'production' &&
['localhost', '127.0.0.1', '0.0.0.0'].includes(parsedUrl.hostname)) {
throw new Error('Localhost URLs not allowed in production');
}
return parsedUrl.toString();
} catch (error) {
if (error instanceof Error && error.message.includes('Invalid URL')) {
throw new Error('Invalid URL format');
}
throw error;
}
}
/**
* Sanitize general purpose text fields
*/
static sanitizeText(text: string, maxLength: number = VALIDATION_LIMITS.TEXT.MAX): string {
if (!text || typeof text !== 'string') {
return '';
}
return text
.replace(REGEX_PATTERNS.TEXT_SANITIZE, '')
.trim()
.slice(0, maxLength);
}
/**
* Validate port number
*/
static validatePort(port: string | number): number {
const portNum = typeof port === 'string' ? parseInt(port, 10) : port;
if (
isNaN(portNum) ||
portNum < VALIDATION_LIMITS.PORT.MIN ||
portNum > VALIDATION_LIMITS.PORT.MAX
) {
throw new Error(
`Invalid port number (must be ${VALIDATION_LIMITS.PORT.MIN}-${VALIDATION_LIMITS.PORT.MAX})`
);
}
return portNum;
}
/**
* Sanitize connection protocol
*/
static sanitizeProtocol(protocol: string): AllowedProtocol {
this.validateStringInput(protocol, 'Protocol');
const sanitized = protocol.toLowerCase().trim();
if (!ALLOWED_PROTOCOLS.includes(sanitized as AllowedProtocol)) {
throw new Error(`Invalid protocol. Allowed: ${ALLOWED_PROTOCOLS.join(', ')}`);
}
return sanitized as AllowedProtocol;
}
/**
* Validate string input
*/
private static validateStringInput(value: string, fieldName: string): void {
if (!value || typeof value !== 'string') {
throw new Error(`${fieldName} is required`);
}
}
}
// React hook for form sanitization
export const useSanitizedForm = () => {
const sanitizeFormData = (data: Record<string, unknown>) => {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'string') {
try {
switch (key) {
case 'ip':
case 'ipAddress':
sanitized[key] = InputSanitizer.sanitizeIpAddress(value);
break;
case 'name':
case 'machineName':
case 'displayName':
sanitized[key] = InputSanitizer.sanitizeMachineName(value);
break;
case 'username':
case 'login':
sanitized[key] = InputSanitizer.sanitizeUsername(value);
break;
case 'password':
InputSanitizer.validatePassword(value);
sanitized[key] = value; // Password is not sanitized, only validated
break;
case 'url':
case 'connectionUrl':
sanitized[key] = InputSanitizer.sanitizeUrl(value);
break;
case 'port':
sanitized[key] = InputSanitizer.validatePort(value);
break;
case 'protocol':
sanitized[key] = InputSanitizer.sanitizeProtocol(value);
break;
default:
// For other fields, apply basic sanitization
sanitized[key] = InputSanitizer.sanitizeText(value);
}
} catch (error) {
// Propagate validation errors up
throw new Error(`${key}: ${error instanceof Error ? error.message : 'Invalid value'}`);
}
} else {
sanitized[key] = value;
}
}
return sanitized;
};
return { sanitizeFormData };
};

31
mc_test/src/types/electron.d.ts vendored Executable file
View File

@ -0,0 +1,31 @@
import { Machine } from '../renderer/types';
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
export type MachineAction = 'start' | 'stop' | 'restart';
export interface MachineResponse {
success: boolean;
message: string;
machines?: Machine[];
status?: 'running' | 'stopped' | 'error' | 'unknown' | 'checking';
details?: Record<string, unknown>;
}
export interface ElectronAPI {
connectToMachine: (machine: Machine) => Promise<MachineResponse>;
controlMachine: (machineId: string, action: MachineAction) => Promise<MachineResponse>;
getMachineStatus: (machineId: string) => Promise<MachineResponse>;
getMachineList: () => Promise<MachineResponse>;
logEvent: (level: LogLevel, tag: string, message: string, context?: Record<string, unknown>) => void;
// ✅ ИСПРАВЛЕНО: Безопасное хранилище (Electron safeStorage)
encryptData: (plaintext: string) => Promise<string>;
decryptData: (encrypted: string) => Promise<string>;
isEncryptionAvailable: () => Promise<boolean>;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}