chore: clean .gitignore cache

This commit is contained in:
GitHub Actions
2026-01-26 19:21:33 +00:00
parent 1b1b3a70b1
commit e5f0fec5db
1483 changed files with 0 additions and 472793 deletions

View File

@@ -1,140 +0,0 @@
import { Suspense, lazy } from 'react'
import { Navigate } from 'react-router-dom'
import { BrowserRouter as Router, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import Layout from './components/Layout'
import { ToastContainer } from './components/Toast'
import { SetupGuard } from './components/SetupGuard'
import { LoadingOverlay } from './components/LoadingStates'
import RequireAuth from './components/RequireAuth'
import { AuthProvider } from './context/AuthContext'
// Lazy load pages for code splitting
const Dashboard = lazy(() => import('./pages/Dashboard'))
const ProxyHosts = lazy(() => import('./pages/ProxyHosts'))
const RemoteServers = lazy(() => import('./pages/RemoteServers'))
const DNS = lazy(() => import('./pages/DNS'))
const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec'))
const ImportNPM = lazy(() => import('./pages/ImportNPM'))
const ImportJSON = lazy(() => import('./pages/ImportJSON'))
const Certificates = lazy(() => import('./pages/Certificates'))
const DNSProviders = lazy(() => import('./pages/DNSProviders'))
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
const SMTPSettings = lazy(() => import('./pages/SMTPSettings'))
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
const Account = lazy(() => import('./pages/Account'))
const Settings = lazy(() => import('./pages/Settings'))
const Backups = lazy(() => import('./pages/Backups'))
const Tasks = lazy(() => import('./pages/Tasks'))
const Logs = lazy(() => import('./pages/Logs'))
const Domains = lazy(() => import('./pages/Domains'))
const Security = lazy(() => import('./pages/Security'))
const AccessLists = lazy(() => import('./pages/AccessLists'))
const WafConfig = lazy(() => import('./pages/WafConfig'))
const RateLimiting = lazy(() => import('./pages/RateLimiting'))
const Uptime = lazy(() => import('./pages/Uptime'))
const Notifications = lazy(() => import('./pages/Notifications'))
const UsersPage = lazy(() => import('./pages/UsersPage'))
const SecurityHeaders = lazy(() => import('./pages/SecurityHeaders'))
const AuditLogs = lazy(() => import('./pages/AuditLogs'))
const EncryptionManagement = lazy(() => import('./pages/EncryptionManagement'))
const Plugins = lazy(() => import('./pages/Plugins'))
const Login = lazy(() => import('./pages/Login'))
const Setup = lazy(() => import('./pages/Setup'))
const AcceptInvite = lazy(() => import('./pages/AcceptInvite'))
export default function App() {
return (
<AuthProvider>
<Router>
<Suspense fallback={<LoadingOverlay message="Loading application..." />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/setup" element={<Setup />} />
<Route path="/accept-invite" element={<AcceptInvite />} />
<Route path="/" element={
<SetupGuard>
<RequireAuth>
<Layout>
<Outlet />
</Layout>
</RequireAuth>
</SetupGuard>
}>
<Route index element={<Dashboard />} />
<Route path="proxy-hosts" element={<ProxyHosts />} />
<Route path="remote-servers" element={<RemoteServers />} />
<Route path="domains" element={<Domains />} />
<Route path="certificates" element={<Certificates />} />
{/* DNS Routes */}
<Route path="dns" element={<DNS />}>
<Route index element={<Navigate to="/dns/providers" replace />} />
<Route path="providers" element={<DNSProviders />} />
<Route path="plugins" element={<Plugins />} />
</Route>
{/* Legacy redirect for old bookmarks */}
<Route path="dns-providers" element={<Navigate to="/dns/providers" replace />} />
<Route path="security" element={<Security />} />
<Route path="security/audit-logs" element={<AuditLogs />} />
<Route path="security/access-lists" element={<AccessLists />} />
<Route path="security/crowdsec" element={<CrowdSecConfig />} />
<Route path="security/rate-limiting" element={<RateLimiting />} />
<Route path="security/waf" element={<WafConfig />} />
<Route path="security/headers" element={<SecurityHeaders />} />
<Route path="security/encryption" element={<EncryptionManagement />} />
<Route path="access-lists" element={<AccessLists />} />
<Route path="uptime" element={<Uptime />} />
<Route path="users" element={<UsersPage />} />
<Route path="admin/plugins" element={<Navigate to="/dns/plugins" replace />} />
<Route path="import" element={<Navigate to="/tasks/import/caddyfile" replace />} />
{/* Settings Routes */}
<Route path="settings" element={<Settings />}>
<Route index element={<SystemSettings />} />
<Route path="system" element={<SystemSettings />} />
<Route path="notifications" element={<Notifications />} />
<Route path="smtp" element={<SMTPSettings />} />
<Route path="crowdsec" element={<Navigate to="/security/crowdsec" replace />} />
<Route path="account" element={<Account />} />
<Route path="account-management" element={<UsersPage />} />
</Route>
{/* Tasks Routes */}
<Route path="tasks" element={<Tasks />}>
<Route index element={<Backups />} />
<Route path="backups" element={<Backups />} />
<Route path="logs" element={<Logs />} />
<Route path="import">
<Route path="caddyfile" element={<ImportCaddy />} />
<Route path="crowdsec" element={<ImportCrowdSec />} />
<Route path="npm" element={<ImportNPM />} />
<Route path="json" element={<ImportJSON />} />
</Route>
</Route>
</Route>
</Routes>
</Suspense>
<ToastContainer />
<Toaster
position="bottom-right"
toastOptions={{
duration: 5000,
success: {
style: { background: '#16a34a', color: 'white' },
ariaProps: { role: 'status', 'aria-live': 'polite' },
},
error: {
style: { background: '#dc2626', color: 'white' },
ariaProps: { role: 'alert', 'aria-live': 'assertive' },
},
}}
/>
</Router>
</AuthProvider>
)
}

View File

@@ -1,59 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import i18n from '../i18n'
describe('i18n configuration', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
})
it('initializes with default language', () => {
expect(i18n.language).toBeDefined()
expect(i18n.isInitialized).toBe(true)
})
it('has all required language resources', () => {
const languages = ['en', 'es', 'fr', 'de', 'zh']
languages.forEach((lang) => {
expect(i18n.hasResourceBundle(lang, 'translation')).toBe(true)
})
})
it('translates common keys', () => {
expect(i18n.t('common.save')).toBe('Save')
expect(i18n.t('common.cancel')).toBe('Cancel')
expect(i18n.t('common.delete')).toBe('Delete')
})
it('translates navigation keys', () => {
expect(i18n.t('navigation.dashboard')).toBe('Dashboard')
expect(i18n.t('navigation.settings')).toBe('Settings')
})
it('changes language and translates correctly', async () => {
await i18n.changeLanguage('es')
expect(i18n.t('common.save')).toBe('Guardar')
expect(i18n.t('common.cancel')).toBe('Cancelar')
await i18n.changeLanguage('fr')
expect(i18n.t('common.save')).toBe('Enregistrer')
expect(i18n.t('common.cancel')).toBe('Annuler')
await i18n.changeLanguage('de')
expect(i18n.t('common.save')).toBe('Speichern')
expect(i18n.t('common.cancel')).toBe('Abbrechen')
await i18n.changeLanguage('zh')
expect(i18n.t('common.save')).toBe('保存')
expect(i18n.t('common.cancel')).toBe('取消')
})
it('falls back to English for missing translations', async () => {
await i18n.changeLanguage('en')
const key = 'nonexistent.key'
expect(i18n.t(key)).toBe(key) // Should return the key itself
})
it('supports interpolation', () => {
expect(i18n.t('dashboard.activeHosts', { count: 5 })).toBe('5 active')
})
})

View File

@@ -1,179 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { accessListsApi } from '../accessLists';
import client from '../client';
import type { AccessList } from '../accessLists';
// Mock the client module
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('accessListsApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('list', () => {
it('should fetch all access lists', async () => {
const mockLists: AccessList[] = [
{
id: 1,
uuid: 'test-uuid',
name: 'Test ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
];
vi.mocked(client.get).mockResolvedValueOnce({ data: mockLists });
const result = await accessListsApi.list();
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists');
expect(result).toEqual(mockLists);
});
});
describe('get', () => {
it('should fetch access list by ID', async () => {
const mockList: AccessList = {
id: 1,
uuid: 'test-uuid',
name: 'Test ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.get).mockResolvedValueOnce({ data: mockList });
const result = await accessListsApi.get(1);
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists/1');
expect(result).toEqual(mockList);
});
});
describe('create', () => {
it('should create a new access list', async () => {
const newList = {
name: 'New ACL',
description: 'New description',
type: 'whitelist' as const,
ip_rules: '[{"cidr":"10.0.0.0/8"}]',
enabled: true,
};
const mockResponse: AccessList = {
id: 1,
uuid: 'new-uuid',
...newList,
country_codes: '',
local_network_only: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.create(newList);
expect(client.post).toHaveBeenCalledWith<[string, typeof newList]>('/access-lists', newList);
expect(result).toEqual(mockResponse);
});
});
describe('update', () => {
it('should update an access list', async () => {
const updates = {
name: 'Updated ACL',
enabled: false,
};
const mockResponse: AccessList = {
id: 1,
uuid: 'test-uuid',
name: 'Updated ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.put).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.update(1, updates);
expect(client.put).toHaveBeenCalledWith<[string, typeof updates]>('/access-lists/1', updates);
expect(result).toEqual(mockResponse);
});
});
describe('delete', () => {
it('should delete an access list', async () => {
vi.mocked(client.delete).mockResolvedValueOnce({ data: undefined });
await accessListsApi.delete(1);
expect(client.delete).toHaveBeenCalledWith<[string]>('/access-lists/1');
});
});
describe('testIP', () => {
it('should test an IP against an access list', async () => {
const mockResponse = {
allowed: true,
reason: 'IP matches whitelist rule',
};
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.testIP(1, '192.168.1.100');
expect(client.post).toHaveBeenCalledWith<[string, { ip_address: string }]>('/access-lists/1/test', {
ip_address: '192.168.1.100',
});
expect(result).toEqual(mockResponse);
});
});
describe('getTemplates', () => {
it('should fetch access list templates', async () => {
const mockTemplates = [
{
name: 'Private Networks',
description: 'RFC1918 private networks',
type: 'whitelist' as const,
ip_rules: '[{"cidr":"10.0.0.0/8"},{"cidr":"172.16.0.0/12"},{"cidr":"192.168.0.0/16"}]',
},
];
vi.mocked(client.get).mockResolvedValueOnce({ data: mockTemplates });
const result = await accessListsApi.getTemplates();
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists/templates');
expect(result).toEqual(mockTemplates);
});
});
});

View File

@@ -1,34 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../../api/client'
import { getBackups, createBackup, restoreBackup, deleteBackup } from '../backups'
describe('backups api', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('getBackups returns list', async () => {
const mockData = [{ filename: 'b1.zip', size: 123, time: '2025-01-01T00:00:00Z' }]
vi.spyOn(client, 'get').mockResolvedValueOnce({ data: mockData })
const res = await getBackups()
expect(res).toEqual(mockData)
})
it('createBackup returns filename', async () => {
vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { filename: 'b2.zip' } })
const res = await createBackup()
expect(res).toEqual({ filename: 'b2.zip' })
})
it('restoreBackup posts to restore endpoint', async () => {
const spy = vi.spyOn(client, 'post').mockResolvedValueOnce({})
await restoreBackup('b3.zip')
expect(spy).toHaveBeenCalledWith('/backups/b3.zip/restore')
})
it('deleteBackup deletes backup', async () => {
const spy = vi.spyOn(client, 'delete').mockResolvedValueOnce({})
await deleteBackup('b3.zip')
expect(spy).toHaveBeenCalledWith('/backups/b3.zip')
})
})

View File

@@ -1,52 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import client from '../client';
import { getCertificates, uploadCertificate, deleteCertificate, Certificate } from '../certificates';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
describe('certificates API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockCert: Certificate = {
id: 1,
domain: 'example.com',
issuer: 'Let\'s Encrypt',
expires_at: '2023-01-01',
status: 'valid',
provider: 'letsencrypt',
};
it('getCertificates calls client.get', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockCert] });
const result = await getCertificates();
expect(client.get).toHaveBeenCalledWith('/certificates');
expect(result).toEqual([mockCert]);
});
it('uploadCertificate calls client.post with FormData', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockCert });
const certFile = new File(['cert'], 'cert.pem', { type: 'text/plain' });
const keyFile = new File(['key'], 'key.pem', { type: 'text/plain' });
const result = await uploadCertificate('My Cert', certFile, keyFile);
expect(client.post).toHaveBeenCalledWith('/certificates', expect.any(FormData), {
headers: { 'Content-Type': 'multipart/form-data' },
});
expect(result).toEqual(mockCert);
});
it('deleteCertificate calls client.delete', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: {} });
await deleteCertificate(1);
expect(client.delete).toHaveBeenCalledWith('/certificates/1');
});
});

View File

@@ -1,507 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as consoleEnrollment from '../consoleEnrollment'
import client from '../client'
vi.mock('../client')
describe('consoleEnrollment API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getConsoleStatus', () => {
it('should fetch enrollment status with pending state', async () => {
const mockStatus = {
status: 'pending',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
last_attempt_at: '2025-12-15T09:00:00Z',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/console/status')
expect(result).toEqual(mockStatus)
expect(result.status).toBe('pending')
expect(result.key_present).toBe(true)
})
it('should fetch enrolled status with heartbeat', async () => {
const mockStatus = {
status: 'enrolled',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
last_heartbeat_at: '2025-12-15T09:55:00Z',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(result.status).toBe('enrolled')
expect(result.enrolled_at).toBeDefined()
expect(result.last_heartbeat_at).toBeDefined()
})
it('should fetch failed status with error message', async () => {
const mockStatus = {
status: 'failed',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: false,
last_error: 'Invalid enrollment key',
last_attempt_at: '2025-12-15T09:00:00Z',
correlation_id: 'req-abc123',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(result.status).toBe('failed')
expect(result.last_error).toBe('Invalid enrollment key')
expect(result.correlation_id).toBe('req-abc123')
expect(result.key_present).toBe(false)
})
it('should fetch status with none state (not enrolled)', async () => {
const mockStatus = {
status: 'none',
key_present: false,
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(result.status).toBe('none')
expect(result.key_present).toBe(false)
expect(result.tenant).toBeUndefined()
})
it('should NOT return enrollment key in status response', async () => {
const mockStatus = {
status: 'enrolled',
tenant: 'test-org',
agent_name: 'test-agent',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
// Security test: Ensure key is never exposed
expect(result).not.toHaveProperty('enrollment_key')
expect(result).not.toHaveProperty('encrypted_enroll_key')
expect(result).toHaveProperty('key_present')
})
it('should handle API errors', async () => {
const error = new Error('Network error')
vi.mocked(client.get).mockRejectedValue(error)
await expect(consoleEnrollment.getConsoleStatus()).rejects.toThrow('Network error')
})
it('should handle server unavailability', async () => {
const error = {
response: {
status: 503,
data: { error: 'Service temporarily unavailable' },
},
}
vi.mocked(client.get).mockRejectedValue(error)
await expect(consoleEnrollment.getConsoleStatus()).rejects.toEqual(error)
})
})
describe('enrollConsole', () => {
it('should enroll with valid payload', async () => {
const payload = {
enrollment_key: 'cs-enroll-abc123xyz',
tenant: 'my-org',
agent_name: 'charon-prod',
force: false,
}
const mockResponse = {
status: 'enrolled',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/console/enroll', payload)
expect(result).toEqual(mockResponse)
expect(result.status).toBe('enrolled')
expect(result.enrolled_at).toBeDefined()
})
it('should enroll with minimal payload (no tenant)', async () => {
const payload = {
enrollment_key: 'cs-enroll-key123',
agent_name: 'charon-test',
}
const mockResponse = {
status: 'enrolled',
agent_name: 'charon-test',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('enrolled')
expect(result.agent_name).toBe('charon-test')
})
it('should force re-enrollment when force=true', async () => {
const payload = {
enrollment_key: 'cs-enroll-new-key',
agent_name: 'charon-updated',
force: true,
}
const mockResponse = {
status: 'enrolled',
agent_name: 'charon-updated',
key_present: true,
enrolled_at: '2025-12-15T10:05:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('enrolled')
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/console/enroll', payload)
})
it('should handle invalid enrollment key format', async () => {
const payload = {
enrollment_key: 'not-a-valid-key',
agent_name: 'test',
}
const error = {
response: {
status: 400,
data: { error: 'Invalid enrollment key format' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toEqual(error)
})
it('should handle transient network errors during enrollment', async () => {
const payload = {
enrollment_key: 'cs-enroll-key123',
agent_name: 'test-agent',
}
const error = {
response: {
status: 503,
data: { error: 'CrowdSec Console API temporarily unavailable' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toEqual(error)
})
it('should handle enrollment key expiration', async () => {
const payload = {
enrollment_key: 'cs-enroll-expired-key',
agent_name: 'test',
}
const mockResponse = {
status: 'failed',
key_present: false,
last_error: 'Enrollment key expired',
correlation_id: 'err-expired-123',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('failed')
expect(result.last_error).toBe('Enrollment key expired')
})
it('should sanitize tenant name with special characters', async () => {
const payload = {
enrollment_key: 'valid-key',
tenant: 'My Org (Production)',
agent_name: 'agent1',
}
const mockResponse = {
status: 'enrolled',
tenant: 'My Org (Production)',
agent_name: 'agent1',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('enrolled')
expect(result.tenant).toBe('My Org (Production)')
})
it('should handle SQL injection attempts in agent_name', async () => {
const payload = {
enrollment_key: 'valid-key',
agent_name: "'; DROP TABLE users; --",
}
const error = {
response: {
status: 400,
data: { error: 'Invalid agent name format' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toEqual(error)
})
it('should handle CrowdSec not running during enrollment', async () => {
const payload = {
enrollment_key: 'valid-key',
agent_name: 'test',
}
const error = {
response: {
status: 500,
data: { error: 'CrowdSec is not running. Start CrowdSec before enrolling.' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toEqual(error)
})
it('should return pending status when enrollment is queued', async () => {
const payload = {
enrollment_key: 'valid-key',
agent_name: 'test',
}
const mockResponse = {
status: 'pending',
agent_name: 'test',
key_present: true,
last_attempt_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('pending')
expect(result.last_attempt_at).toBeDefined()
})
})
describe('default export', () => {
it('should export all functions', () => {
expect(consoleEnrollment.default).toHaveProperty('getConsoleStatus')
expect(consoleEnrollment.default).toHaveProperty('enrollConsole')
})
})
describe('integration scenarios', () => {
it('should handle full enrollment workflow: status → enroll → verify', async () => {
// 1. Check initial status (not enrolled)
const mockStatusNone = {
status: 'none',
key_present: false,
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockStatusNone })
const statusBefore = await consoleEnrollment.getConsoleStatus()
expect(statusBefore.status).toBe('none')
// 2. Enroll
const enrollPayload = {
enrollment_key: 'cs-enroll-valid-key',
tenant: 'test-org',
agent_name: 'charon-test',
}
const mockEnrollResponse = {
status: 'enrolled',
tenant: 'test-org',
agent_name: 'charon-test',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockEnrollResponse })
const enrollResult = await consoleEnrollment.enrollConsole(enrollPayload)
expect(enrollResult.status).toBe('enrolled')
// 3. Verify status updated
const mockStatusEnrolled = {
status: 'enrolled',
tenant: 'test-org',
agent_name: 'charon-test',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
last_heartbeat_at: '2025-12-15T10:01:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockStatusEnrolled })
const statusAfter = await consoleEnrollment.getConsoleStatus()
expect(statusAfter.status).toBe('enrolled')
expect(statusAfter.tenant).toBe('test-org')
})
it('should handle enrollment failure and retry', async () => {
// 1. First enrollment attempt fails
const payload = {
enrollment_key: 'cs-enroll-key',
agent_name: 'test',
}
const networkError = new Error('Network timeout')
vi.mocked(client.post).mockRejectedValueOnce(networkError)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toThrow('Network timeout')
// 2. Retry succeeds
const mockResponse = {
status: 'enrolled',
agent_name: 'test',
key_present: true,
enrolled_at: '2025-12-15T10:05:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResponse })
const retryResult = await consoleEnrollment.enrollConsole(payload)
expect(retryResult.status).toBe('enrolled')
})
it('should handle status transitions: none → pending → enrolled', async () => {
// 1. Initial: none
const mockNone = { status: 'none', key_present: false }
vi.mocked(client.get).mockResolvedValueOnce({ data: mockNone })
const status1 = await consoleEnrollment.getConsoleStatus()
expect(status1.status).toBe('none')
// 2. Enroll (returns pending)
const payload = { enrollment_key: 'key', agent_name: 'agent' }
const mockPending = {
status: 'pending',
agent_name: 'agent',
key_present: true,
last_attempt_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockPending })
const enrollResult = await consoleEnrollment.enrollConsole(payload)
expect(enrollResult.status).toBe('pending')
// 3. Check status again (now enrolled)
const mockEnrolled = {
status: 'enrolled',
agent_name: 'agent',
key_present: true,
enrolled_at: '2025-12-15T10:00:30Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockEnrolled })
const status2 = await consoleEnrollment.getConsoleStatus()
expect(status2.status).toBe('enrolled')
})
it('should handle force re-enrollment over existing enrollment', async () => {
// 1. Check current enrollment
const mockCurrent = {
status: 'enrolled',
tenant: 'old-org',
agent_name: 'old-agent',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockCurrent })
const currentStatus = await consoleEnrollment.getConsoleStatus()
expect(currentStatus.tenant).toBe('old-org')
// 2. Force re-enrollment
const forcePayload = {
enrollment_key: 'new-key',
tenant: 'new-org',
agent_name: 'new-agent',
force: true,
}
const mockForced = {
status: 'enrolled',
tenant: 'new-org',
agent_name: 'new-agent',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockForced })
const forceResult = await consoleEnrollment.enrollConsole(forcePayload)
expect(forceResult.tenant).toBe('new-org')
})
})
describe('security tests', () => {
it('should never log or expose enrollment key', async () => {
const payload = {
enrollment_key: 'cs-enroll-secret-key-should-never-log',
agent_name: 'test',
}
const mockResponse = {
status: 'enrolled',
agent_name: 'test',
key_present: true,
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
// Ensure response never contains the key
expect(result).not.toHaveProperty('enrollment_key')
expect(JSON.stringify(result)).not.toContain('cs-enroll-secret-key')
})
it('should sanitize error messages to avoid key leakage', async () => {
const payload = {
enrollment_key: 'cs-enroll-sensitive-key',
agent_name: 'test',
}
const error = {
response: {
status: 400,
data: { error: 'Enrollment failed: invalid key format' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
try {
await consoleEnrollment.enrollConsole(payload)
} catch (e: unknown) {
// Error message should NOT contain the key
const error = e as { response?: { data?: { error?: string } } }
expect(error.response?.data?.error).not.toContain('cs-enroll-sensitive-key')
}
})
it('should handle correlation_id for debugging without exposing keys', async () => {
const mockStatus = {
status: 'failed',
key_present: false,
last_error: 'Authentication failed',
correlation_id: 'debug-correlation-abc123',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(result.correlation_id).toBe('debug-correlation-abc123')
expect(result).not.toHaveProperty('enrollment_key')
})
})
})

View File

@@ -1,119 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getCredentials,
getCredential,
createCredential,
updateCredential,
deleteCredential,
testCredential,
enableMultiCredentials,
type DNSProviderCredential,
type CredentialRequest,
type CredentialTestResult,
} from '../credentials'
import client from '../client'
vi.mock('../client')
const mockCredential: DNSProviderCredential = {
id: 1,
uuid: 'test-uuid-1',
dns_provider_id: 1,
label: 'Production Credentials',
zone_filter: '*.example.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 2,
key_version: 1,
success_count: 5,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
const mockCredentialRequest: CredentialRequest = {
label: 'New Credentials',
zone_filter: '*.example.com',
credentials: { api_token: 'test-token-123' },
propagation_timeout: 120,
polling_interval: 2,
enabled: true,
}
describe('credentials API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call getCredentials with correct endpoint', async () => {
const mockData = [mockCredential, { ...mockCredential, id: 2, label: 'Secondary' }]
vi.mocked(client.get).mockResolvedValue({
data: { credentials: mockData, total: 2 },
})
const result = await getCredentials(1)
expect(client.get).toHaveBeenCalledWith('/dns-providers/1/credentials')
expect(result).toEqual(mockData)
expect(result).toHaveLength(2)
})
it('should call getCredential with correct endpoint', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockCredential })
const result = await getCredential(1, 1)
expect(client.get).toHaveBeenCalledWith('/dns-providers/1/credentials/1')
expect(result).toEqual(mockCredential)
})
it('should call createCredential with correct endpoint and data', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockCredential })
const result = await createCredential(1, mockCredentialRequest)
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/credentials', mockCredentialRequest)
expect(result).toEqual(mockCredential)
})
it('should call updateCredential with correct endpoint and data', async () => {
const updatedCredential = { ...mockCredential, label: 'Updated Label' }
vi.mocked(client.put).mockResolvedValue({ data: updatedCredential })
const result = await updateCredential(1, 1, mockCredentialRequest)
expect(client.put).toHaveBeenCalledWith('/dns-providers/1/credentials/1', mockCredentialRequest)
expect(result).toEqual(updatedCredential)
})
it('should call deleteCredential with correct endpoint', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: undefined })
await deleteCredential(1, 1)
expect(client.delete).toHaveBeenCalledWith('/dns-providers/1/credentials/1')
})
it('should call testCredential with correct endpoint', async () => {
const mockTestResult: CredentialTestResult = {
success: true,
message: 'Credentials validated successfully',
propagation_time_ms: 1200,
}
vi.mocked(client.post).mockResolvedValue({ data: mockTestResult })
const result = await testCredential(1, 1)
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/credentials/1/test')
expect(result).toEqual(mockTestResult)
expect(result.success).toBe(true)
})
it('should call enableMultiCredentials with correct endpoint', async () => {
vi.mocked(client.post).mockResolvedValue({ data: undefined })
await enableMultiCredentials(1)
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/enable-multi-credentials')
})
})

View File

@@ -1,130 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as crowdsec from '../crowdsec'
import client from '../client'
vi.mock('../client')
describe('crowdsec API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('startCrowdsec', () => {
it('should call POST /admin/crowdsec/start', async () => {
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await crowdsec.startCrowdsec()
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/start')
expect(result).toEqual(mockData)
})
})
describe('stopCrowdsec', () => {
it('should call POST /admin/crowdsec/stop', async () => {
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await crowdsec.stopCrowdsec()
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/stop')
expect(result).toEqual(mockData)
})
})
describe('statusCrowdsec', () => {
it('should call GET /admin/crowdsec/status', async () => {
const mockData = { running: true, pid: 1234 }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await crowdsec.statusCrowdsec()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/status')
expect(result).toEqual(mockData)
})
})
describe('importCrowdsecConfig', () => {
it('should call POST /admin/crowdsec/import with FormData', async () => {
const mockFile = new File(['content'], 'config.tar.gz', { type: 'application/gzip' })
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await crowdsec.importCrowdsecConfig(mockFile)
expect(client.post).toHaveBeenCalledWith(
'/admin/crowdsec/import',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
expect(result).toEqual(mockData)
})
})
describe('exportCrowdsecConfig', () => {
it('should call GET /admin/crowdsec/export with blob responseType', async () => {
const mockBlob = new Blob(['data'], { type: 'application/gzip' })
vi.mocked(client.get).mockResolvedValue({ data: mockBlob })
const result = await crowdsec.exportCrowdsecConfig()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/export', { responseType: 'blob' })
expect(result).toEqual(mockBlob)
})
})
describe('listCrowdsecFiles', () => {
it('should call GET /admin/crowdsec/files', async () => {
const mockData = { files: ['file1.yaml', 'file2.yaml'] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await crowdsec.listCrowdsecFiles()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/files')
expect(result).toEqual(mockData)
})
})
describe('readCrowdsecFile', () => {
it('should call GET /admin/crowdsec/file with encoded path', async () => {
const mockData = { content: 'file content' }
const path = '/etc/crowdsec/file.yaml'
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await crowdsec.readCrowdsecFile(path)
expect(client.get).toHaveBeenCalledWith(
`/admin/crowdsec/file?path=${encodeURIComponent(path)}`
)
expect(result).toEqual(mockData)
})
})
describe('writeCrowdsecFile', () => {
it('should call POST /admin/crowdsec/file with path and content', async () => {
const mockData = { success: true }
const path = '/etc/crowdsec/file.yaml'
const content = 'new content'
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await crowdsec.writeCrowdsecFile(path, content)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/file', { path, content })
expect(result).toEqual(mockData)
})
})
describe('default export', () => {
it('should export all functions', () => {
expect(crowdsec.default).toHaveProperty('startCrowdsec')
expect(crowdsec.default).toHaveProperty('stopCrowdsec')
expect(crowdsec.default).toHaveProperty('statusCrowdsec')
expect(crowdsec.default).toHaveProperty('importCrowdsecConfig')
expect(crowdsec.default).toHaveProperty('exportCrowdsecConfig')
expect(crowdsec.default).toHaveProperty('listCrowdsecFiles')
expect(crowdsec.default).toHaveProperty('readCrowdsecFile')
expect(crowdsec.default).toHaveProperty('writeCrowdsecFile')
})
})
})

View File

@@ -1,138 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { detectDNSProvider, getDetectionPatterns } from '../dnsDetection'
import client from '../client'
import type { DetectionResult, NameserverPattern } from '../dnsDetection'
vi.mock('../client')
describe('dnsDetection API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('detectDNSProvider', () => {
it('should detect DNS provider successfully', async () => {
const mockResponse: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'cloudflare',
nameservers: ['ns1.cloudflare.com', 'ns2.cloudflare.com'],
confidence: 'high',
suggested_provider: {
id: 1,
uuid: 'test-uuid',
name: 'Production Cloudflare',
provider_type: 'cloudflare',
enabled: true,
is_default: true,
has_credentials: true,
propagation_timeout: 120,
polling_interval: 5,
success_count: 10,
failure_count: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await detectDNSProvider('example.com')
expect(client.post).toHaveBeenCalledWith('/dns-providers/detect', { domain: 'example.com' })
expect(result).toEqual(mockResponse)
expect(result.detected).toBe(true)
expect(result.provider_type).toBe('cloudflare')
expect(result.confidence).toBe('high')
})
it('should handle detection failure (no provider found)', async () => {
const mockResponse: DetectionResult = {
domain: 'example.com',
detected: false,
nameservers: ['ns1.unknown.com', 'ns2.unknown.com'],
confidence: 'none',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await detectDNSProvider('example.com')
expect(result.detected).toBe(false)
expect(result.confidence).toBe('none')
expect(result.nameservers).toHaveLength(2)
})
it('should handle detection error', async () => {
const mockResponse: DetectionResult = {
domain: 'invalid.domain',
detected: false,
nameservers: [],
confidence: 'none',
error: 'Failed to lookup nameservers: domain not found',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await detectDNSProvider('invalid.domain')
expect(result.detected).toBe(false)
expect(result.error).toContain('domain not found')
})
it('should handle network error', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
await expect(detectDNSProvider('example.com')).rejects.toThrow('Network error')
})
it('should handle medium confidence detection', async () => {
const mockResponse: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'route53',
nameservers: ['ns-123.awsdns-12.com'],
confidence: 'medium',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await detectDNSProvider('example.com')
expect(result.confidence).toBe('medium')
expect(result.detected).toBe(true)
})
})
describe('getDetectionPatterns', () => {
it('should fetch detection patterns successfully', async () => {
const mockPatterns: NameserverPattern[] = [
{ pattern: '.ns.cloudflare.com', provider_type: 'cloudflare' },
{ pattern: '.awsdns', provider_type: 'route53' },
{ pattern: '.digitalocean.com', provider_type: 'digitalocean' },
]
vi.mocked(client.get).mockResolvedValue({ data: { patterns: mockPatterns } })
const result = await getDetectionPatterns()
expect(client.get).toHaveBeenCalledWith('/dns-providers/patterns')
expect(result).toEqual(mockPatterns)
expect(result).toHaveLength(3)
})
it('should handle empty patterns list', async () => {
vi.mocked(client.get).mockResolvedValue({ data: { patterns: [] } })
const result = await getDetectionPatterns()
expect(result).toEqual([])
})
it('should handle network error when fetching patterns', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Network error'))
await expect(getDetectionPatterns()).rejects.toThrow('Network error')
})
})
})

View File

@@ -1,431 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getDNSProviders,
getDNSProvider,
getDNSProviderTypes,
createDNSProvider,
updateDNSProvider,
deleteDNSProvider,
testDNSProvider,
testDNSProviderCredentials,
type DNSProvider,
type DNSProviderRequest,
type DNSProviderTypeInfo,
} from '../dnsProviders'
import client from '../client'
vi.mock('../client')
const mockProvider: DNSProvider = {
id: 1,
uuid: 'test-uuid-1',
name: 'Cloudflare Production',
provider_type: 'cloudflare',
enabled: true,
is_default: true,
has_credentials: true,
propagation_timeout: 120,
polling_interval: 2,
success_count: 5,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
const mockProviderType: DNSProviderTypeInfo = {
type: 'cloudflare',
name: 'Cloudflare',
fields: [
{
name: 'api_token',
label: 'API Token',
type: 'password',
required: true,
hint: 'Cloudflare API token with DNS edit permissions',
},
],
documentation_url: 'https://developers.cloudflare.com/api/',
}
describe('getDNSProviders', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches all DNS providers successfully', async () => {
const mockProviders = [mockProvider, { ...mockProvider, id: 2, name: 'Secondary' }]
vi.mocked(client.get).mockResolvedValue({
data: { providers: mockProviders, total: 2 },
})
const result = await getDNSProviders()
expect(client.get).toHaveBeenCalledWith('/dns-providers')
expect(result).toEqual(mockProviders)
expect(result).toHaveLength(2)
})
it('returns empty array when no providers exist', async () => {
vi.mocked(client.get).mockResolvedValue({
data: { providers: [], total: 0 },
})
const result = await getDNSProviders()
expect(result).toEqual([])
})
it('handles network errors', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Network error'))
await expect(getDNSProviders()).rejects.toThrow('Network error')
})
it('handles server errors', async () => {
vi.mocked(client.get).mockRejectedValue({ response: { status: 500 } })
await expect(getDNSProviders()).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('getDNSProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches single provider by valid ID', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockProvider })
const result = await getDNSProvider(1)
expect(client.get).toHaveBeenCalledWith('/dns-providers/1')
expect(result).toEqual(mockProvider)
})
it('handles not found error for invalid ID', async () => {
vi.mocked(client.get).mockRejectedValue({ response: { status: 404 } })
await expect(getDNSProvider(999)).rejects.toMatchObject({
response: { status: 404 },
})
})
it('handles server errors', async () => {
vi.mocked(client.get).mockRejectedValue({ response: { status: 500 } })
await expect(getDNSProvider(1)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('getDNSProviderTypes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches supported provider types with field definitions', async () => {
const mockTypes = [
mockProviderType,
{
type: 'route53',
name: 'AWS Route 53',
fields: [
{ name: 'access_key_id', label: 'Access Key ID', type: 'text', required: true },
{ name: 'secret_access_key', label: 'Secret Access Key', type: 'password', required: true },
],
documentation_url: 'https://aws.amazon.com/route53/',
} as DNSProviderTypeInfo,
]
vi.mocked(client.get).mockResolvedValue({
data: { types: mockTypes },
})
const result = await getDNSProviderTypes()
expect(client.get).toHaveBeenCalledWith('/dns-providers/types')
expect(result).toEqual(mockTypes)
expect(result).toHaveLength(2)
})
it('handles errors when fetching types', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Failed to fetch types'))
await expect(getDNSProviderTypes()).rejects.toThrow('Failed to fetch types')
})
})
describe('createDNSProvider', () => {
const validRequest: DNSProviderRequest = {
name: 'New Cloudflare',
provider_type: 'cloudflare',
credentials: { api_token: 'test-token-123' },
propagation_timeout: 120,
polling_interval: 2,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('creates provider successfully and returns with ID', async () => {
const createdProvider = { ...mockProvider, id: 5, name: 'New Cloudflare' }
vi.mocked(client.post).mockResolvedValue({ data: createdProvider })
const result = await createDNSProvider(validRequest)
expect(client.post).toHaveBeenCalledWith('/dns-providers', validRequest)
expect(result).toEqual(createdProvider)
expect(result.id).toBe(5)
})
it('handles validation error for missing required fields', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { status: 400, data: { error: 'Missing required field: api_token' } },
})
await expect(
createDNSProvider({ ...validRequest, credentials: {} })
).rejects.toMatchObject({
response: { status: 400 },
})
})
it('handles validation error for invalid provider type', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { status: 400, data: { error: 'Invalid provider type' } },
})
await expect(
createDNSProvider({ ...validRequest, provider_type: 'invalid' as DNSProviderRequest['provider_type'] })
).rejects.toMatchObject({
response: { status: 400 },
})
})
it('handles duplicate name error', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { status: 409, data: { error: 'Provider with this name already exists' } },
})
await expect(createDNSProvider(validRequest)).rejects.toMatchObject({
response: { status: 409 },
})
})
it('handles server errors', async () => {
vi.mocked(client.post).mockRejectedValue({ response: { status: 500 } })
await expect(createDNSProvider(validRequest)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('updateDNSProvider', () => {
const updateRequest: DNSProviderRequest = {
name: 'Updated Name',
provider_type: 'cloudflare',
credentials: { api_token: 'new-token' },
}
beforeEach(() => {
vi.clearAllMocks()
})
it('updates provider successfully', async () => {
const updatedProvider = { ...mockProvider, name: 'Updated Name' }
vi.mocked(client.put).mockResolvedValue({ data: updatedProvider })
const result = await updateDNSProvider(1, updateRequest)
expect(client.put).toHaveBeenCalledWith('/dns-providers/1', updateRequest)
expect(result).toEqual(updatedProvider)
expect(result.name).toBe('Updated Name')
})
it('handles not found error', async () => {
vi.mocked(client.put).mockRejectedValue({ response: { status: 404 } })
await expect(updateDNSProvider(999, updateRequest)).rejects.toMatchObject({
response: { status: 404 },
})
})
it('handles validation errors', async () => {
vi.mocked(client.put).mockRejectedValue({
response: { status: 400, data: { error: 'Invalid credentials' } },
})
await expect(updateDNSProvider(1, updateRequest)).rejects.toMatchObject({
response: { status: 400 },
})
})
it('handles server errors', async () => {
vi.mocked(client.put).mockRejectedValue({ response: { status: 500 } })
await expect(updateDNSProvider(1, updateRequest)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('deleteDNSProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('deletes provider successfully', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: undefined })
await deleteDNSProvider(1)
expect(client.delete).toHaveBeenCalledWith('/dns-providers/1')
})
it('handles not found error', async () => {
vi.mocked(client.delete).mockRejectedValue({ response: { status: 404 } })
await expect(deleteDNSProvider(999)).rejects.toMatchObject({
response: { status: 404 },
})
})
it('handles in-use error when provider used by proxy hosts', async () => {
vi.mocked(client.delete).mockRejectedValue({
response: {
status: 409,
data: { error: 'Cannot delete provider in use by proxy hosts' },
},
})
await expect(deleteDNSProvider(1)).rejects.toMatchObject({
response: { status: 409 },
})
})
it('handles server errors', async () => {
vi.mocked(client.delete).mockRejectedValue({ response: { status: 500 } })
await expect(deleteDNSProvider(1)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('testDNSProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns success result with propagation time', async () => {
const successResult = {
success: true,
message: 'DNS challenge completed successfully',
propagation_time_ms: 1500,
}
vi.mocked(client.post).mockResolvedValue({ data: successResult })
const result = await testDNSProvider(1)
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/test')
expect(result).toEqual(successResult)
expect(result.success).toBe(true)
expect(result.propagation_time_ms).toBe(1500)
})
it('returns failure result with error message', async () => {
const failureResult = {
success: false,
error: 'Invalid API token',
code: 'AUTH_FAILED',
}
vi.mocked(client.post).mockResolvedValue({ data: failureResult })
const result = await testDNSProvider(1)
expect(result).toEqual(failureResult)
expect(result.success).toBe(false)
expect(result.error).toBe('Invalid API token')
})
it('handles not found error', async () => {
vi.mocked(client.post).mockRejectedValue({ response: { status: 404 } })
await expect(testDNSProvider(999)).rejects.toMatchObject({
response: { status: 404 },
})
})
it('handles server errors', async () => {
vi.mocked(client.post).mockRejectedValue({ response: { status: 500 } })
await expect(testDNSProvider(1)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('testDNSProviderCredentials', () => {
const testRequest: DNSProviderRequest = {
name: 'Test Provider',
provider_type: 'cloudflare',
credentials: { api_token: 'test-token' },
}
beforeEach(() => {
vi.clearAllMocks()
})
it('returns success for valid credentials', async () => {
const successResult = {
success: true,
message: 'Credentials validated successfully',
propagation_time_ms: 800,
}
vi.mocked(client.post).mockResolvedValue({ data: successResult })
const result = await testDNSProviderCredentials(testRequest)
expect(client.post).toHaveBeenCalledWith('/dns-providers/test', testRequest)
expect(result).toEqual(successResult)
expect(result.success).toBe(true)
})
it('returns failure for invalid credentials', async () => {
const failureResult = {
success: false,
error: 'Authentication failed',
code: 'INVALID_CREDENTIALS',
}
vi.mocked(client.post).mockResolvedValue({ data: failureResult })
const result = await testDNSProviderCredentials(testRequest)
expect(result).toEqual(failureResult)
expect(result.success).toBe(false)
})
it('handles validation errors for missing credentials', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { status: 400, data: { error: 'Missing required field: api_token' } },
})
await expect(
testDNSProviderCredentials({ ...testRequest, credentials: {} })
).rejects.toMatchObject({
response: { status: 400 },
})
})
it('handles server errors', async () => {
vi.mocked(client.post).mockRejectedValue({ response: { status: 500 } })
await expect(testDNSProviderCredentials(testRequest)).rejects.toMatchObject({
response: { status: 500 },
})
})
})

View File

@@ -1,96 +0,0 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { dockerApi } from '../docker';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
},
}));
describe('dockerApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('listContainers', () => {
const mockContainers = [
{
id: 'abc123',
names: ['/container1'],
image: 'nginx:latest',
state: 'running',
status: 'Up 2 hours',
network: 'bridge',
ip: '172.17.0.2',
ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }],
},
{
id: 'def456',
names: ['/container2'],
image: 'redis:alpine',
state: 'running',
status: 'Up 1 hour',
network: 'bridge',
ip: '172.17.0.3',
ports: [],
},
];
it('fetches containers without parameters', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockContainers });
const result = await dockerApi.listContainers();
expect(client.get).toHaveBeenCalledWith('/docker/containers', { params: {} });
expect(result).toEqual(mockContainers);
});
it('fetches containers with host parameter', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockContainers });
const result = await dockerApi.listContainers('192.168.1.100');
expect(client.get).toHaveBeenCalledWith('/docker/containers', {
params: { host: '192.168.1.100' },
});
expect(result).toEqual(mockContainers);
});
it('fetches containers with serverId parameter', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockContainers });
const result = await dockerApi.listContainers(undefined, 'server-uuid-123');
expect(client.get).toHaveBeenCalledWith('/docker/containers', {
params: { server_id: 'server-uuid-123' },
});
expect(result).toEqual(mockContainers);
});
it('fetches containers with both host and serverId parameters', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockContainers });
const result = await dockerApi.listContainers('192.168.1.100', 'server-uuid-123');
expect(client.get).toHaveBeenCalledWith('/docker/containers', {
params: { host: '192.168.1.100', server_id: 'server-uuid-123' },
});
expect(result).toEqual(mockContainers);
});
it('returns empty array when no containers', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [] });
const result = await dockerApi.listContainers();
expect(result).toEqual([]);
});
it('handles API error', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Network error'));
await expect(dockerApi.listContainers()).rejects.toThrow('Network error');
});
});
});

View File

@@ -1,44 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import client from '../client';
import { getDomains, createDomain, deleteDomain, Domain } from '../domains';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
describe('domains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockDomain: Domain = {
id: 1,
uuid: '123',
name: 'example.com',
created_at: '2023-01-01',
};
it('getDomains calls client.get', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockDomain] });
const result = await getDomains();
expect(client.get).toHaveBeenCalledWith('/domains');
expect(result).toEqual([mockDomain]);
});
it('createDomain calls client.post', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockDomain });
const result = await createDomain('example.com');
expect(client.post).toHaveBeenCalledWith('/domains', { name: 'example.com' });
expect(result).toEqual(mockDomain);
});
it('deleteDomain calls client.delete', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: {} });
await deleteDomain('123');
expect(client.delete).toHaveBeenCalledWith('/domains/123');
});
});

View File

@@ -1,95 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getEncryptionStatus,
rotateEncryptionKey,
getRotationHistory,
validateKeyConfiguration,
type RotationStatus,
type RotationResult,
type RotationHistoryEntry,
type KeyValidationResult,
} from '../encryption'
import client from '../client'
vi.mock('../client')
const mockRotationStatus: RotationStatus = {
current_version: 2,
next_key_configured: true,
legacy_key_count: 1,
providers_on_current_version: 5,
providers_on_older_versions: 0,
}
const mockRotationResult: RotationResult = {
total_providers: 5,
success_count: 5,
failure_count: 0,
duration: '2.5s',
new_key_version: 3,
}
const mockHistoryEntry: RotationHistoryEntry = {
id: 1,
uuid: 'test-uuid-1',
actor: 'admin@example.com',
action: 'encryption_key_rotated',
event_category: 'security',
details: 'Rotated from version 1 to version 2',
created_at: '2025-01-01T00:00:00Z',
}
const mockValidationResult: KeyValidationResult = {
valid: true,
message: 'Key configuration is valid',
}
describe('encryption API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call getEncryptionStatus with correct endpoint', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockRotationStatus })
const result = await getEncryptionStatus()
expect(client.get).toHaveBeenCalledWith('/admin/encryption/status')
expect(result).toEqual(mockRotationStatus)
expect(result.current_version).toBe(2)
})
it('should call rotateEncryptionKey with correct endpoint', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockRotationResult })
const result = await rotateEncryptionKey()
expect(client.post).toHaveBeenCalledWith('/admin/encryption/rotate')
expect(result).toEqual(mockRotationResult)
expect(result.new_key_version).toBe(3)
expect(result.success_count).toBe(5)
})
it('should call getRotationHistory with correct endpoint', async () => {
const mockHistory = [mockHistoryEntry, { ...mockHistoryEntry, id: 2 }]
vi.mocked(client.get).mockResolvedValue({
data: { history: mockHistory, total: 2 },
})
const result = await getRotationHistory()
expect(client.get).toHaveBeenCalledWith('/admin/encryption/history')
expect(result).toEqual(mockHistory)
expect(result).toHaveLength(2)
})
it('should call validateKeyConfiguration with correct endpoint', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockValidationResult })
const result = await validateKeyConfiguration()
expect(client.post).toHaveBeenCalledWith('/admin/encryption/validate')
expect(result).toEqual(mockValidationResult)
expect(result.valid).toBe(true)
})
})

View File

@@ -1,218 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { connectLiveLogs } from '../logs';
// Mock WebSocket
class MockWebSocket {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((error: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
readyState: number = WebSocket.CONNECTING;
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
constructor(url: string) {
this.url = url;
// Simulate connection opening
setTimeout(() => {
this.readyState = WebSocket.OPEN;
}, 0);
}
close() {
this.readyState = WebSocket.CLOSING;
setTimeout(() => {
this.readyState = WebSocket.CLOSED;
const closeEvent = { code: 1000, reason: '', wasClean: true } as CloseEvent;
if (this.onclose) {
this.onclose(closeEvent);
}
}, 0);
}
simulateMessage(data: string) {
if (this.onmessage) {
const event = new MessageEvent('message', { data });
this.onmessage(event);
}
}
simulateError() {
if (this.onerror) {
const event = new Event('error');
this.onerror(event);
}
}
}
describe('logs API - connectLiveLogs', () => {
let mockWebSocket: MockWebSocket;
beforeEach(() => {
// Mock global WebSocket
mockWebSocket = new MockWebSocket('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).WebSocket = class MockedWebSocket extends MockWebSocket {
constructor(url: string) {
super(url);
// eslint-disable-next-line @typescript-eslint/no-this-alias
mockWebSocket = this;
}
} as unknown as typeof WebSocket;
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
protocol: 'http:',
host: 'localhost:8080',
},
writable: true,
});
});
it('creates WebSocket connection with correct URL', () => {
connectLiveLogs({}, vi.fn());
expect(mockWebSocket.url).toBe('ws://localhost:8080/api/v1/logs/live?');
});
it('uses wss protocol when page is https', () => {
Object.defineProperty(window, 'location', {
value: {
protocol: 'https:',
host: 'example.com',
},
writable: true,
});
connectLiveLogs({}, vi.fn());
expect(mockWebSocket.url).toBe('wss://example.com/api/v1/logs/live?');
});
it('includes filters in query parameters', () => {
connectLiveLogs({ level: 'error', source: 'waf' }, vi.fn());
expect(mockWebSocket.url).toContain('level=error');
expect(mockWebSocket.url).toContain('source=waf');
});
it('calls onMessage callback when message is received', () => {
const mockOnMessage = vi.fn();
connectLiveLogs({}, mockOnMessage);
const logData = {
level: 'info',
timestamp: '2025-12-09T10:30:00Z',
message: 'Test message',
};
mockWebSocket.simulateMessage(JSON.stringify(logData));
expect(mockOnMessage).toHaveBeenCalledWith(logData);
});
it('handles JSON parse errors gracefully', () => {
const mockOnMessage = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
connectLiveLogs({}, mockOnMessage);
mockWebSocket.simulateMessage('invalid json');
expect(mockOnMessage).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to parse log message:', expect.any(Error));
consoleErrorSpy.mockRestore();
});
// These tests are skipped because the WebSocket mock has timing issues with event handlers
// The functionality is covered by E2E tests
it.skip('calls onError callback when error occurs', async () => {
const mockOnError = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
connectLiveLogs({}, vi.fn(), mockOnError);
// Wait for handlers to be set up
await new Promise(resolve => setTimeout(resolve, 10));
mockWebSocket.simulateError();
expect(mockOnError).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith('WebSocket error:', expect.any(Event));
consoleErrorSpy.mockRestore();
});
it.skip('calls onClose callback when connection closes', async () => {
const mockOnClose = vi.fn();
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
connectLiveLogs({}, vi.fn(), undefined, mockOnClose);
// Wait for handlers to be set up
await new Promise(resolve => setTimeout(resolve, 10));
mockWebSocket.close();
// Wait for the close event to be processed
await new Promise(resolve => setTimeout(resolve, 20));
expect(mockOnClose).toHaveBeenCalled();
consoleLogSpy.mockRestore();
});
it('returns a close function that closes the WebSocket', async () => {
const closeConnection = connectLiveLogs({}, vi.fn());
// Wait for connection to open
await new Promise(resolve => setTimeout(resolve, 10));
expect(mockWebSocket.readyState).toBe(WebSocket.OPEN);
closeConnection();
expect(mockWebSocket.readyState).toBeGreaterThanOrEqual(WebSocket.CLOSING);
});
it('does not throw when closing already closed connection', () => {
const closeConnection = connectLiveLogs({}, vi.fn());
mockWebSocket.readyState = WebSocket.CLOSED;
expect(() => closeConnection()).not.toThrow();
});
it('handles missing optional callbacks', () => {
// Should not throw with only required onMessage callback
expect(() => connectLiveLogs({}, vi.fn())).not.toThrow();
const mockOnMessage = vi.fn();
connectLiveLogs({}, mockOnMessage);
// Simulate various events
mockWebSocket.simulateMessage(JSON.stringify({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'test' }));
mockWebSocket.simulateError();
expect(mockOnMessage).toHaveBeenCalled();
});
it('processes multiple messages in sequence', () => {
const mockOnMessage = vi.fn();
connectLiveLogs({}, mockOnMessage);
const log1 = { level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Message 1' };
const log2 = { level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Message 2' };
mockWebSocket.simulateMessage(JSON.stringify(log1));
mockWebSocket.simulateMessage(JSON.stringify(log2));
expect(mockOnMessage).toHaveBeenCalledTimes(2);
expect(mockOnMessage).toHaveBeenNthCalledWith(1, log1);
expect(mockOnMessage).toHaveBeenNthCalledWith(2, log2);
});
});

View File

@@ -1,44 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../client'
import { downloadLog, getLogContent, getLogs } from '../logs'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
},
}))
describe('logs api http helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'location', {
value: { href: 'http://localhost' },
writable: true,
})
})
it('fetches log list and content with filters', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: [{ name: 'access.log', size: 10, mod_time: 'now' }] })
const logs = await getLogs()
expect(logs[0].name).toBe('access.log')
expect(client.get).toHaveBeenCalledWith('/logs')
vi.mocked(client.get).mockResolvedValueOnce({ data: { filename: 'access.log', logs: [], total: 0, limit: 100, offset: 0 } })
const resp = await getLogContent('access.log', {
search: 'bot',
host: 'example.com',
status: '500',
level: 'error',
limit: 50,
offset: 5,
sort: 'asc',
})
expect(resp.filename).toBe('access.log')
expect(client.get).toHaveBeenCalledWith('/logs/access.log?search=bot&host=example.com&status=500&level=error&limit=50&offset=5&sort=asc')
})
it('downloads log via window location', () => {
downloadLog('access.log')
expect(window.location.href).toBe('/api/v1/logs/access.log/download')
})
})

View File

@@ -1,230 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getChallenge,
createChallenge,
verifyChallenge,
pollChallenge,
deleteChallenge,
} from '../manualChallenge'
import client from '../client'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
describe('manualChallenge API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getChallenge', () => {
it('fetches challenge by provider and challenge ID', async () => {
const mockChallenge = {
id: 'challenge-uuid',
status: 'pending',
fqdn: '_acme-challenge.example.com',
value: 'test-value',
ttl: 300,
created_at: '2026-01-11T00:00:00Z',
expires_at: '2026-01-11T00:10:00Z',
dns_propagated: false,
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockChallenge })
const result = await getChallenge(1, 'challenge-uuid')
expect(client.get).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid'
)
expect(result).toEqual(mockChallenge)
})
it('throws error when challenge not found', async () => {
vi.mocked(client.get).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Challenge not found' } },
})
await expect(getChallenge(1, 'invalid-uuid')).rejects.toMatchObject({
response: { status: 404 },
})
})
})
describe('createChallenge', () => {
it('creates a new challenge for the provider', async () => {
const mockChallenge = {
id: 'new-challenge-uuid',
status: 'created',
fqdn: '_acme-challenge.example.com',
value: 'generated-value',
ttl: 300,
created_at: '2026-01-11T00:00:00Z',
expires_at: '2026-01-11T00:10:00Z',
dns_propagated: false,
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockChallenge })
const result = await createChallenge(1, { domain: 'example.com' })
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/manual-challenge', {
domain: 'example.com',
})
expect(result).toEqual(mockChallenge)
})
it('throws error when provider not found', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Provider not found' } },
})
await expect(createChallenge(999, { domain: 'example.com' })).rejects.toMatchObject({
response: { status: 404 },
})
})
it('throws error when challenge already in progress', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 409, data: { code: 'CHALLENGE_IN_PROGRESS' } },
})
await expect(createChallenge(1, { domain: 'example.com' })).rejects.toMatchObject({
response: { status: 409 },
})
})
})
describe('verifyChallenge', () => {
it('triggers verification for a challenge', async () => {
const mockResult = {
success: true,
dns_found: true,
message: 'TXT record verified successfully',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResult })
const result = await verifyChallenge(1, 'challenge-uuid')
expect(client.post).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid/verify'
)
expect(result).toEqual(mockResult)
})
it('returns dns_found false when record not propagated', async () => {
const mockResult = {
success: false,
dns_found: false,
message: 'DNS record not found',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResult })
const result = await verifyChallenge(1, 'challenge-uuid')
expect(result.success).toBe(false)
expect(result.dns_found).toBe(false)
})
it('throws error when challenge expired', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 410, data: { code: 'CHALLENGE_EXPIRED' } },
})
await expect(verifyChallenge(1, 'challenge-uuid')).rejects.toMatchObject({
response: { status: 410 },
})
})
})
describe('pollChallenge', () => {
it('returns current challenge status', async () => {
const mockPoll = {
status: 'pending',
dns_propagated: false,
time_remaining_seconds: 480,
last_check_at: '2026-01-11T00:02:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(client.get).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid/poll'
)
expect(result).toEqual(mockPoll)
})
it('returns verified status when DNS propagated', async () => {
const mockPoll = {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: '2026-01-11T00:05:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(result.status).toBe('verified')
expect(result.dns_propagated).toBe(true)
})
it('includes error message when challenge failed', async () => {
const mockPoll = {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: '2026-01-11T00:05:00Z',
error_message: 'ACME validation failed',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(result.status).toBe('failed')
expect(result.error_message).toBe('ACME validation failed')
})
})
describe('deleteChallenge', () => {
it('deletes/cancels a challenge', async () => {
vi.mocked(client.delete).mockResolvedValueOnce({ data: undefined })
await deleteChallenge(1, 'challenge-uuid')
expect(client.delete).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid'
)
})
it('throws error when challenge not found', async () => {
vi.mocked(client.delete).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Challenge not found' } },
})
await expect(deleteChallenge(1, 'invalid-uuid')).rejects.toMatchObject({
response: { status: 404 },
})
})
it('throws error when unauthorized', async () => {
vi.mocked(client.delete).mockRejectedValueOnce({
response: { status: 403, data: { error: 'Unauthorized' } },
})
await expect(deleteChallenge(1, 'challenge-uuid')).rejects.toMatchObject({
response: { status: 403 },
})
})
})
})

View File

@@ -1,102 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../client'
import {
getProviders,
createProvider,
updateProvider,
deleteProvider,
testProvider,
getTemplates,
previewProvider,
getExternalTemplates,
createExternalTemplate,
updateExternalTemplate,
deleteExternalTemplate,
previewExternalTemplate,
getSecurityNotificationSettings,
updateSecurityNotificationSettings,
} from '../notifications'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
describe('notifications api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('crud for providers uses correct endpoints', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [{ id: '1', name: 'webhook', type: 'webhook', url: 'http://', enabled: true } as never] })
vi.mocked(client.post).mockResolvedValue({ data: { id: '2' } })
vi.mocked(client.put).mockResolvedValue({ data: { id: '2', name: 'updated' } })
const providers = await getProviders()
expect(providers[0].id).toBe('1')
expect(client.get).toHaveBeenCalledWith('/notifications/providers')
await createProvider({ name: 'x' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers', { name: 'x' })
await updateProvider('2', { name: 'updated' })
expect(client.put).toHaveBeenCalledWith('/notifications/providers/2', { name: 'updated' })
await deleteProvider('2')
expect(client.delete).toHaveBeenCalledWith('/notifications/providers/2')
await testProvider({ id: '2', name: 'test' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers/test', { id: '2', name: 'test' })
})
it('templates and previews use merged payloads', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: [{ id: 't1', name: 'default' }] })
const templates = await getTemplates()
expect(templates[0].name).toBe('default')
expect(client.get).toHaveBeenCalledWith('/notifications/templates')
vi.mocked(client.post).mockResolvedValueOnce({ data: { preview: 'ok' } })
const preview = await previewProvider({ name: 'provider' }, { user: 'alice' })
expect(preview).toEqual({ preview: 'ok' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', data: { user: 'alice' } })
})
it('external template endpoints shape payloads', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: [{ id: 'ext', name: 'External' }] })
const external = await getExternalTemplates()
expect(external[0].id).toBe('ext')
expect(client.get).toHaveBeenCalledWith('/notifications/external-templates')
vi.mocked(client.post).mockResolvedValueOnce({ data: { id: 'ext2' } })
await createExternalTemplate({ name: 'n' })
expect(client.post).toHaveBeenCalledWith('/notifications/external-templates', { name: 'n' })
vi.mocked(client.put).mockResolvedValueOnce({ data: { id: 'ext', name: 'updated' } })
await updateExternalTemplate('ext', { name: 'updated' })
expect(client.put).toHaveBeenCalledWith('/notifications/external-templates/ext', { name: 'updated' })
await deleteExternalTemplate('ext')
expect(client.delete).toHaveBeenCalledWith('/notifications/external-templates/ext')
vi.mocked(client.post).mockResolvedValueOnce({ data: { rendered: true } })
const result = await previewExternalTemplate('ext', 'tpl', { id: 1 })
expect(result).toEqual({ rendered: true })
expect(client.post).toHaveBeenCalledWith('/notifications/external-templates/preview', { template_id: 'ext', template: 'tpl', data: { id: 1 } })
})
it('reads and updates security notification settings', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: { enabled: true, min_log_level: 'info', notify_waf_blocks: true } })
const settings = await getSecurityNotificationSettings()
expect(settings.enabled).toBe(true)
expect(client.get).toHaveBeenCalledWith('/notifications/settings/security')
vi.mocked(client.put).mockResolvedValueOnce({ data: { enabled: false } })
const updated = await updateSecurityNotificationSettings({ enabled: false })
expect(updated.enabled).toBe(false)
expect(client.put).toHaveBeenCalledWith('/notifications/settings/security', { enabled: false })
})
})

View File

@@ -1,465 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as presets from '../presets'
import client from '../client'
vi.mock('../client')
describe('presets API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('listCrowdsecPresets', () => {
it('should fetch presets list with cached flags', async () => {
const mockPresets = {
presets: [
{
slug: 'bot-mitigation-essentials',
title: 'Bot Mitigation Essentials',
summary: 'Core HTTP parsers and scenarios',
source: 'hub',
tags: ['bots', 'web'],
requires_hub: true,
available: true,
cached: true,
cache_key: 'hub-bot-abc123',
etag: '"w/12345"',
retrieved_at: '2025-12-15T10:00:00Z',
},
{
slug: 'honeypot-friendly-defaults',
title: 'Honeypot Friendly Defaults',
summary: 'Lightweight defaults for honeypots',
source: 'builtin',
tags: ['low-noise'],
requires_hub: false,
available: true,
cached: false,
},
],
}
vi.mocked(client.get).mockResolvedValue({ data: mockPresets })
const result = await presets.listCrowdsecPresets()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets')
expect(result).toEqual(mockPresets)
expect(result.presets).toHaveLength(2)
expect(result.presets[0].cached).toBe(true)
expect(result.presets[0].cache_key).toBe('hub-bot-abc123')
expect(result.presets[1].cached).toBe(false)
})
it('should handle empty presets list', async () => {
const mockData = { presets: [] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await presets.listCrowdsecPresets()
expect(result.presets).toHaveLength(0)
})
it('should handle API errors', async () => {
const error = new Error('Network error')
vi.mocked(client.get).mockRejectedValue(error)
await expect(presets.listCrowdsecPresets()).rejects.toThrow('Network error')
})
it('should handle hub API unavailability', async () => {
const error = {
response: {
status: 503,
data: { error: 'CrowdSec Hub API unavailable' },
},
}
vi.mocked(client.get).mockRejectedValue(error)
await expect(presets.listCrowdsecPresets()).rejects.toEqual(error)
})
})
describe('getCrowdsecPresets', () => {
it('should be an alias for listCrowdsecPresets', async () => {
const mockData = { presets: [] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await presets.getCrowdsecPresets()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets')
expect(result).toEqual(mockData)
})
})
describe('pullCrowdsecPreset', () => {
it('should pull preset and return preview with cache_key', async () => {
const mockResponse = {
status: 'success',
slug: 'bot-mitigation-essentials',
preview: '# Bot Mitigation Config\nconfigs:\n collections:\n - crowdsecurity/base-http-scenarios',
cache_key: 'hub-bot-xyz789',
etag: '"abc123"',
retrieved_at: '2025-12-15T10:00:00Z',
source: 'hub',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.pullCrowdsecPreset('bot-mitigation-essentials')
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/pull', {
slug: 'bot-mitigation-essentials',
})
expect(result).toEqual(mockResponse)
expect(result.status).toBe('success')
expect(result.cache_key).toBeDefined()
expect(result.preview).toContain('configs:')
})
it('should handle invalid preset slug', async () => {
const mockResponse = {
status: 'error',
slug: 'non-existent-preset',
preview: '',
cache_key: '',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.pullCrowdsecPreset('non-existent-preset')
expect(result.status).toBe('error')
})
it('should handle hub API timeout during pull', async () => {
const error = {
response: {
status: 504,
data: { error: 'Gateway timeout while fetching from CrowdSec Hub' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.pullCrowdsecPreset('bot-mitigation-essentials')).rejects.toEqual(error)
})
it('should handle ETAG validation scenarios', async () => {
const mockResponse = {
status: 'success',
slug: 'bot-mitigation-essentials',
preview: '# Cached content',
cache_key: 'hub-bot-cached123',
etag: '"not-modified"',
retrieved_at: '2025-12-14T09:00:00Z',
source: 'cache',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.pullCrowdsecPreset('bot-mitigation-essentials')
expect(result.source).toBe('cache')
expect(result.etag).toBe('"not-modified"')
})
it('should handle CrowdSec not running during pull', async () => {
const error = {
response: {
status: 500,
data: { error: 'CrowdSec LAPI not available' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.pullCrowdsecPreset('bot-mitigation-essentials')).rejects.toEqual(error)
})
it('should encode special characters in preset slug', async () => {
const mockResponse = {
status: 'success',
slug: 'custom/preset-with-slash',
preview: '# Custom',
cache_key: 'custom-key',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
await presets.pullCrowdsecPreset('custom/preset-with-slash')
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/pull', {
slug: 'custom/preset-with-slash',
})
})
})
describe('applyCrowdsecPreset', () => {
it('should apply preset with cache_key when available', async () => {
const payload = { slug: 'bot-mitigation-essentials', cache_key: 'hub-bot-xyz789' }
const mockResponse = {
status: 'success',
backup: '/data/charon/data/backups/preset-backup-20251215-100000.tar.gz',
reload_hint: true,
used_cscli: true,
cache_key: 'hub-bot-xyz789',
slug: 'bot-mitigation-essentials',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.applyCrowdsecPreset(payload)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/apply', payload)
expect(result).toEqual(mockResponse)
expect(result.status).toBe('success')
expect(result.backup).toBeDefined()
expect(result.reload_hint).toBe(true)
})
it('should apply preset without cache_key (fallback mode)', async () => {
const payload = { slug: 'honeypot-friendly-defaults' }
const mockResponse = {
status: 'success',
backup: '/data/charon/data/backups/preset-backup-20251215-100100.tar.gz',
reload_hint: true,
used_cscli: true,
slug: 'honeypot-friendly-defaults',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.applyCrowdsecPreset(payload)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/apply', payload)
expect(result.status).toBe('success')
expect(result.used_cscli).toBe(true)
})
it('should handle stale cache_key gracefully', async () => {
const stalePayload = { slug: 'bot-mitigation-essentials', cache_key: 'old_key_123' }
const error = {
response: {
status: 400,
data: { error: 'Cache key mismatch or expired. Please pull the preset again.' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.applyCrowdsecPreset(stalePayload)).rejects.toEqual(error)
})
it('should error when applying preset with CrowdSec stopped', async () => {
const payload = { slug: 'bot-mitigation-essentials', cache_key: 'valid-key' }
const error = {
response: {
status: 500,
data: { error: 'CrowdSec is not running. Start CrowdSec before applying presets.' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.applyCrowdsecPreset(payload)).rejects.toEqual(error)
})
it('should handle backup creation failure', async () => {
const payload = { slug: 'bot-mitigation-essentials', cache_key: 'valid-key' }
const error = {
response: {
status: 500,
data: { error: 'Failed to create backup before applying preset' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.applyCrowdsecPreset(payload)).rejects.toEqual(error)
})
it('should handle cscli errors during application', async () => {
const payload = { slug: 'invalid-preset' }
const error = {
response: {
status: 500,
data: { error: 'cscli hub update failed: exit status 1' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.applyCrowdsecPreset(payload)).rejects.toEqual(error)
})
it('should handle payload with force flag', async () => {
const payload = { slug: 'bot-mitigation-essentials', cache_key: 'key123' }
const mockResponse = {
status: 'success',
backup: '/data/backups/preset-forced.tar.gz',
reload_hint: true,
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.applyCrowdsecPreset(payload)
expect(result.status).toBe('success')
})
})
describe('getCrowdsecPresetCache', () => {
it('should fetch cached preset preview', async () => {
const mockCache = {
preview: '# Cached Bot Mitigation Config\nconfigs:\n collections:\n - crowdsecurity/base-http-scenarios',
cache_key: 'hub-bot-xyz789',
etag: '"abc123"',
}
vi.mocked(client.get).mockResolvedValue({ data: mockCache })
const result = await presets.getCrowdsecPresetCache('bot-mitigation-essentials')
expect(client.get).toHaveBeenCalledWith(
'/admin/crowdsec/presets/cache/bot-mitigation-essentials'
)
expect(result).toEqual(mockCache)
expect(result.preview).toContain('configs:')
expect(result.cache_key).toBe('hub-bot-xyz789')
})
it('should encode special characters in slug', async () => {
const mockCache = {
preview: '# Custom',
cache_key: 'custom-key',
}
vi.mocked(client.get).mockResolvedValue({ data: mockCache })
await presets.getCrowdsecPresetCache('custom/preset with spaces')
expect(client.get).toHaveBeenCalledWith(
'/admin/crowdsec/presets/cache/custom%2Fpreset%20with%20spaces'
)
})
it('should handle cache miss (404)', async () => {
const error = {
response: {
status: 404,
data: { error: 'Preset not found in cache' },
},
}
vi.mocked(client.get).mockRejectedValue(error)
await expect(presets.getCrowdsecPresetCache('non-cached-preset')).rejects.toEqual(error)
})
it('should handle expired cache entries', async () => {
const error = {
response: {
status: 410,
data: { error: 'Cache entry expired' },
},
}
vi.mocked(client.get).mockRejectedValue(error)
await expect(presets.getCrowdsecPresetCache('expired-preset')).rejects.toEqual(error)
})
it('should handle empty preview content', async () => {
const mockCache = {
preview: '',
cache_key: 'empty-key',
}
vi.mocked(client.get).mockResolvedValue({ data: mockCache })
const result = await presets.getCrowdsecPresetCache('empty-preset')
expect(result.preview).toBe('')
expect(result.cache_key).toBe('empty-key')
})
})
describe('default export', () => {
it('should export all functions', () => {
expect(presets.default).toHaveProperty('listCrowdsecPresets')
expect(presets.default).toHaveProperty('getCrowdsecPresets')
expect(presets.default).toHaveProperty('pullCrowdsecPreset')
expect(presets.default).toHaveProperty('applyCrowdsecPreset')
expect(presets.default).toHaveProperty('getCrowdsecPresetCache')
})
})
describe('integration scenarios', () => {
it('should handle full workflow: list → pull → cache → apply', async () => {
// 1. List presets
const mockList = {
presets: [
{
slug: 'bot-mitigation-essentials',
title: 'Bot Mitigation',
summary: 'Core',
source: 'hub',
requires_hub: true,
available: true,
cached: false,
},
],
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockList })
const listResult = await presets.listCrowdsecPresets()
expect(listResult.presets[0].cached).toBe(false)
// 2. Pull preset
const mockPull = {
status: 'success',
slug: 'bot-mitigation-essentials',
preview: '# Config',
cache_key: 'hub-bot-new123',
etag: '"etag1"',
retrieved_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockPull })
const pullResult = await presets.pullCrowdsecPreset('bot-mitigation-essentials')
expect(pullResult.cache_key).toBe('hub-bot-new123')
// 3. Verify cache
const mockCache = {
preview: '# Config',
cache_key: 'hub-bot-new123',
etag: '"etag1"',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockCache })
const cacheResult = await presets.getCrowdsecPresetCache('bot-mitigation-essentials')
expect(cacheResult.cache_key).toBe(pullResult.cache_key)
// 4. Apply preset
const mockApply = {
status: 'success',
backup: '/data/backups/preset-backup.tar.gz',
reload_hint: true,
cache_key: 'hub-bot-new123',
slug: 'bot-mitigation-essentials',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockApply })
const applyResult = await presets.applyCrowdsecPreset({
slug: 'bot-mitigation-essentials',
cache_key: pullResult.cache_key,
})
expect(applyResult.status).toBe('success')
expect(applyResult.backup).toBeDefined()
})
it('should handle network failure mid-workflow', async () => {
// Pull succeeds
const mockPull = {
status: 'success',
slug: 'test-preset',
preview: '# Test',
cache_key: 'test-key',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockPull })
const pullResult = await presets.pullCrowdsecPreset('test-preset')
expect(pullResult.cache_key).toBe('test-key')
// Apply fails due to network
const networkError = new Error('Network error')
vi.mocked(client.post).mockRejectedValueOnce(networkError)
await expect(
presets.applyCrowdsecPreset({ slug: 'test-preset', cache_key: 'test-key' })
).rejects.toThrow('Network error')
})
})
})

View File

@@ -1,95 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { bulkUpdateACL } from '../proxyHosts';
import type { BulkUpdateACLResponse } from '../proxyHosts';
// Mock the client module
const mockPut = vi.fn();
vi.mock('../client', () => ({
default: {
put: (...args: unknown[]) => mockPut(...args),
},
}));
describe('proxyHosts bulk operations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('bulkUpdateACL', () => {
it('should apply ACL to multiple hosts', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 3,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['uuid-1', 'uuid-2', 'uuid-3'];
const accessListID = 42;
const result = await bulkUpdateACL(hostUUIDs, accessListID);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: accessListID,
});
expect(result).toEqual(mockResponse);
});
it('should remove ACL from hosts when accessListID is null', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 2,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['uuid-1', 'uuid-2'];
const result = await bulkUpdateACL(hostUUIDs, null);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: null,
});
expect(result).toEqual(mockResponse);
});
it('should handle partial failures', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 1,
errors: [
{ uuid: 'invalid-uuid', error: 'proxy host not found' },
],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['valid-uuid', 'invalid-uuid'];
const accessListID = 10;
const result = await bulkUpdateACL(hostUUIDs, accessListID);
expect(result.updated).toBe(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].uuid).toBe('invalid-uuid');
});
it('should handle empty host list', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 0,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const result = await bulkUpdateACL([], 5);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: [],
access_list_id: 5,
});
expect(result.updated).toBe(0);
});
it('should propagate API errors', async () => {
const error = new Error('Network error');
mockPut.mockRejectedValue(error);
await expect(bulkUpdateACL(['uuid-1'], 1)).rejects.toThrow('Network error');
});
});
});

View File

@@ -1,91 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import client from '../client';
import {
getProxyHosts,
getProxyHost,
createProxyHost,
updateProxyHost,
deleteProxyHost,
testProxyHostConnection,
ProxyHost
} from '../proxyHosts';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('proxyHosts API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockHost: ProxyHost = {
uuid: '123',
name: 'Example Host',
domain_names: 'example.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: false,
websocket_support: false,
application: 'none',
locations: [],
enabled: true,
created_at: '2023-01-01',
updated_at: '2023-01-01',
};
it('getProxyHosts calls client.get', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockHost] });
const result = await getProxyHosts();
expect(client.get).toHaveBeenCalledWith('/proxy-hosts');
expect(result).toEqual([mockHost]);
});
it('getProxyHost calls client.get with uuid', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockHost });
const result = await getProxyHost('123');
expect(client.get).toHaveBeenCalledWith('/proxy-hosts/123');
expect(result).toEqual(mockHost);
});
it('createProxyHost calls client.post', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockHost });
const newHost = { domain_names: 'example.com' };
const result = await createProxyHost(newHost);
expect(client.post).toHaveBeenCalledWith('/proxy-hosts', newHost);
expect(result).toEqual(mockHost);
});
it('updateProxyHost calls client.put', async () => {
vi.mocked(client.put).mockResolvedValue({ data: mockHost });
const updates = { enabled: false };
const result = await updateProxyHost('123', updates);
expect(client.put).toHaveBeenCalledWith('/proxy-hosts/123', updates);
expect(result).toEqual(mockHost);
});
it('deleteProxyHost calls client.delete', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: {} });
await deleteProxyHost('123');
expect(client.delete).toHaveBeenCalledWith('/proxy-hosts/123');
});
it('testProxyHostConnection calls client.post', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} });
await testProxyHostConnection('localhost', 8080);
expect(client.post).toHaveBeenCalledWith('/proxy-hosts/test', {
forward_host: 'localhost',
forward_port: 8080,
});
});
});

View File

@@ -1,146 +0,0 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
getRemoteServers,
getRemoteServer,
createRemoteServer,
updateRemoteServer,
deleteRemoteServer,
testRemoteServerConnection,
testCustomRemoteServerConnection,
} from '../remoteServers';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('remoteServers API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockServer = {
uuid: 'server-123',
name: 'Test Server',
provider: 'docker',
host: '192.168.1.100',
port: 2375,
username: 'admin',
enabled: true,
reachable: true,
last_check: '2024-01-01T12:00:00Z',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T12:00:00Z',
};
describe('getRemoteServers', () => {
it('fetches all servers', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockServer] });
const result = await getRemoteServers();
expect(client.get).toHaveBeenCalledWith('/remote-servers', { params: {} });
expect(result).toEqual([mockServer]);
});
it('fetches enabled servers only', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockServer] });
const result = await getRemoteServers(true);
expect(client.get).toHaveBeenCalledWith('/remote-servers', { params: { enabled: true } });
expect(result).toEqual([mockServer]);
});
});
describe('getRemoteServer', () => {
it('fetches a single server by UUID', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockServer });
const result = await getRemoteServer('server-123');
expect(client.get).toHaveBeenCalledWith('/remote-servers/server-123');
expect(result).toEqual(mockServer);
});
});
describe('createRemoteServer', () => {
it('creates a new server', async () => {
const newServer = {
name: 'New Server',
provider: 'docker',
host: '10.0.0.1',
port: 2375,
};
vi.mocked(client.post).mockResolvedValue({ data: { ...mockServer, ...newServer } });
const result = await createRemoteServer(newServer);
expect(client.post).toHaveBeenCalledWith('/remote-servers', newServer);
expect(result.name).toBe('New Server');
});
});
describe('updateRemoteServer', () => {
it('updates an existing server', async () => {
const updates = { name: 'Updated Server', enabled: false };
vi.mocked(client.put).mockResolvedValue({ data: { ...mockServer, ...updates } });
const result = await updateRemoteServer('server-123', updates);
expect(client.put).toHaveBeenCalledWith('/remote-servers/server-123', updates);
expect(result.name).toBe('Updated Server');
expect(result.enabled).toBe(false);
});
});
describe('deleteRemoteServer', () => {
it('deletes a server', async () => {
vi.mocked(client.delete).mockResolvedValue({});
await deleteRemoteServer('server-123');
expect(client.delete).toHaveBeenCalledWith('/remote-servers/server-123');
});
});
describe('testRemoteServerConnection', () => {
it('tests connection to an existing server', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { address: '192.168.1.100:2375' } });
const result = await testRemoteServerConnection('server-123');
expect(client.post).toHaveBeenCalledWith('/remote-servers/server-123/test');
expect(result.address).toBe('192.168.1.100:2375');
});
});
describe('testCustomRemoteServerConnection', () => {
it('tests connection to a custom host and port', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { address: '10.0.0.1:2375', reachable: true },
});
const result = await testCustomRemoteServerConnection('10.0.0.1', 2375);
expect(client.post).toHaveBeenCalledWith('/remote-servers/test', { host: '10.0.0.1', port: 2375 });
expect(result.reachable).toBe(true);
});
it('handles unreachable server', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { address: '10.0.0.1:2375', reachable: false, error: 'Connection refused' },
});
const result = await testCustomRemoteServerConnection('10.0.0.1', 2375);
expect(result.reachable).toBe(false);
expect(result.error).toBe('Connection refused');
});
});
});

View File

@@ -1,244 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as security from '../security'
import client from '../client'
vi.mock('../client')
describe('security API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getSecurityStatus', () => {
it('should call GET /security/status', async () => {
const mockData: security.SecurityStatus = {
cerberus: { enabled: true },
crowdsec: { mode: 'local', api_url: 'http://localhost:8080', enabled: true },
waf: { mode: 'enabled', enabled: true },
rate_limit: { mode: 'enabled', enabled: true },
acl: { enabled: true }
}
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getSecurityStatus()
expect(client.get).toHaveBeenCalledWith('/security/status')
expect(result).toEqual(mockData)
})
})
describe('getSecurityConfig', () => {
it('should call GET /security/config', async () => {
const mockData = { config: { admin_whitelist: '10.0.0.0/8' } }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getSecurityConfig()
expect(client.get).toHaveBeenCalledWith('/security/config')
expect(result).toEqual(mockData)
})
})
describe('updateSecurityConfig', () => {
it('should call POST /security/config with payload', async () => {
const payload: security.SecurityConfigPayload = {
name: 'test',
enabled: true,
admin_whitelist: '10.0.0.0/8'
}
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.updateSecurityConfig(payload)
expect(client.post).toHaveBeenCalledWith('/security/config', payload)
expect(result).toEqual(mockData)
})
it('should handle all payload fields', async () => {
const payload: security.SecurityConfigPayload = {
name: 'test',
enabled: true,
admin_whitelist: '10.0.0.0/8',
crowdsec_mode: 'local',
crowdsec_api_url: 'http://localhost:8080',
waf_mode: 'enabled',
waf_rules_source: 'coreruleset',
waf_learning: true,
rate_limit_enable: true,
rate_limit_burst: 10,
rate_limit_requests: 100,
rate_limit_window_sec: 60
}
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.updateSecurityConfig(payload)
expect(client.post).toHaveBeenCalledWith('/security/config', payload)
expect(result).toEqual(mockData)
})
})
describe('generateBreakGlassToken', () => {
it('should call POST /security/breakglass/generate', async () => {
const mockData = { token: 'abc123' }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.generateBreakGlassToken()
expect(client.post).toHaveBeenCalledWith('/security/breakglass/generate')
expect(result).toEqual(mockData)
})
})
describe('enableCerberus', () => {
it('should call POST /security/enable with payload', async () => {
const payload = { mode: 'full' }
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.enableCerberus(payload)
expect(client.post).toHaveBeenCalledWith('/security/enable', payload)
expect(result).toEqual(mockData)
})
it('should call POST /security/enable with empty object when no payload', async () => {
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.enableCerberus()
expect(client.post).toHaveBeenCalledWith('/security/enable', {})
expect(result).toEqual(mockData)
})
})
describe('disableCerberus', () => {
it('should call POST /security/disable with payload', async () => {
const payload = { reason: 'maintenance' }
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.disableCerberus(payload)
expect(client.post).toHaveBeenCalledWith('/security/disable', payload)
expect(result).toEqual(mockData)
})
it('should call POST /security/disable with empty object when no payload', async () => {
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.disableCerberus()
expect(client.post).toHaveBeenCalledWith('/security/disable', {})
expect(result).toEqual(mockData)
})
})
describe('getDecisions', () => {
it('should call GET /security/decisions with default limit', async () => {
const mockData = { decisions: [] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getDecisions()
expect(client.get).toHaveBeenCalledWith('/security/decisions?limit=50')
expect(result).toEqual(mockData)
})
it('should call GET /security/decisions with custom limit', async () => {
const mockData = { decisions: [] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getDecisions(100)
expect(client.get).toHaveBeenCalledWith('/security/decisions?limit=100')
expect(result).toEqual(mockData)
})
})
describe('createDecision', () => {
it('should call POST /security/decisions with payload', async () => {
const payload = { value: '1.2.3.4', duration: '4h', type: 'ban' }
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.createDecision(payload)
expect(client.post).toHaveBeenCalledWith('/security/decisions', payload)
expect(result).toEqual(mockData)
})
})
describe('getRuleSets', () => {
it('should call GET /security/rulesets', async () => {
const mockData: security.RuleSetsResponse = {
rulesets: [
{
id: 1,
uuid: 'abc-123',
name: 'OWASP CRS',
source_url: 'https://example.com/rules',
mode: 'blocking',
last_updated: '2025-12-04T00:00:00Z',
content: 'rule content'
}
]
}
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getRuleSets()
expect(client.get).toHaveBeenCalledWith('/security/rulesets')
expect(result).toEqual(mockData)
})
})
describe('upsertRuleSet', () => {
it('should call POST /security/rulesets with create payload', async () => {
const payload: security.UpsertRuleSetPayload = {
name: 'Custom Rules',
content: 'rule content',
mode: 'blocking'
}
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.upsertRuleSet(payload)
expect(client.post).toHaveBeenCalledWith('/security/rulesets', payload)
expect(result).toEqual(mockData)
})
it('should call POST /security/rulesets with update payload', async () => {
const payload: security.UpsertRuleSetPayload = {
id: 1,
name: 'Updated Rules',
source_url: 'https://example.com/rules',
mode: 'detection'
}
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.upsertRuleSet(payload)
expect(client.post).toHaveBeenCalledWith('/security/rulesets', payload)
expect(result).toEqual(mockData)
})
})
describe('deleteRuleSet', () => {
it('should call DELETE /security/rulesets/:id', async () => {
const mockData = { success: true }
vi.mocked(client.delete).mockResolvedValue({ data: mockData })
const result = await security.deleteRuleSet(1)
expect(client.delete).toHaveBeenCalledWith('/security/rulesets/1')
expect(result).toEqual(mockData)
})
})
})

View File

@@ -1,181 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as settings from '../settings'
import client from '../client'
vi.mock('../client')
describe('settings API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getSettings', () => {
it('should call GET /settings', async () => {
const mockData: settings.SettingsMap = {
'ui.theme': 'dark',
'security.cerberus.enabled': 'true'
}
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await settings.getSettings()
expect(client.get).toHaveBeenCalledWith('/settings')
expect(result).toEqual(mockData)
})
})
describe('updateSetting', () => {
it('should call POST /settings with key and value only', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} })
await settings.updateSetting('ui.theme', 'light')
expect(client.post).toHaveBeenCalledWith('/settings', {
key: 'ui.theme',
value: 'light',
category: undefined,
type: undefined
})
})
it('should call POST /settings with all parameters', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} })
await settings.updateSetting('security.cerberus.enabled', 'true', 'security', 'bool')
expect(client.post).toHaveBeenCalledWith('/settings', {
key: 'security.cerberus.enabled',
value: 'true',
category: 'security',
type: 'bool'
})
})
it('should call POST /settings with category but no type', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} })
await settings.updateSetting('ui.theme', 'dark', 'ui')
expect(client.post).toHaveBeenCalledWith('/settings', {
key: 'ui.theme',
value: 'dark',
category: 'ui',
type: undefined
})
})
})
describe('validatePublicURL', () => {
it('should call POST /settings/validate-url with URL', async () => {
const mockResponse = { valid: true, normalized: 'https://example.com' }
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await settings.validatePublicURL('https://example.com')
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', { url: 'https://example.com' })
expect(result).toEqual(mockResponse)
})
it('should return valid: true for valid URL', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { valid: true } })
const result = await settings.validatePublicURL('https://valid.com')
expect(result.valid).toBe(true)
})
it('should return valid: false for invalid URL', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { valid: false, error: 'Invalid URL format' } })
const result = await settings.validatePublicURL('not-a-url')
expect(result.valid).toBe(false)
expect(result.error).toBe('Invalid URL format')
})
it('should return normalized URL when provided', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { valid: true, normalized: 'https://example.com/' }
})
const result = await settings.validatePublicURL('https://example.com')
expect(result.normalized).toBe('https://example.com/')
})
it('should handle validation errors', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
await expect(settings.validatePublicURL('https://example.com')).rejects.toThrow('Network error')
})
it('should handle empty URL parameter', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { valid: false } })
const result = await settings.validatePublicURL('')
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', { url: '' })
expect(result.valid).toBe(false)
})
})
describe('testPublicURL', () => {
it('should call POST /settings/test-url with URL', async () => {
const mockResponse = { reachable: true, latency: 42 }
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await settings.testPublicURL('https://example.com')
expect(client.post).toHaveBeenCalledWith('/settings/test-url', { url: 'https://example.com' })
expect(result).toEqual(mockResponse)
})
it('should return reachable: true with latency for successful test', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { reachable: true, latency: 123, message: 'URL is reachable' }
})
const result = await settings.testPublicURL('https://example.com')
expect(result.reachable).toBe(true)
expect(result.latency).toBe(123)
expect(result.message).toBe('URL is reachable')
})
it('should return reachable: false with error for failed test', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { reachable: false, error: 'Connection timeout' }
})
const result = await settings.testPublicURL('https://unreachable.com')
expect(result.reachable).toBe(false)
expect(result.error).toBe('Connection timeout')
})
it('should return message field when provided', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { reachable: true, latency: 50, message: 'Custom success message' }
})
const result = await settings.testPublicURL('https://example.com')
expect(result.message).toBe('Custom success message')
})
it('should handle request errors', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('Request failed'))
await expect(settings.testPublicURL('https://example.com')).rejects.toThrow('Request failed')
})
it('should handle empty URL parameter', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { reachable: false } })
const result = await settings.testPublicURL('')
expect(client.post).toHaveBeenCalledWith('/settings/test-url', { url: '' })
expect(result.reachable).toBe(false)
})
})
})

View File

@@ -1,23 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../../api/client'
import { getSetupStatus, performSetup } from '../setup'
describe('setup api', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('getSetupStatus returns status', async () => {
const data = { setupRequired: true }
vi.spyOn(client, 'get').mockResolvedValueOnce({ data })
const res = await getSetupStatus()
expect(res).toEqual(data)
})
it('performSetup posts data to setup endpoint', async () => {
const spy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: {} })
const payload = { name: 'Admin', email: 'admin@example.com', password: 'secret' }
await performSetup(payload)
expect(spy).toHaveBeenCalledWith('/setup', payload)
})
})

View File

@@ -1,62 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import client from '../client'
import { checkUpdates, getNotifications, markNotificationRead, markAllNotificationsRead } from '../system'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}))
describe('System API', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('checkUpdates calls /system/updates', async () => {
const mockData = { available: true, latest_version: '1.0.0', changelog_url: 'url' }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await checkUpdates()
expect(client.get).toHaveBeenCalledWith('/system/updates')
expect(result).toEqual(mockData)
})
it('getNotifications calls /notifications', async () => {
const mockData = [{ id: '1', title: 'Test' }]
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await getNotifications()
expect(client.get).toHaveBeenCalledWith('/notifications', { params: { unread: false } })
expect(result).toEqual(mockData)
})
it('getNotifications calls /notifications with unreadOnly=true', async () => {
const mockData = [{ id: '1', title: 'Test' }]
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await getNotifications(true)
expect(client.get).toHaveBeenCalledWith('/notifications', { params: { unread: true } })
expect(result).toEqual(mockData)
})
it('markNotificationRead calls /notifications/:id/read', async () => {
vi.mocked(client.post).mockResolvedValue({})
await markNotificationRead('123')
expect(client.post).toHaveBeenCalledWith('/notifications/123/read')
})
it('markAllNotificationsRead calls /notifications/read-all', async () => {
vi.mocked(client.post).mockResolvedValue({})
await markAllNotificationsRead()
expect(client.post).toHaveBeenCalledWith('/notifications/read-all')
})
})

View File

@@ -1,135 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as uptime from '../uptime'
import client from '../client'
import type { UptimeMonitor, UptimeHeartbeat } from '../uptime'
vi.mock('../client')
describe('uptime API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getMonitors', () => {
it('should call GET /uptime/monitors', async () => {
const mockData: UptimeMonitor[] = [
{
id: 'mon-1',
name: 'Test Monitor',
type: 'http',
url: 'https://example.com',
interval: 60,
enabled: true,
status: 'up',
latency: 100,
max_retries: 3
}
]
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await uptime.getMonitors()
expect(client.get).toHaveBeenCalledWith('/uptime/monitors')
expect(result).toEqual(mockData)
})
})
describe('getMonitorHistory', () => {
it('should call GET /uptime/monitors/:id/history with default limit', async () => {
const mockData: UptimeHeartbeat[] = [
{
id: 1,
monitor_id: 'mon-1',
status: 'up',
latency: 100,
message: 'OK',
created_at: '2025-12-04T00:00:00Z'
}
]
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await uptime.getMonitorHistory('mon-1')
expect(client.get).toHaveBeenCalledWith('/uptime/monitors/mon-1/history?limit=50')
expect(result).toEqual(mockData)
})
it('should call GET /uptime/monitors/:id/history with custom limit', async () => {
const mockData: UptimeHeartbeat[] = []
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await uptime.getMonitorHistory('mon-1', 100)
expect(client.get).toHaveBeenCalledWith('/uptime/monitors/mon-1/history?limit=100')
expect(result).toEqual(mockData)
})
})
describe('updateMonitor', () => {
it('should call PUT /uptime/monitors/:id', async () => {
const mockMonitor: UptimeMonitor = {
id: 'mon-1',
name: 'Updated Monitor',
type: 'http',
url: 'https://example.com',
interval: 120,
enabled: false,
status: 'down',
latency: 0,
max_retries: 5
}
vi.mocked(client.put).mockResolvedValue({ data: mockMonitor })
const result = await uptime.updateMonitor('mon-1', { enabled: false, interval: 120 })
expect(client.put).toHaveBeenCalledWith('/uptime/monitors/mon-1', { enabled: false, interval: 120 })
expect(result).toEqual(mockMonitor)
})
})
describe('deleteMonitor', () => {
it('should call DELETE /uptime/monitors/:id', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: undefined })
const result = await uptime.deleteMonitor('mon-1')
expect(client.delete).toHaveBeenCalledWith('/uptime/monitors/mon-1')
expect(result).toBeUndefined()
})
})
describe('syncMonitors', () => {
it('should call POST /uptime/sync with empty body when no params', async () => {
const mockData = { synced: 5 }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await uptime.syncMonitors()
expect(client.post).toHaveBeenCalledWith('/uptime/sync', {})
expect(result).toEqual(mockData)
})
it('should call POST /uptime/sync with provided parameters', async () => {
const mockData = { synced: 5 }
const body = { interval: 120, max_retries: 5 }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await uptime.syncMonitors(body)
expect(client.post).toHaveBeenCalledWith('/uptime/sync', body)
expect(result).toEqual(mockData)
})
})
describe('checkMonitor', () => {
it('should call POST /uptime/monitors/:id/check', async () => {
const mockData = { message: 'Check initiated' }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await uptime.checkMonitor('mon-1')
expect(client.post).toHaveBeenCalledWith('/uptime/monitors/mon-1/check')
expect(result).toEqual(mockData)
})
})
})

View File

@@ -1,189 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../client'
import {
listUsers,
getUser,
createUser,
inviteUser,
updateUser,
deleteUser,
updateUserPermissions,
validateInvite,
acceptInvite,
} from '../users'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
describe('users api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('lists, reads, creates, updates, and deletes users', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: [{ id: 1, email: 'a' }] })
const users = await listUsers()
expect(users[0].id).toBe(1)
expect(client.get).toHaveBeenCalledWith('/users')
vi.mocked(client.get).mockResolvedValueOnce({ data: { id: 2 } })
await getUser(2)
expect(client.get).toHaveBeenCalledWith('/users/2')
vi.mocked(client.post).mockResolvedValueOnce({ data: { id: 3 } })
await createUser({ email: 'e', name: 'n', password: 'p' })
expect(client.post).toHaveBeenCalledWith('/users', { email: 'e', name: 'n', password: 'p' })
vi.mocked(client.put).mockResolvedValueOnce({ data: { message: 'ok' } })
await updateUser(2, { enabled: false })
expect(client.put).toHaveBeenCalledWith('/users/2', { enabled: false })
vi.mocked(client.delete).mockResolvedValueOnce({ data: { message: 'deleted' } })
await deleteUser(2)
expect(client.delete).toHaveBeenCalledWith('/users/2')
})
it('invites users and updates permissions', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ data: { invite_token: 't' } })
await inviteUser({ email: 'i', permission_mode: 'allow_all' })
expect(client.post).toHaveBeenCalledWith('/users/invite', { email: 'i', permission_mode: 'allow_all' })
vi.mocked(client.put).mockResolvedValueOnce({ data: { message: 'saved' } })
await updateUserPermissions(1, { permission_mode: 'deny_all', permitted_hosts: [1, 2] })
expect(client.put).toHaveBeenCalledWith('/users/1/permissions', { permission_mode: 'deny_all', permitted_hosts: [1, 2] })
})
it('validates and accepts invites with params', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: { valid: true, email: 'a' } })
await validateInvite('token-1')
expect(client.get).toHaveBeenCalledWith('/invite/validate', { params: { token: 'token-1' } })
vi.mocked(client.post).mockResolvedValueOnce({ data: { message: 'accepted', email: 'a' } })
await acceptInvite({ token: 't', name: 'n', password: 'p' })
expect(client.post).toHaveBeenCalledWith('/invite/accept', { token: 't', name: 'n', password: 'p' })
})
describe('previewInviteURL', () => {
it('should call POST /users/preview-invite-url with email', async () => {
const mockResponse = {
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://example.com',
is_configured: true,
email: 'test@example.com',
warning: false,
warning_message: ''
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await import('../users').then(m => m.previewInviteURL('test@example.com'))
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
expect(result).toEqual(mockResponse)
})
it('should return complete PreviewInviteURLResponse structure', async () => {
const mockResponse = {
preview_url: 'https://charon.example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://charon.example.com',
is_configured: true,
email: 'user@test.com',
warning: false,
warning_message: ''
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await import('../users').then(m => m.previewInviteURL('user@test.com'))
expect(result.preview_url).toBeDefined()
expect(result.base_url).toBeDefined()
expect(result.is_configured).toBeDefined()
expect(result.email).toBeDefined()
expect(result.warning).toBeDefined()
expect(result.warning_message).toBeDefined()
})
it('should return preview_url with sample token', async () => {
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'http://localhost:8080/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'http://localhost:8080',
is_configured: false,
email: 'test@example.com',
warning: true,
warning_message: 'Public URL not configured'
}
})
const result = await import('../users').then(m => m.previewInviteURL('test@example.com'))
expect(result.preview_url).toContain('SAMPLE_TOKEN_PREVIEW')
})
it('should return is_configured flag', async () => {
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://example.com',
is_configured: true,
email: 'test@example.com',
warning: false,
warning_message: ''
}
})
const result = await import('../users').then(m => m.previewInviteURL('test@example.com'))
expect(result.is_configured).toBe(true)
})
it('should return warning flag when public URL not configured', async () => {
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'http://localhost:8080/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'http://localhost:8080',
is_configured: false,
email: 'admin@test.com',
warning: true,
warning_message: 'Using default localhost URL'
}
})
const result = await import('../users').then(m => m.previewInviteURL('admin@test.com'))
expect(result.warning).toBe(true)
expect(result.warning_message).toBe('Using default localhost URL')
})
it('should return the provided email in response', async () => {
const testEmail = 'specific@email.com'
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://example.com',
is_configured: true,
email: testEmail,
warning: false,
warning_message: ''
}
})
const result = await import('../users').then(m => m.previewInviteURL(testEmail))
expect(result.email).toBe(testEmail)
})
it('should handle request errors', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
await expect(
import('../users').then(m => m.previewInviteURL('test@example.com'))
).rejects.toThrow('Network error')
})
})
})

View File

@@ -1,112 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getWebSocketConnections, getWebSocketStats } from '../websocket';
import client from '../client';
vi.mock('../client');
describe('WebSocket API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getWebSocketConnections', () => {
it('should fetch WebSocket connections', async () => {
const mockResponse = {
connections: [
{
id: 'test-conn-1',
type: 'logs',
connected_at: '2024-01-15T10:00:00Z',
last_activity_at: '2024-01-15T10:05:00Z',
remote_addr: '192.168.1.1:12345',
user_agent: 'Mozilla/5.0',
filters: 'level=error',
},
{
id: 'test-conn-2',
type: 'cerberus',
connected_at: '2024-01-15T10:02:00Z',
last_activity_at: '2024-01-15T10:06:00Z',
remote_addr: '192.168.1.2:54321',
user_agent: 'Chrome/90.0',
filters: 'source=waf',
},
],
count: 2,
};
vi.mocked(client.get).mockResolvedValue({ data: mockResponse });
const result = await getWebSocketConnections();
expect(client.get).toHaveBeenCalledWith('/websocket/connections');
expect(result).toEqual(mockResponse);
expect(result.count).toBe(2);
expect(result.connections).toHaveLength(2);
});
it('should handle empty connections', async () => {
const mockResponse = {
connections: [],
count: 0,
};
vi.mocked(client.get).mockResolvedValue({ data: mockResponse });
const result = await getWebSocketConnections();
expect(result.connections).toHaveLength(0);
expect(result.count).toBe(0);
});
it('should handle API errors', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Network error'));
await expect(getWebSocketConnections()).rejects.toThrow('Network error');
});
});
describe('getWebSocketStats', () => {
it('should fetch WebSocket statistics', async () => {
const mockResponse = {
total_active: 3,
logs_connections: 2,
cerberus_connections: 1,
oldest_connection: '2024-01-15T09:55:00Z',
last_updated: '2024-01-15T10:10:00Z',
};
vi.mocked(client.get).mockResolvedValue({ data: mockResponse });
const result = await getWebSocketStats();
expect(client.get).toHaveBeenCalledWith('/websocket/stats');
expect(result).toEqual(mockResponse);
expect(result.total_active).toBe(3);
expect(result.logs_connections).toBe(2);
expect(result.cerberus_connections).toBe(1);
});
it('should handle stats with no connections', async () => {
const mockResponse = {
total_active: 0,
logs_connections: 0,
cerberus_connections: 0,
last_updated: '2024-01-15T10:10:00Z',
};
vi.mocked(client.get).mockResolvedValue({ data: mockResponse });
const result = await getWebSocketStats();
expect(result.total_active).toBe(0);
expect(result.oldest_connection).toBeUndefined();
});
it('should handle API errors', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Server error'));
await expect(getWebSocketStats()).rejects.toThrow('Server error');
});
});
});

View File

@@ -1,126 +0,0 @@
import client from './client';
export interface AccessListRule {
cidr: string;
description: string;
}
export interface AccessList {
id: number;
uuid: string;
name: string;
description: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules: string; // JSON string of AccessListRule[]
country_codes: string; // Comma-separated
local_network_only: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CreateAccessListRequest {
name: string;
description?: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules?: string;
country_codes?: string;
local_network_only?: boolean;
enabled?: boolean;
}
export interface TestIPRequest {
ip_address: string;
}
export interface TestIPResponse {
allowed: boolean;
reason: string;
}
export interface AccessListTemplate {
name: string;
description: string;
type: string;
local_network_only?: boolean;
country_codes?: string;
}
export const accessListsApi = {
/**
* Fetches all access lists.
* @returns Promise resolving to array of AccessList objects
* @throws {AxiosError} If the request fails
*/
async list(): Promise<AccessList[]> {
const response = await client.get<AccessList[]>('/access-lists');
return response.data;
},
/**
* Gets a single access list by ID.
* @param id - The access list ID
* @returns Promise resolving to the AccessList object
* @throws {AxiosError} If the request fails or access list not found
*/
async get(id: number): Promise<AccessList> {
const response = await client.get<AccessList>(`/access-lists/${id}`);
return response.data;
},
/**
* Creates a new access list.
* @param data - CreateAccessListRequest with access list configuration
* @returns Promise resolving to the created AccessList
* @throws {AxiosError} If creation fails or validation errors occur
*/
async create(data: CreateAccessListRequest): Promise<AccessList> {
const response = await client.post<AccessList>('/access-lists', data);
return response.data;
},
/**
* Updates an existing access list.
* @param id - The access list ID to update
* @param data - Partial CreateAccessListRequest with fields to update
* @returns Promise resolving to the updated AccessList
* @throws {AxiosError} If update fails or access list not found
*/
async update(id: number, data: Partial<CreateAccessListRequest>): Promise<AccessList> {
const response = await client.put<AccessList>(`/access-lists/${id}`, data);
return response.data;
},
/**
* Deletes an access list.
* @param id - The access list ID to delete
* @throws {AxiosError} If deletion fails or access list not found
*/
async delete(id: number): Promise<void> {
await client.delete(`/access-lists/${id}`);
},
/**
* Tests if an IP address would be allowed or blocked by an access list.
* @param id - The access list ID to test against
* @param ipAddress - The IP address to test
* @returns Promise resolving to TestIPResponse with allowed status and reason
* @throws {AxiosError} If test fails or access list not found
*/
async testIP(id: number, ipAddress: string): Promise<TestIPResponse> {
const response = await client.post<TestIPResponse>(`/access-lists/${id}/test`, {
ip_address: ipAddress,
});
return response.data;
},
/**
* Gets predefined access list templates.
* @returns Promise resolving to array of AccessListTemplate objects
* @throws {AxiosError} If the request fails
*/
async getTemplates(): Promise<AccessListTemplate[]> {
const response = await client.get<AccessListTemplate[]>('/access-lists/templates');
return response.data;
},
};

View File

@@ -1,267 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from './client'
import {
getAuditLogs,
getAuditLog,
getAuditLogsByProvider,
exportAuditLogsCSV,
type AuditLog,
type AuditLogFilters,
} from './auditLogs'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
}
describe('auditLogs api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getAuditLogs', () => {
it('fetches audit logs with default pagination', async () => {
const mockResponse = {
logs: [
{
id: 1,
uuid: 'log-1',
actor: 'admin',
action: 'user_login',
event_category: 'user',
details: 'User logged in',
ip_address: '192.168.1.1',
created_at: '2024-01-01T00:00:00Z',
},
],
total: 1,
page: 1,
limit: 50,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
const result = await getAuditLogs()
expect(mockedClient.get).toHaveBeenCalledWith('/audit-logs?page=1&limit=50')
expect(result).toEqual(mockResponse)
expect(result.logs).toHaveLength(1)
expect(result.logs[0].uuid).toBe('log-1')
})
it('fetches audit logs with custom pagination', async () => {
const mockResponse = {
logs: [],
total: 100,
page: 3,
limit: 25,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
const result = await getAuditLogs(undefined, 3, 25)
expect(mockedClient.get).toHaveBeenCalledWith('/audit-logs?page=3&limit=25')
expect(result.page).toBe(3)
expect(result.limit).toBe(25)
})
it('fetches audit logs with all filters', async () => {
const filters: AuditLogFilters = {
event_category: 'dns_provider',
actor: 'admin',
action: 'dns_provider_create',
start_date: '2024-01-01',
end_date: '2024-12-31',
resource_uuid: 'resource-123',
}
const mockResponse = {
logs: [],
total: 0,
page: 1,
limit: 50,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
await getAuditLogs(filters)
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs?page=1&limit=50&event_category=dns_provider&actor=admin&action=dns_provider_create&start_date=2024-01-01&end_date=2024-12-31&resource_uuid=resource-123'
)
})
it('fetches audit logs with partial filters', async () => {
const filters: AuditLogFilters = {
event_category: 'certificate',
start_date: '2024-01-01',
}
const mockResponse = {
logs: [],
total: 5,
page: 1,
limit: 50,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
await getAuditLogs(filters, 1, 50)
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs?page=1&limit=50&event_category=certificate&start_date=2024-01-01'
)
})
it('handles errors when fetching audit logs', async () => {
const error = new Error('Network error')
mockedClient.get.mockRejectedValueOnce(error)
await expect(getAuditLogs()).rejects.toThrow('Network error')
})
})
describe('getAuditLog', () => {
it('fetches a single audit log by UUID', async () => {
const mockLog: AuditLog = {
id: 42,
uuid: 'log-uuid-123',
actor: 'admin',
action: 'certificate_issue',
event_category: 'certificate',
resource_id: 10,
resource_uuid: 'cert-uuid',
details: 'Certificate issued successfully',
ip_address: '10.0.0.1',
user_agent: 'Mozilla/5.0',
created_at: '2024-06-15T12:30:00Z',
}
mockedClient.get.mockResolvedValueOnce({ data: mockLog })
const result = await getAuditLog('log-uuid-123')
expect(mockedClient.get).toHaveBeenCalledWith('/audit-logs/log-uuid-123')
expect(result).toEqual(mockLog)
expect(result.uuid).toBe('log-uuid-123')
expect(result.action).toBe('certificate_issue')
})
it('handles 404 when audit log not found', async () => {
const error = new Error('Not found')
mockedClient.get.mockRejectedValueOnce(error)
await expect(getAuditLog('nonexistent')).rejects.toThrow('Not found')
})
})
describe('getAuditLogsByProvider', () => {
it('fetches audit logs for a specific DNS provider with default pagination', async () => {
const mockResponse = {
logs: [
{
id: 5,
uuid: 'log-5',
actor: 'system',
action: 'dns_provider_update',
event_category: 'dns_provider',
resource_id: 123,
details: 'DNS provider updated',
created_at: '2024-03-15T10:00:00Z',
},
],
total: 10,
page: 1,
limit: 50,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
const result = await getAuditLogsByProvider(123)
expect(mockedClient.get).toHaveBeenCalledWith('/dns-providers/123/audit-logs?page=1&limit=50')
expect(result.logs).toHaveLength(1)
expect(result.logs[0].action).toBe('dns_provider_update')
})
it('fetches audit logs for a provider with custom pagination', async () => {
const mockResponse = {
logs: [],
total: 25,
page: 2,
limit: 10,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
const result = await getAuditLogsByProvider(456, 2, 10)
expect(mockedClient.get).toHaveBeenCalledWith('/dns-providers/456/audit-logs?page=2&limit=10')
expect(result.page).toBe(2)
expect(result.limit).toBe(10)
})
it('handles errors when fetching provider audit logs', async () => {
const error = new Error('Provider not found')
mockedClient.get.mockRejectedValueOnce(error)
await expect(getAuditLogsByProvider(999)).rejects.toThrow('Provider not found')
})
})
describe('exportAuditLogsCSV', () => {
it('exports audit logs to CSV without filters', async () => {
const mockCSV = 'id,actor,action,created_at\n1,admin,user_login,2024-01-01'
mockedClient.get.mockResolvedValueOnce({ data: mockCSV })
const result = await exportAuditLogsCSV()
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs/export?',
{ headers: { Accept: 'text/csv' } }
)
expect(result).toBe(mockCSV)
})
it('exports audit logs to CSV with all filters', async () => {
const filters: AuditLogFilters = {
event_category: 'proxy_host',
actor: 'operator',
action: 'proxy_host_delete',
start_date: '2024-01-01',
end_date: '2024-06-30',
resource_uuid: 'host-uuid-456',
}
const mockCSV = 'id,actor,action,created_at\n'
mockedClient.get.mockResolvedValueOnce({ data: mockCSV })
const result = await exportAuditLogsCSV(filters)
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs/export?event_category=proxy_host&actor=operator&action=proxy_host_delete&start_date=2024-01-01&end_date=2024-06-30&resource_uuid=host-uuid-456',
{ headers: { Accept: 'text/csv' } }
)
expect(result).toBe(mockCSV)
})
it('exports audit logs with partial filters', async () => {
const filters: AuditLogFilters = {
action: 'settings_update',
end_date: '2024-12-31',
}
const mockCSV = 'header,data\n'
mockedClient.get.mockResolvedValueOnce({ data: mockCSV })
await exportAuditLogsCSV(filters)
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs/export?action=settings_update&end_date=2024-12-31',
{ headers: { Accept: 'text/csv' } }
)
})
it('handles errors when exporting audit logs', async () => {
const error = new Error('Export failed')
mockedClient.get.mockRejectedValueOnce(error)
await expect(exportAuditLogsCSV()).rejects.toThrow('Export failed')
})
})
})

View File

@@ -1,144 +0,0 @@
import client from './client'
/** Audit log event category */
export type EventCategory = 'dns_provider' | 'certificate' | 'proxy_host' | 'user' | 'system'
/** Audit log action type */
export type AuditAction =
| 'dns_provider_create'
| 'dns_provider_update'
| 'dns_provider_delete'
| 'credential_test'
| 'credential_decrypt'
| 'certificate_issue'
| 'certificate_renew'
| 'proxy_host_create'
| 'proxy_host_update'
| 'proxy_host_delete'
| 'user_login'
| 'user_logout'
| 'settings_update'
/** Represents a single audit log entry */
export interface AuditLog {
id: number
uuid: string
actor: string
action: AuditAction
event_category: EventCategory
resource_id?: number
resource_uuid?: string
details: string
ip_address?: string
user_agent?: string
created_at: string
}
/** Filters for querying audit logs */
export interface AuditLogFilters {
event_category?: EventCategory
actor?: string
action?: AuditAction
start_date?: string
end_date?: string
resource_uuid?: string
}
/** Response for list endpoint */
interface ListAuditLogsResponse {
logs: AuditLog[]
total: number
page: number
limit: number
}
/**
* Fetches audit logs with pagination and filtering.
* @param filters - Optional filters to apply
* @param page - Page number (1-indexed)
* @param limit - Number of records per page
* @returns Promise resolving to paginated audit logs
* @throws {AxiosError} If the request fails
*/
export async function getAuditLogs(
filters?: AuditLogFilters,
page: number = 1,
limit: number = 50
): Promise<ListAuditLogsResponse> {
const params = new URLSearchParams()
params.append('page', page.toString())
params.append('limit', limit.toString())
if (filters) {
if (filters.event_category) params.append('event_category', filters.event_category)
if (filters.actor) params.append('actor', filters.actor)
if (filters.action) params.append('action', filters.action)
if (filters.start_date) params.append('start_date', filters.start_date)
if (filters.end_date) params.append('end_date', filters.end_date)
if (filters.resource_uuid) params.append('resource_uuid', filters.resource_uuid)
}
const response = await client.get<ListAuditLogsResponse>(`/audit-logs?${params.toString()}`)
return response.data
}
/**
* Fetches a single audit log by UUID.
* @param uuid - The audit log UUID
* @returns Promise resolving to the audit log
* @throws {AxiosError} If not found or request fails
*/
export async function getAuditLog(uuid: string): Promise<AuditLog> {
const response = await client.get<AuditLog>(`/audit-logs/${uuid}`)
return response.data
}
/**
* Fetches audit logs for a specific DNS provider.
* @param providerId - The DNS provider ID
* @param page - Page number (1-indexed)
* @param limit - Number of records per page
* @returns Promise resolving to paginated audit logs
* @throws {AxiosError} If not found or request fails
*/
export async function getAuditLogsByProvider(
providerId: number,
page: number = 1,
limit: number = 50
): Promise<ListAuditLogsResponse> {
const params = new URLSearchParams()
params.append('page', page.toString())
params.append('limit', limit.toString())
const response = await client.get<ListAuditLogsResponse>(
`/dns-providers/${providerId}/audit-logs?${params.toString()}`
)
return response.data
}
/**
* Exports audit logs to CSV format.
* @param filters - Optional filters to apply
* @returns Promise resolving to CSV string
* @throws {AxiosError} If the request fails
*/
export async function exportAuditLogsCSV(filters?: AuditLogFilters): Promise<string> {
const params = new URLSearchParams()
if (filters) {
if (filters.event_category) params.append('event_category', filters.event_category)
if (filters.actor) params.append('actor', filters.actor)
if (filters.action) params.append('action', filters.action)
if (filters.start_date) params.append('start_date', filters.start_date)
if (filters.end_date) params.append('end_date', filters.end_date)
if (filters.resource_uuid) params.append('resource_uuid', filters.resource_uuid)
}
const response = await client.get<string>(
`/audit-logs/export?${params.toString()}`,
{
headers: { Accept: 'text/csv' },
}
)
return response.data
}

View File

@@ -1,46 +0,0 @@
import client from './client';
/** Represents a backup file stored on the server. */
export interface BackupFile {
filename: string;
size: number;
time: string;
}
/**
* Fetches all available backup files.
* @returns Promise resolving to array of BackupFile objects
* @throws {AxiosError} If the request fails
*/
export const getBackups = async (): Promise<BackupFile[]> => {
const response = await client.get<BackupFile[]>('/backups');
return response.data;
};
/**
* Creates a new backup of the current configuration.
* @returns Promise resolving to object containing the new backup filename
* @throws {AxiosError} If backup creation fails
*/
export const createBackup = async (): Promise<{ filename: string }> => {
const response = await client.post<{ filename: string }>('/backups');
return response.data;
};
/**
* Restores configuration from a backup file.
* @param filename - The name of the backup file to restore
* @throws {AxiosError} If restoration fails or file not found
*/
export const restoreBackup = async (filename: string): Promise<void> => {
await client.post(`/backups/${filename}/restore`);
};
/**
* Deletes a backup file.
* @param filename - The name of the backup file to delete
* @throws {AxiosError} If deletion fails or file not found
*/
export const deleteBackup = async (filename: string): Promise<void> => {
await client.delete(`/backups/${filename}`);
};

View File

@@ -1,53 +0,0 @@
import client from './client'
/** Represents an SSL/TLS certificate. */
export interface Certificate {
id?: number
name?: string
domain: string
issuer: string
expires_at: string
status: 'valid' | 'expiring' | 'expired' | 'untrusted'
provider: string
}
/**
* Fetches all SSL certificates.
* @returns Promise resolving to array of Certificate objects
* @throws {AxiosError} If the request fails
*/
export async function getCertificates(): Promise<Certificate[]> {
const response = await client.get<Certificate[]>('/certificates')
return response.data
}
/**
* Uploads a new SSL certificate with its private key.
* @param name - Display name for the certificate
* @param certFile - The certificate file (PEM format)
* @param keyFile - The private key file (PEM format)
* @returns Promise resolving to the created Certificate
* @throws {AxiosError} If upload fails or certificate is invalid
*/
export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise<Certificate> {
const formData = new FormData()
formData.append('name', name)
formData.append('certificate_file', certFile)
formData.append('key_file', keyFile)
const response = await client.post<Certificate>('/certificates', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
/**
* Deletes an SSL certificate.
* @param id - The ID of the certificate to delete
* @throws {AxiosError} If deletion fails or certificate not found
*/
export async function deleteCertificate(id: number): Promise<void> {
await client.delete(`/certificates/${id}`)
}

View File

@@ -1,56 +0,0 @@
import axios from 'axios';
/**
* Pre-configured Axios instance for API communication.
* Includes base URL, credentials, and timeout settings.
*/
const client = axios.create({
baseURL: '/api/v1',
withCredentials: true, // Required for HttpOnly cookie transmission
timeout: 30000, // 30 second timeout
});
/**
* Sets or clears the Authorization header for API requests.
* @param token - JWT token to set, or null to clear authentication
*/
export const setAuthToken = (token: string | null) => {
if (token) {
client.defaults.headers.common.Authorization = `Bearer ${token}`;
} else {
delete client.defaults.headers.common.Authorization;
}
};
/**
* Callback function invoked when a 401 authentication error occurs.
* Set via setAuthErrorHandler to allow AuthContext to handle session expiry.
*/
let onAuthError: (() => void) | null = null;
/**
* Registers a callback to handle authentication errors (401 responses).
* @param handler - Function to call when authentication fails
*/
export const setAuthErrorHandler = (handler: () => void) => {
onAuthError = handler;
};
// Global 401 error handling - triggers auth error callback for session expiry
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.warn('Authentication failed:', error.config?.url);
// Skip auth error handling for login/auth endpoints to avoid redirect loops
const url = error.config?.url || '';
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/me');
if (onAuthError && !isAuthEndpoint) {
onAuthError();
}
}
return Promise.reject(error);
}
);
export default client;

View File

@@ -1,57 +0,0 @@
import client from './client'
/** CrowdSec Console enrollment status. */
export interface ConsoleEnrollmentStatus {
status: string
tenant?: string
agent_name?: string
last_error?: string
last_attempt_at?: string
enrolled_at?: string
last_heartbeat_at?: string
key_present: boolean
correlation_id?: string
}
/** Payload for enrolling with CrowdSec Console. */
export interface ConsoleEnrollPayload {
enrollment_key: string
tenant?: string
agent_name: string
force?: boolean
}
/**
* Gets the current CrowdSec Console enrollment status.
* @returns Promise resolving to ConsoleEnrollmentStatus
* @throws {AxiosError} If status check fails
*/
export async function getConsoleStatus(): Promise<ConsoleEnrollmentStatus> {
const resp = await client.get<ConsoleEnrollmentStatus>('/admin/crowdsec/console/status')
return resp.data
}
/**
* Enrolls the instance with CrowdSec Console.
* @param payload - Enrollment configuration including key and agent name
* @returns Promise resolving to the new enrollment status
* @throws {AxiosError} If enrollment fails
*/
export async function enrollConsole(payload: ConsoleEnrollPayload): Promise<ConsoleEnrollmentStatus> {
const resp = await client.post<ConsoleEnrollmentStatus>('/admin/crowdsec/console/enroll', payload)
return resp.data
}
/**
* Clears the current CrowdSec Console enrollment.
* @throws {AxiosError} If clearing enrollment fails
*/
export async function clearConsoleEnrollment(): Promise<void> {
await client.delete('/admin/crowdsec/console/enrollment')
}
export default {
getConsoleStatus,
enrollConsole,
clearConsoleEnrollment,
}

View File

@@ -1,148 +0,0 @@
import client from './client'
/** Represents a zone-specific credential set */
export interface DNSProviderCredential {
id: number
uuid: string
dns_provider_id: number
label: string
zone_filter: string
enabled: boolean
propagation_timeout: number
polling_interval: number
key_version: number
last_used_at?: string
success_count: number
failure_count: number
last_error?: string
created_at: string
updated_at: string
}
/** Request payload for creating/updating credentials */
export interface CredentialRequest {
label: string
zone_filter: string
credentials: Record<string, string>
propagation_timeout?: number
polling_interval?: number
enabled?: boolean
}
/** Credential test result */
export interface CredentialTestResult {
success: boolean
message?: string
error?: string
propagation_time_ms?: number
}
/** Response for list endpoint */
interface ListCredentialsResponse {
credentials: DNSProviderCredential[]
total: number
}
/**
* Fetches all credentials for a DNS provider.
* @param providerId - The DNS provider ID
* @returns Promise resolving to array of credentials
* @throws {AxiosError} If the request fails
*/
export async function getCredentials(providerId: number): Promise<DNSProviderCredential[]> {
const response = await client.get<ListCredentialsResponse>(
`/dns-providers/${providerId}/credentials`
)
return response.data.credentials
}
/**
* Fetches a single credential by ID.
* @param providerId - The DNS provider ID
* @param credentialId - The credential ID
* @returns Promise resolving to the credential
* @throws {AxiosError} If not found or request fails
*/
export async function getCredential(
providerId: number,
credentialId: number
): Promise<DNSProviderCredential> {
const response = await client.get<DNSProviderCredential>(
`/dns-providers/${providerId}/credentials/${credentialId}`
)
return response.data
}
/**
* Creates a new credential for a DNS provider.
* @param providerId - The DNS provider ID
* @param data - Credential configuration
* @returns Promise resolving to the created credential
* @throws {AxiosError} If validation fails or request fails
*/
export async function createCredential(
providerId: number,
data: CredentialRequest
): Promise<DNSProviderCredential> {
const response = await client.post<DNSProviderCredential>(
`/dns-providers/${providerId}/credentials`,
data
)
return response.data
}
/**
* Updates an existing credential.
* @param providerId - The DNS provider ID
* @param credentialId - The credential ID
* @param data - Updated configuration
* @returns Promise resolving to the updated credential
* @throws {AxiosError} If not found, validation fails, or request fails
*/
export async function updateCredential(
providerId: number,
credentialId: number,
data: CredentialRequest
): Promise<DNSProviderCredential> {
const response = await client.put<DNSProviderCredential>(
`/dns-providers/${providerId}/credentials/${credentialId}`,
data
)
return response.data
}
/**
* Deletes a credential.
* @param providerId - The DNS provider ID
* @param credentialId - The credential ID
* @throws {AxiosError} If not found or in use
*/
export async function deleteCredential(providerId: number, credentialId: number): Promise<void> {
await client.delete(`/dns-providers/${providerId}/credentials/${credentialId}`)
}
/**
* Tests a credential's connectivity.
* @param providerId - The DNS provider ID
* @param credentialId - The credential ID
* @returns Promise resolving to test result
* @throws {AxiosError} If not found or request fails
*/
export async function testCredential(
providerId: number,
credentialId: number
): Promise<CredentialTestResult> {
const response = await client.post<CredentialTestResult>(
`/dns-providers/${providerId}/credentials/${credentialId}/test`
)
return response.data
}
/**
* Enables multi-credential mode for a DNS provider.
* @param providerId - The DNS provider ID
* @throws {AxiosError} If provider not found or already enabled
*/
export async function enableMultiCredentials(providerId: number): Promise<void> {
await client.post(`/dns-providers/${providerId}/enable-multi-credentials`)
}

View File

@@ -1,138 +0,0 @@
import client from './client'
/** Represents a CrowdSec decision (ban/captcha). */
export interface CrowdSecDecision {
id: string
ip: string
reason: string
duration: string
created_at: string
source: string
}
/**
* Starts the CrowdSec security service.
* @returns Promise resolving to status with process ID and LAPI readiness
* @throws {AxiosError} If the service fails to start
*/
export async function startCrowdsec(): Promise<{ status: string; pid: number; lapi_ready?: boolean }> {
const resp = await client.post('/admin/crowdsec/start')
return resp.data
}
/**
* Stops the CrowdSec security service.
* @returns Promise resolving to stop status
* @throws {AxiosError} If the service fails to stop
*/
export async function stopCrowdsec() {
const resp = await client.post('/admin/crowdsec/stop')
return resp.data
}
/** CrowdSec service status information. */
export interface CrowdSecStatus {
running: boolean
pid: number
lapi_ready: boolean
}
/**
* Gets the current status of the CrowdSec service.
* @returns Promise resolving to CrowdSecStatus
* @throws {AxiosError} If status check fails
*/
export async function statusCrowdsec(): Promise<CrowdSecStatus> {
const resp = await client.get<CrowdSecStatus>('/admin/crowdsec/status')
return resp.data
}
/**
* Imports a CrowdSec configuration file.
* @param file - The configuration file to import
* @returns Promise resolving to import result
* @throws {AxiosError} If import fails or file is invalid
*/
export async function importCrowdsecConfig(file: File) {
const fd = new FormData()
fd.append('file', file)
const resp = await client.post('/admin/crowdsec/import', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return resp.data
}
/**
* Exports the current CrowdSec configuration.
* @returns Promise resolving to configuration blob for download
* @throws {AxiosError} If export fails
*/
export async function exportCrowdsecConfig() {
const resp = await client.get('/admin/crowdsec/export', { responseType: 'blob' })
return resp.data
}
/**
* Lists all CrowdSec configuration files.
* @returns Promise resolving to object containing file list
* @throws {AxiosError} If listing fails
*/
export async function listCrowdsecFiles() {
const resp = await client.get<{ files: string[] }>('/admin/crowdsec/files')
return resp.data
}
/**
* Reads the content of a CrowdSec configuration file.
* @param path - The file path to read
* @returns Promise resolving to object containing file content
* @throws {AxiosError} If file cannot be read
*/
export async function readCrowdsecFile(path: string) {
const resp = await client.get<{ content: string }>(`/admin/crowdsec/file?path=${encodeURIComponent(path)}`)
return resp.data
}
/**
* Writes content to a CrowdSec configuration file.
* @param path - The file path to write
* @param content - The content to write
* @returns Promise resolving to write result
* @throws {AxiosError} If file cannot be written
*/
export async function writeCrowdsecFile(path: string, content: string) {
const resp = await client.post('/admin/crowdsec/file', { path, content })
return resp.data
}
/**
* Lists all active CrowdSec decisions (bans).
* @returns Promise resolving to object containing decisions array
* @throws {AxiosError} If listing fails
*/
export async function listCrowdsecDecisions(): Promise<{ decisions: CrowdSecDecision[] }> {
const resp = await client.get<{ decisions: CrowdSecDecision[] }>('/admin/crowdsec/decisions')
return resp.data
}
/**
* Bans an IP address via CrowdSec.
* @param ip - The IP address to ban
* @param duration - Ban duration (e.g., "24h", "7d")
* @param reason - Reason for the ban
* @throws {AxiosError} If ban fails
*/
export async function banIP(ip: string, duration: string, reason: string): Promise<void> {
await client.post('/admin/crowdsec/ban', { ip, duration, reason })
}
/**
* Removes a ban for an IP address.
* @param ip - The IP address to unban
* @throws {AxiosError} If unban fails
*/
export async function unbanIP(ip: string): Promise<void> {
await client.delete(`/admin/crowdsec/ban/${encodeURIComponent(ip)}`)
}
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP }

View File

@@ -1,40 +0,0 @@
import client from './client'
import type { DNSProvider } from './dnsProviders'
/** DNS provider detection result */
export interface DetectionResult {
domain: string
detected: boolean
provider_type?: string
nameservers: string[]
confidence: 'high' | 'medium' | 'low' | 'none'
suggested_provider?: DNSProvider
error?: string
}
/** Nameserver pattern used for detection */
export interface NameserverPattern {
pattern: string
provider_type: string
}
/**
* Detects DNS provider for a domain by analyzing nameservers.
* @param domain - Domain name to detect provider for
* @returns Promise resolving to detection result
* @throws {AxiosError} If the request fails
*/
export async function detectDNSProvider(domain: string): Promise<DetectionResult> {
const response = await client.post<DetectionResult>('/dns-providers/detect', { domain })
return response.data
}
/**
* Fetches built-in nameserver patterns used for detection.
* @returns Promise resolving to array of patterns
* @throws {AxiosError} If the request fails
*/
export async function getDetectionPatterns(): Promise<NameserverPattern[]> {
const response = await client.get<{ patterns: NameserverPattern[] }>('/dns-providers/patterns')
return response.data.patterns
}

View File

@@ -1,178 +0,0 @@
import client from './client'
/** Supported DNS provider types */
export type DNSProviderType =
| 'cloudflare'
| 'route53'
| 'digitalocean'
| 'googleclouddns'
| 'namecheap'
| 'godaddy'
| 'azure'
| 'hetzner'
| 'vultr'
| 'dnsimple'
// Custom plugin types
| 'manual'
| 'webhook'
| 'rfc2136'
| 'script'
/** Represents a configured DNS provider */
export interface DNSProvider {
id: number
uuid: string
name: string
provider_type: DNSProviderType
enabled: boolean
is_default: boolean
has_credentials: boolean
propagation_timeout: number
polling_interval: number
last_used_at?: string
success_count: number
failure_count: number
last_error?: string
created_at: string
updated_at: string
}
/** Request payload for creating/updating DNS providers */
export interface DNSProviderRequest {
name: string
provider_type: DNSProviderType
credentials: Record<string, string>
propagation_timeout?: number
polling_interval?: number
is_default?: boolean
}
/** DNS provider test result */
export interface DNSTestResult {
success: boolean
message?: string
error?: string
code?: string
propagation_time_ms?: number
}
/** Field definition for DNS provider credentials */
export interface DNSProviderField {
name: string
label: string
type: 'text' | 'password' | 'textarea' | 'select'
required: boolean
default?: string
hint?: string
placeholder?: string
options?: Array<{
value: string
label: string
}>
}
/** DNS provider type information with field definitions */
export interface DNSProviderTypeInfo {
type: DNSProviderType
name: string
description?: string
documentation_url?: string
is_built_in?: boolean
fields: DNSProviderField[]
}
/** Response for list endpoint */
interface ListDNSProvidersResponse {
providers: DNSProvider[]
total: number
}
/** Response for types endpoint */
interface DNSProviderTypesResponse {
types: DNSProviderTypeInfo[]
}
/**
* Fetches all configured DNS providers.
* @returns Promise resolving to array of DNS providers
* @throws {AxiosError} If the request fails
*/
export async function getDNSProviders(): Promise<DNSProvider[]> {
const response = await client.get<ListDNSProvidersResponse>('/dns-providers')
return response.data.providers
}
/**
* Fetches a single DNS provider by ID.
* @param id - The DNS provider ID
* @returns Promise resolving to the DNS provider
* @throws {AxiosError} If not found or request fails
*/
export async function getDNSProvider(id: number): Promise<DNSProvider> {
const response = await client.get<DNSProvider>(`/dns-providers/${id}`)
return response.data
}
/**
* Creates a new DNS provider.
* @param data - DNS provider configuration
* @returns Promise resolving to the created provider
* @throws {AxiosError} If validation fails or request fails
*/
export async function createDNSProvider(data: DNSProviderRequest): Promise<DNSProvider> {
const response = await client.post<DNSProvider>('/dns-providers', data)
return response.data
}
/**
* Updates an existing DNS provider.
* @param id - The DNS provider ID
* @param data - Updated configuration
* @returns Promise resolving to the updated provider
* @throws {AxiosError} If not found, validation fails, or request fails
*/
export async function updateDNSProvider(id: number, data: DNSProviderRequest): Promise<DNSProvider> {
const response = await client.put<DNSProvider>(`/dns-providers/${id}`, data)
return response.data
}
/**
* Deletes a DNS provider.
* @param id - The DNS provider ID
* @throws {AxiosError} If not found or in use by proxy hosts
*/
export async function deleteDNSProvider(id: number): Promise<void> {
await client.delete(`/dns-providers/${id}`)
}
/**
* Tests connectivity of a saved DNS provider.
* @param id - The DNS provider ID
* @returns Promise resolving to test result
* @throws {AxiosError} If not found or request fails
*/
export async function testDNSProvider(id: number): Promise<DNSTestResult> {
const response = await client.post<DNSTestResult>(`/dns-providers/${id}/test`)
return response.data
}
/**
* Tests DNS provider credentials before saving.
* @param data - Provider configuration to test
* @returns Promise resolving to test result
* @throws {AxiosError} If validation fails or request fails
*/
export async function testDNSProviderCredentials(data: DNSProviderRequest): Promise<DNSTestResult> {
const response = await client.post<DNSTestResult>('/dns-providers/test', data)
return response.data
}
/**
* Fetches supported DNS provider types with field definitions.
* @returns Promise resolving to array of provider type info
* @throws {AxiosError} If request fails
*/
export async function getDNSProviderTypes(): Promise<DNSProviderTypeInfo[]> {
const response = await client.get<DNSProviderTypesResponse>('/dns-providers/types')
return response.data.types
}

View File

@@ -1,39 +0,0 @@
import client from './client'
/** Docker port mapping information. */
export interface DockerPort {
private_port: number
public_port: number
type: string
}
/** Docker container information. */
export interface DockerContainer {
id: string
names: string[]
image: string
state: string
status: string
network: string
ip: string
ports: DockerPort[]
}
/** Docker API client for container operations. */
export const dockerApi = {
/**
* Lists Docker containers from a local or remote host.
* @param host - Optional Docker host address
* @param serverId - Optional remote server ID
* @returns Promise resolving to array of DockerContainer objects
* @throws {AxiosError} If listing fails or host unreachable
*/
listContainers: async (host?: string, serverId?: string): Promise<DockerContainer[]> => {
const params: Record<string, string> = {}
if (host) params.host = host
if (serverId) params.server_id = serverId
const response = await client.get<DockerContainer[]>('/docker/containers', { params })
return response.data
},
}

View File

@@ -1,39 +0,0 @@
import client from './client'
/** Represents a managed domain. */
export interface Domain {
id: number
uuid: string
name: string
created_at: string
}
/**
* Fetches all managed domains.
* @returns Promise resolving to array of Domain objects
* @throws {AxiosError} If the request fails
*/
export const getDomains = async (): Promise<Domain[]> => {
const { data } = await client.get<Domain[]>('/domains')
return data
}
/**
* Creates a new managed domain.
* @param name - The domain name to create
* @returns Promise resolving to the created Domain
* @throws {AxiosError} If creation fails or domain is invalid
*/
export const createDomain = async (name: string): Promise<Domain> => {
const { data } = await client.post<Domain>('/domains', { name })
return data
}
/**
* Deletes a managed domain.
* @param uuid - The unique identifier of the domain to delete
* @throws {AxiosError} If deletion fails or domain not found
*/
export const deleteDomain = async (uuid: string): Promise<void> => {
await client.delete(`/domains/${uuid}`)
}

View File

@@ -1,85 +0,0 @@
import client from './client'
/** Rotation status for key management */
export interface RotationStatus {
current_version: number
next_key_configured: boolean
legacy_key_count: number
providers_on_current_version: number
providers_on_older_versions: number
}
/** Result of a key rotation operation */
export interface RotationResult {
total_providers: number
success_count: number
failure_count: number
failed_providers?: number[]
duration: string
new_key_version: number
}
/** Audit log entry for key rotation history */
export interface RotationHistoryEntry {
id: number
uuid: string
actor: string
action: string
event_category: string
details: string
created_at: string
}
/** Response for history endpoint */
interface RotationHistoryResponse {
history: RotationHistoryEntry[]
total: number
}
/** Validation result for key configuration */
export interface KeyValidationResult {
valid: boolean
message?: string
errors?: string[]
warnings?: string[]
}
/**
* Fetches current encryption key status and rotation information.
* @returns Promise resolving to rotation status
* @throws {AxiosError} If the request fails
*/
export async function getEncryptionStatus(): Promise<RotationStatus> {
const response = await client.get<RotationStatus>('/admin/encryption/status')
return response.data
}
/**
* Triggers rotation of all DNS provider credentials to a new encryption key.
* @returns Promise resolving to rotation result
* @throws {AxiosError} If rotation fails or request fails
*/
export async function rotateEncryptionKey(): Promise<RotationResult> {
const response = await client.post<RotationResult>('/admin/encryption/rotate')
return response.data
}
/**
* Fetches key rotation audit history.
* @returns Promise resolving to array of rotation history entries
* @throws {AxiosError} If the request fails
*/
export async function getRotationHistory(): Promise<RotationHistoryEntry[]> {
const response = await client.get<RotationHistoryResponse>('/admin/encryption/history')
return response.data.history
}
/**
* Validates the current key configuration.
* @returns Promise resolving to validation result
* @throws {AxiosError} If the request fails
*/
export async function validateKeyConfiguration(): Promise<KeyValidationResult> {
const response = await client.post<KeyValidationResult>('/admin/encryption/validate')
return response.data
}

View File

@@ -1,26 +0,0 @@
import { vi, describe, it, expect } from 'vitest'
// Mock the client module which is an axios instance wrapper
vi.mock('./client', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: { 'feature.cerberus.enabled': true } })),
put: vi.fn(() => Promise.resolve({ data: { status: 'ok' } })),
},
}))
import { getFeatureFlags, updateFeatureFlags } from './featureFlags'
import client from './client'
describe('featureFlags API', () => {
it('fetches feature flags', async () => {
const flags = await getFeatureFlags()
expect(flags['feature.cerberus.enabled']).toBe(true)
expect(vi.mocked(client.get)).toHaveBeenCalled()
})
it('updates feature flags', async () => {
const resp = await updateFeatureFlags({ 'feature.cerberus.enabled': false })
expect(resp).toEqual({ status: 'ok' })
expect(vi.mocked(client.put)).toHaveBeenCalledWith('/feature-flags', { 'feature.cerberus.enabled': false })
})
})

View File

@@ -1,27 +0,0 @@
import client from './client'
/**
* Fetches all feature flags and their current states.
* @returns Promise resolving to a record of flag names to boolean values
* @throws {AxiosError} If the request fails
*/
export async function getFeatureFlags(): Promise<Record<string, boolean>> {
const resp = await client.get<Record<string, boolean>>('/feature-flags')
return resp.data
}
/**
* Updates one or more feature flags.
* @param payload - Record of flag names to new boolean values
* @returns Promise resolving to the update result
* @throws {AxiosError} If the update fails
*/
export async function updateFeatureFlags(payload: Record<string, boolean>) {
const resp = await client.put('/feature-flags', payload)
return resp.data
}
export default {
getFeatureFlags,
updateFeatureFlags,
}

View File

@@ -1,20 +0,0 @@
import client from './client';
/** Health check response with version and build information. */
export interface HealthResponse {
status: string;
service: string;
version: string;
git_commit: string;
build_time: string;
}
/**
* Checks the health status of the API server.
* @returns Promise resolving to HealthResponse with version info
* @throws {AxiosError} If the health check fails
*/
export const checkHealth = async (): Promise<HealthResponse> => {
const { data } = await client.get<HealthResponse>('/health');
return data;
};

View File

@@ -1,127 +0,0 @@
import client from './client';
/** Represents an active import session. */
export interface ImportSession {
id: string;
state: 'pending' | 'reviewing' | 'completed' | 'failed' | 'transient';
created_at: string;
updated_at: string;
source_file?: string;
}
/** Preview of a Caddyfile import with hosts and conflicts. */
export interface ImportPreview {
session: ImportSession;
preview: {
hosts: Array<{ domain_names: string; [key: string]: unknown }>;
conflicts: string[];
errors: string[];
};
caddyfile_content?: string;
conflict_details?: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
/**
* Uploads a Caddyfile content for import preview.
* @param content - The Caddyfile content as a string
* @returns Promise resolving to ImportPreview with parsed hosts
* @throws {AxiosError} If parsing fails or content is invalid
*/
export const uploadCaddyfile = async (content: string): Promise<ImportPreview> => {
const { data } = await client.post<ImportPreview>('/import/upload', { content });
return data;
};
/**
* Uploads multiple Caddyfile contents for batch import.
* @param contents - Array of Caddyfile content strings
* @returns Promise resolving to combined ImportPreview
* @throws {AxiosError} If parsing fails
*/
export const uploadCaddyfilesMulti = async (contents: string[]): Promise<ImportPreview> => {
const { data } = await client.post<ImportPreview>('/import/upload-multi', { contents });
return data;
};
/**
* Gets the current import preview for the active session.
* @returns Promise resolving to ImportPreview
* @throws {AxiosError} If no active session or request fails
*/
export const getImportPreview = async (): Promise<ImportPreview> => {
const { data } = await client.get<ImportPreview>('/import/preview');
return data;
};
/** Result of committing an import operation. */
export interface ImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* Commits the import, creating/updating proxy hosts.
* @param sessionUUID - The import session UUID
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
* @param names - Map of custom names for imported hosts
* @returns Promise resolving to ImportCommitResult with counts
* @throws {AxiosError} If commit fails
*/
export const commitImport = async (
sessionUUID: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<ImportCommitResult> => {
const { data } = await client.post<ImportCommitResult>('/import/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
return data;
};
/**
* Cancels the current import session.
* @throws {AxiosError} If cancellation fails
*/
export const cancelImport = async (): Promise<void> => {
await client.post('/import/cancel');
};
/**
* Gets the current import session status.
* @returns Promise resolving to object with pending status and optional session
*/
export const getImportStatus = async (): Promise<{ has_pending: boolean; session?: ImportSession }> => {
// Note: Assuming there might be a status endpoint or we infer from preview.
// If no dedicated status endpoint exists in backend, we might rely on preview returning 404 or empty.
// Based on previous context, there wasn't an explicit status endpoint mentioned in the simple API,
// but the hook used `importAPI.status()`. I'll check the backend routes if needed.
// For now, I'll implement it assuming /import/preview can serve as status check or there is a /import/status.
// Let's check the backend routes to be sure.
try {
const { data } = await client.get<{ has_pending: boolean; session?: ImportSession }>('/import/status');
return data;
} catch {
// Fallback if status endpoint doesn't exist, though the hook used it.
return { has_pending: false };
}
};

View File

@@ -1,90 +0,0 @@
import client from './client';
/** Represents a host parsed from a JSON export. */
export interface JSONHost {
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket_support: boolean;
}
/** Preview of a JSON import with hosts and conflicts. */
export interface JSONImportPreview {
session: {
id: string;
state: string;
source: string;
};
preview: {
hosts: JSONHost[];
conflicts: string[];
errors: string[];
};
conflict_details: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
/** Result of committing a JSON import operation. */
export interface JSONImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* Uploads JSON export content for import preview.
* @param content - The JSON export content as a string
* @returns Promise resolving to JSONImportPreview with parsed hosts
* @throws {AxiosError} If parsing fails or content is invalid
*/
export const uploadJSONExport = async (content: string): Promise<JSONImportPreview> => {
const { data } = await client.post<JSONImportPreview>('/import/json/upload', { content });
return data;
};
/**
* Commits the JSON import, creating/updating proxy hosts.
* @param sessionUuid - The import session UUID
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
* @param names - Map of custom names for imported hosts
* @returns Promise resolving to JSONImportCommitResult with counts
* @throws {AxiosError} If commit fails
*/
export const commitJSONImport = async (
sessionUuid: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<JSONImportCommitResult> => {
const { data } = await client.post<JSONImportCommitResult>('/import/json/commit', {
session_uuid: sessionUuid,
resolutions,
names,
});
return data;
};
/**
* Cancels the current JSON import session.
* @throws {AxiosError} If cancellation fails
*/
export const cancelJSONImport = async (): Promise<void> => {
await client.post('/import/json/cancel');
};

View File

@@ -1,339 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import client from './client'
import { getLogs, getLogContent, downloadLog, connectLiveLogs, connectSecurityLogs } from './logs'
import type { LiveLogEntry, SecurityLogEntry } from './logs'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
}
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSED = 3
static instances: MockWebSocket[] = []
url: string
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onerror: ((event: Event) => void) | null = null
onclose: ((event: CloseEvent) => void) | null = null
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
open() {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
sendMessage(data: string) {
this.onmessage?.({ data })
}
triggerError(event: Event) {
this.onerror?.(event)
}
close() {
this.readyState = MockWebSocket.CLOSED
this.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent)
}
}
const originalWebSocket = globalThis.WebSocket
const originalLocation = { ...window.location }
beforeEach(() => {
vi.clearAllMocks()
;(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = MockWebSocket as unknown as typeof WebSocket
Object.defineProperty(window, 'location', {
value: { ...originalLocation, protocol: 'http:', host: 'localhost', href: '' },
writable: true,
})
})
afterEach(() => {
;(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = originalWebSocket
Object.defineProperty(window, 'location', { value: originalLocation })
MockWebSocket.instances.length = 0
})
describe('logs api', () => {
it('lists log files', async () => {
mockedClient.get.mockResolvedValue({ data: [{ name: 'access.log', size: 10, mod_time: 'now' }] })
const logs = await getLogs()
expect(mockedClient.get).toHaveBeenCalledWith('/logs')
expect(logs[0].name).toBe('access.log')
})
it('fetches log content with filters applied', async () => {
mockedClient.get.mockResolvedValue({ data: { filename: 'access.log', logs: [], total: 0, limit: 50, offset: 0 } })
await getLogContent('access.log', {
search: 'error',
host: 'example.com',
status: '500',
level: 'error',
limit: 50,
offset: 10,
sort: 'asc',
})
expect(mockedClient.get).toHaveBeenCalledWith(
'/logs/access.log?search=error&host=example.com&status=500&level=error&limit=50&offset=10&sort=asc'
)
})
it('sets window location when downloading logs', () => {
downloadLog('access.log')
expect(window.location.href).toBe('/api/v1/logs/access.log/download')
})
it('connects to live logs websocket and handles lifecycle events', () => {
const received: LiveLogEntry[] = []
const onOpen = vi.fn()
const onError = vi.fn()
const onClose = vi.fn()
const disconnect = connectLiveLogs({ level: 'error', source: 'cerberus' }, (log) => received.push(log), onOpen, onError, onClose)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('level=error')
expect(socket.url).toContain('source=cerberus')
socket.open()
expect(onOpen).toHaveBeenCalled()
socket.sendMessage(JSON.stringify({ level: 'info', timestamp: 'now', message: 'hello' }))
expect(received).toHaveLength(1)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
socket.sendMessage('not-json')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
const errorEvent = new Event('error')
socket.triggerError(errorEvent)
expect(onError).toHaveBeenCalledWith(errorEvent)
socket.close()
expect(onClose).toHaveBeenCalled()
disconnect()
})
})
describe('connectSecurityLogs', () => {
it('connects to cerberus logs websocket endpoint', () => {
const received: SecurityLogEntry[] = []
const onOpen = vi.fn()
connectSecurityLogs({}, (log) => received.push(log), onOpen)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('/api/v1/cerberus/logs/ws')
})
it('passes source filter to websocket url', () => {
connectSecurityLogs({ source: 'waf' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('source=waf')
})
it('passes level filter to websocket url', () => {
connectSecurityLogs({ level: 'error' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('level=error')
})
it('passes ip filter to websocket url', () => {
connectSecurityLogs({ ip: '192.168' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('ip=192.168')
})
it('passes host filter to websocket url', () => {
connectSecurityLogs({ host: 'example.com' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('host=example.com')
})
it('passes blocked_only filter to websocket url', () => {
connectSecurityLogs({ blocked_only: true }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('blocked_only=true')
})
it('receives and parses security log entries', () => {
const received: SecurityLogEntry[] = []
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
const securityLogEntry: SecurityLogEntry = {
timestamp: '2025-12-12T10:30:00Z',
level: 'info',
logger: 'http.log.access',
client_ip: '192.168.1.100',
method: 'GET',
uri: '/api/test',
status: 200,
duration: 0.05,
size: 1024,
user_agent: 'TestAgent/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
}
socket.sendMessage(JSON.stringify(securityLogEntry))
expect(received).toHaveLength(1)
expect(received[0].client_ip).toBe('192.168.1.100')
expect(received[0].source).toBe('normal')
expect(received[0].blocked).toBe(false)
})
it('receives blocked security log entries', () => {
const received: SecurityLogEntry[] = []
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
const blockedEntry: SecurityLogEntry = {
timestamp: '2025-12-12T10:30:00Z',
level: 'warn',
logger: 'http.handlers.waf',
client_ip: '10.0.0.1',
method: 'POST',
uri: '/admin',
status: 403,
duration: 0.001,
size: 0,
user_agent: 'Attack/1.0',
host: 'example.com',
source: 'waf',
blocked: true,
block_reason: 'SQL injection detected',
}
socket.sendMessage(JSON.stringify(blockedEntry))
expect(received).toHaveLength(1)
expect(received[0].blocked).toBe(true)
expect(received[0].block_reason).toBe('SQL injection detected')
expect(received[0].source).toBe('waf')
})
it('handles onOpen callback', () => {
const onOpen = vi.fn()
connectSecurityLogs({}, () => {}, onOpen)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
expect(onOpen).toHaveBeenCalled()
})
it('handles onError callback', () => {
const onError = vi.fn()
connectSecurityLogs({}, () => {}, undefined, onError)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
const errorEvent = new Event('error')
socket.triggerError(errorEvent)
expect(onError).toHaveBeenCalledWith(errorEvent)
})
it('handles onClose callback', () => {
const onClose = vi.fn()
connectSecurityLogs({}, () => {}, undefined, undefined, onClose)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.close()
expect(onClose).toHaveBeenCalled()
})
it('returns disconnect function that closes websocket', () => {
const disconnect = connectSecurityLogs({}, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
expect(socket.readyState).toBe(MockWebSocket.OPEN)
disconnect()
expect(socket.readyState).toBe(MockWebSocket.CLOSED)
})
it('handles JSON parse errors gracefully', () => {
const received: SecurityLogEntry[] = []
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
socket.sendMessage('invalid-json')
expect(received).toHaveLength(0)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
it('uses wss protocol when on https', () => {
Object.defineProperty(window, 'location', {
value: { protocol: 'https:', host: 'secure.example.com', href: '' },
writable: true,
})
connectSecurityLogs({}, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('wss://')
expect(socket.url).toContain('secure.example.com')
})
it('combines multiple filters in websocket url', () => {
connectSecurityLogs(
{
source: 'waf',
level: 'warn',
ip: '10.0.0',
host: 'example.com',
blocked_only: true,
},
() => {}
)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('source=waf')
expect(socket.url).toContain('level=warn')
expect(socket.url).toContain('ip=10.0.0')
expect(socket.url).toContain('host=example.com')
expect(socket.url).toContain('blocked_only=true')
})
})

View File

@@ -1,262 +0,0 @@
import client from './client';
/** Represents a log file on the server. */
export interface LogFile {
name: string;
size: number;
mod_time: string;
}
/** Parsed Caddy access log entry. */
export interface CaddyAccessLog {
level: string;
ts: number;
logger: string;
msg: string;
request: {
remote_ip: string;
method: string;
host: string;
uri: string;
proto: string;
};
status: number;
duration: number;
size: number;
}
/** Paginated log response. */
export interface LogResponse {
filename: string;
logs: CaddyAccessLog[];
total: number;
limit: number;
offset: number;
}
/** Filter options for log queries. */
export interface LogFilter {
search?: string;
host?: string;
status?: string;
level?: string;
limit?: number;
offset?: number;
sort?: 'asc' | 'desc';
}
/**
* Fetches the list of available log files.
* @returns Promise resolving to array of LogFile objects
* @throws {AxiosError} If the request fails
*/
export const getLogs = async (): Promise<LogFile[]> => {
const response = await client.get<LogFile[]>('/logs');
return response.data;
};
/**
* Fetches paginated and filtered log entries from a specific file.
* @param filename - The log file name to read
* @param filter - Optional filter and pagination options
* @returns Promise resolving to LogResponse with entries and metadata
* @throws {AxiosError} If the request fails or file not found
*/
export const getLogContent = async (filename: string, filter: LogFilter = {}): Promise<LogResponse> => {
const params = new URLSearchParams();
if (filter.search) params.append('search', filter.search);
if (filter.host) params.append('host', filter.host);
if (filter.status) params.append('status', filter.status);
if (filter.level) params.append('level', filter.level);
if (filter.limit) params.append('limit', filter.limit.toString());
if (filter.offset) params.append('offset', filter.offset.toString());
if (filter.sort) params.append('sort', filter.sort);
const response = await client.get<LogResponse>(`/logs/${filename}?${params.toString()}`);
return response.data;
};
/**
* Initiates a log file download by redirecting the browser.
* @param filename - The log file name to download
*/
export const downloadLog = (filename: string) => {
// Direct window location change to trigger download
// We need to use the base URL from the client config if possible,
// but for now we assume relative path works with the proxy setup
window.location.href = `/api/v1/logs/${filename}/download`;
};
/** Live log entry from WebSocket stream. */
export interface LiveLogEntry {
level: string;
timestamp: string;
message: string;
source?: string;
data?: Record<string, unknown>;
}
/** Filter options for live log streaming. */
export interface LiveLogFilter {
level?: string;
source?: string;
}
/**
* SecurityLogEntry represents a security-relevant log entry from Cerberus.
* This matches the backend SecurityLogEntry struct from /api/v1/cerberus/logs/ws
*/
export interface SecurityLogEntry {
timestamp: string;
level: string;
logger: string;
client_ip: string;
method: string;
uri: string;
status: number;
duration: number;
size: number;
user_agent: string;
host: string;
source: 'waf' | 'crowdsec' | 'ratelimit' | 'acl' | 'normal';
blocked: boolean;
block_reason?: string;
details?: Record<string, unknown>;
}
/**
* Filters for the Cerberus security logs WebSocket endpoint.
*/
export interface SecurityLogFilter {
source?: string; // Filter by security module: waf, crowdsec, ratelimit, acl, normal
level?: string; // Filter by log level: info, warn, error
ip?: string; // Filter by client IP (partial match)
host?: string; // Filter by host (partial match)
blocked_only?: boolean; // Only show blocked requests
}
/**
* Connects to the live logs WebSocket endpoint for real-time log streaming.
* Returns a cleanup function to close the connection.
* @param filters - LiveLogFilter options for level and source filtering
* @param onMessage - Callback invoked for each received LiveLogEntry
* @param onOpen - Optional callback when WebSocket connection is established
* @param onError - Optional callback on WebSocket error
* @param onClose - Optional callback when WebSocket connection closes
* @returns Function to close the WebSocket connection
*/
export const connectLiveLogs = (
filters: LiveLogFilter,
onMessage: (log: LiveLogEntry) => void,
onOpen?: () => void,
onError?: (error: Event) => void,
onClose?: () => void
): (() => void) => {
const params = new URLSearchParams();
if (filters.level) params.append('level', filters.level);
if (filters.source) params.append('source', filters.source);
// Authentication is handled via HttpOnly cookies sent automatically by the browser
// This prevents tokens from being logged in access logs or exposed to XSS attacks
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/logs/live?${params.toString()}`;
console.log('Connecting to WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connection established');
onOpen?.();
};
ws.onmessage = (event: MessageEvent) => {
try {
const log = JSON.parse(event.data) as LiveLogEntry;
onMessage(log);
} catch (err) {
console.error('Failed to parse log message:', err);
}
};
ws.onerror = (error: Event) => {
console.error('WebSocket error:', error);
onError?.(error);
};
ws.onclose = (event: CloseEvent) => {
console.log('WebSocket connection closed', { code: event.code, reason: event.reason, wasClean: event.wasClean });
onClose?.();
};
return () => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
};
};
/**
* Connects to the Cerberus security logs WebSocket endpoint.
* This streams parsed Caddy access logs with security event annotations.
*
* @param filters - Optional filters for source, level, IP, host, and blocked_only
* @param onMessage - Callback for each received SecurityLogEntry
* @param onOpen - Callback when connection is established
* @param onError - Callback on connection error
* @param onClose - Callback when connection closes
* @returns A function to close the WebSocket connection
*/
export const connectSecurityLogs = (
filters: SecurityLogFilter,
onMessage: (log: SecurityLogEntry) => void,
onOpen?: () => void,
onError?: (error: Event) => void,
onClose?: () => void
): (() => void) => {
const params = new URLSearchParams();
if (filters.source) params.append('source', filters.source);
if (filters.level) params.append('level', filters.level);
if (filters.ip) params.append('ip', filters.ip);
if (filters.host) params.append('host', filters.host);
if (filters.blocked_only) params.append('blocked_only', 'true');
// Authentication is handled via HttpOnly cookies sent automatically by the browser
// This prevents tokens from being logged in access logs or exposed to XSS attacks
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/cerberus/logs/ws?${params.toString()}`;
console.log('Connecting to Cerberus logs WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Cerberus logs WebSocket connection established');
onOpen?.();
};
ws.onmessage = (event: MessageEvent) => {
try {
const log = JSON.parse(event.data) as SecurityLogEntry;
onMessage(log);
} catch (err) {
console.error('Failed to parse security log message:', err);
}
};
ws.onerror = (error: Event) => {
console.error('Cerberus logs WebSocket error:', error);
onError?.(error);
};
ws.onclose = (event: CloseEvent) => {
console.log('Cerberus logs WebSocket closed', { code: event.code, reason: event.reason, wasClean: event.wasClean });
onClose?.();
};
return () => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
};
};

View File

@@ -1,115 +0,0 @@
import client from './client'
/** Status of a manual DNS challenge */
export type ChallengeStatus = 'created' | 'pending' | 'verifying' | 'verified' | 'expired' | 'failed'
/** Manual DNS challenge response from API */
export interface ManualChallenge {
id: string
status: ChallengeStatus
fqdn: string
value: string
ttl: number
created_at: string
expires_at: string
last_check_at?: string
dns_propagated: boolean
error_message?: string
}
/** Polling response for challenge status */
export interface ChallengePollResponse {
status: ChallengeStatus
dns_propagated: boolean
time_remaining_seconds: number
last_check_at: string
error_message?: string
}
/** Challenge verification result */
export interface ChallengeVerifyResponse {
success: boolean
dns_found: boolean
message: string
}
/** Request to create a new manual challenge */
export interface CreateChallengeRequest {
domain: string
}
/**
* Fetches a manual challenge by ID.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to the challenge details
* @throws {AxiosError} If not found or request fails
*/
export async function getChallenge(providerId: number, challengeId: string): Promise<ManualChallenge> {
const response = await client.get<ManualChallenge>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}`
)
return response.data
}
/**
* Creates a new manual DNS challenge.
* @param providerId - The DNS provider ID
* @param data - Challenge creation data
* @returns Promise resolving to the created challenge
* @throws {AxiosError} If validation fails or request fails
*/
export async function createChallenge(
providerId: number,
data: CreateChallengeRequest
): Promise<ManualChallenge> {
const response = await client.post<ManualChallenge>(
`/dns-providers/${providerId}/manual-challenge`,
data
)
return response.data
}
/**
* Triggers verification of a manual challenge.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to verification result
* @throws {AxiosError} If not found or request fails
*/
export async function verifyChallenge(
providerId: number,
challengeId: string
): Promise<ChallengeVerifyResponse> {
const response = await client.post<ChallengeVerifyResponse>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}/verify`
)
return response.data
}
/**
* Polls for challenge status updates.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to poll response
* @throws {AxiosError} If not found or request fails
*/
export async function pollChallenge(
providerId: number,
challengeId: string
): Promise<ChallengePollResponse> {
const response = await client.get<ChallengePollResponse>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}/poll`
)
return response.data
}
/**
* Deletes/cancels a manual challenge.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @throws {AxiosError} If not found or request fails
*/
export async function deleteChallenge(providerId: number, challengeId: string): Promise<void> {
await client.delete(`/dns-providers/${providerId}/manual-challenge/${challengeId}`)
}

View File

@@ -1,149 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from './client'
import {
getProviders,
createProvider,
updateProvider,
deleteProvider,
testProvider,
getTemplates,
previewProvider,
getExternalTemplates,
createExternalTemplate,
updateExternalTemplate,
deleteExternalTemplate,
previewExternalTemplate,
getSecurityNotificationSettings,
updateSecurityNotificationSettings,
} from './notifications'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
describe('notifications api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches providers list', async () => {
mockedClient.get.mockResolvedValue({
data: [
{
id: '1',
name: 'PagerDuty',
type: 'webhook',
url: 'https://hooks.example.com',
enabled: true,
notify_proxy_hosts: true,
notify_remote_servers: false,
notify_domains: false,
notify_certs: false,
notify_uptime: true,
created_at: '2025-01-01T00:00:00Z',
},
],
})
const result = await getProviders()
expect(mockedClient.get).toHaveBeenCalledWith('/notifications/providers')
expect(result[0].name).toBe('PagerDuty')
})
it('creates, updates, tests, and deletes a provider', async () => {
mockedClient.post.mockResolvedValue({ data: { id: 'new', name: 'Slack' } })
mockedClient.put.mockResolvedValue({ data: { id: 'new', name: 'Slack v2' } })
const created = await createProvider({ name: 'Slack' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers', { name: 'Slack' })
expect(created.id).toBe('new')
const updated = await updateProvider('new', { enabled: false })
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/new', { enabled: false })
expect(updated.name).toBe('Slack v2')
await testProvider({ id: 'new', name: 'Slack', enabled: true })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/test', {
id: 'new',
name: 'Slack',
enabled: true,
})
mockedClient.delete.mockResolvedValue({})
await deleteProvider('new')
expect(mockedClient.delete).toHaveBeenCalledWith('/notifications/providers/new')
})
it('fetches templates and previews provider payloads with data', async () => {
mockedClient.get.mockResolvedValueOnce({ data: [{ id: 'tpl', name: 'default' }] })
mockedClient.post.mockResolvedValue({ data: { preview: 'ok' } })
const templates = await getTemplates()
expect(mockedClient.get).toHaveBeenCalledWith('/notifications/templates')
expect(templates[0].id).toBe('tpl')
const preview = await previewProvider({ id: 'p1', name: 'Provider' }, { foo: 'bar' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/preview', {
id: 'p1',
name: 'Provider',
data: { foo: 'bar' },
})
expect(preview).toEqual({ preview: 'ok' })
})
it('handles external templates lifecycle and previews', async () => {
mockedClient.get.mockResolvedValueOnce({ data: [{ id: 'ext', name: 'External' }] })
mockedClient.post.mockResolvedValueOnce({ data: { id: 'ext', name: 'created' } })
mockedClient.put.mockResolvedValueOnce({ data: { id: 'ext', name: 'updated' } })
mockedClient.post.mockResolvedValueOnce({ data: { preview: 'rendered' } })
const list = await getExternalTemplates()
expect(mockedClient.get).toHaveBeenCalledWith('/notifications/external-templates')
expect(list[0].id).toBe('ext')
const created = await createExternalTemplate({ name: 'External' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/external-templates', { name: 'External' })
expect(created.name).toBe('created')
const updated = await updateExternalTemplate('ext', { description: 'desc' })
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/external-templates/ext', { description: 'desc' })
expect(updated.name).toBe('updated')
await deleteExternalTemplate('ext')
expect(mockedClient.delete).toHaveBeenCalledWith('/notifications/external-templates/ext')
const preview = await previewExternalTemplate('ext', '<tpl>', { a: 1 })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/external-templates/preview', {
template_id: 'ext',
template: '<tpl>',
data: { a: 1 },
})
expect(preview).toEqual({ preview: 'rendered' })
})
it('reads and updates security notification settings', async () => {
mockedClient.get.mockResolvedValueOnce({ data: { enabled: true, min_log_level: 'info', notify_waf_blocks: true, notify_acl_denials: false, notify_rate_limit_hits: true } })
mockedClient.put.mockResolvedValueOnce({ data: { enabled: false, min_log_level: 'error', notify_waf_blocks: false, notify_acl_denials: true, notify_rate_limit_hits: false } })
const settings = await getSecurityNotificationSettings()
expect(settings.enabled).toBe(true)
expect(mockedClient.get).toHaveBeenCalledWith('/notifications/settings/security')
const updated = await updateSecurityNotificationSettings({ enabled: false, min_log_level: 'error' })
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/settings/security', { enabled: false, min_log_level: 'error' })
expect(updated.enabled).toBe(false)
})
})

View File

@@ -1,204 +0,0 @@
import client from './client';
/** Notification provider configuration. */
export interface NotificationProvider {
id: string;
name: string;
type: string;
url: string;
config?: string;
template?: string;
enabled: boolean;
notify_proxy_hosts: boolean;
notify_remote_servers: boolean;
notify_domains: boolean;
notify_certs: boolean;
notify_uptime: boolean;
created_at: string;
}
/**
* Fetches all notification providers.
* @returns Promise resolving to array of NotificationProvider objects
* @throws {AxiosError} If the request fails
*/
export const getProviders = async () => {
const response = await client.get<NotificationProvider[]>('/notifications/providers');
return response.data;
};
/**
* Creates a new notification provider.
* @param data - Partial NotificationProvider configuration
* @returns Promise resolving to the created NotificationProvider
* @throws {AxiosError} If creation fails
*/
export const createProvider = async (data: Partial<NotificationProvider>) => {
const response = await client.post<NotificationProvider>('/notifications/providers', data);
return response.data;
};
/**
* Updates an existing notification provider.
* @param id - The provider ID to update
* @param data - Partial NotificationProvider with fields to update
* @returns Promise resolving to the updated NotificationProvider
* @throws {AxiosError} If update fails or provider not found
*/
export const updateProvider = async (id: string, data: Partial<NotificationProvider>) => {
const response = await client.put<NotificationProvider>(`/notifications/providers/${id}`, data);
return response.data;
};
/**
* Deletes a notification provider.
* @param id - The provider ID to delete
* @throws {AxiosError} If deletion fails or provider not found
*/
export const deleteProvider = async (id: string) => {
await client.delete(`/notifications/providers/${id}`);
};
/**
* Tests a notification provider by sending a test message.
* @param provider - Provider configuration to test
* @throws {AxiosError} If test fails
*/
export const testProvider = async (provider: Partial<NotificationProvider>) => {
await client.post('/notifications/providers/test', provider);
};
/**
* Fetches all available notification templates.
* @returns Promise resolving to array of NotificationTemplate objects
* @throws {AxiosError} If the request fails
*/
export const getTemplates = async () => {
const response = await client.get<NotificationTemplate[]>('/notifications/templates');
return response.data;
};
/** Notification template definition. */
export interface NotificationTemplate {
id: string;
name: string;
}
/**
* Previews a notification with sample data.
* @param provider - Provider configuration for preview
* @param data - Optional sample data for template rendering
* @returns Promise resolving to preview result
* @throws {AxiosError} If preview fails
*/
export const previewProvider = async (provider: Partial<NotificationProvider>, data?: Record<string, unknown>) => {
const payload: Record<string, unknown> = { ...provider } as Record<string, unknown>;
if (data) payload.data = data;
const response = await client.post('/notifications/providers/preview', payload);
return response.data;
};
// External (saved) templates API
/** External notification template configuration. */
export interface ExternalTemplate {
id: string;
name: string;
description?: string;
config?: string;
template?: string;
created_at?: string;
}
/**
* Fetches all external notification templates.
* @returns Promise resolving to array of ExternalTemplate objects
* @throws {AxiosError} If the request fails
*/
export const getExternalTemplates = async () => {
const response = await client.get<ExternalTemplate[]>('/notifications/external-templates');
return response.data;
};
/**
* Creates a new external notification template.
* @param data - Partial ExternalTemplate configuration
* @returns Promise resolving to the created ExternalTemplate
* @throws {AxiosError} If creation fails
*/
export const createExternalTemplate = async (data: Partial<ExternalTemplate>) => {
const response = await client.post<ExternalTemplate>('/notifications/external-templates', data);
return response.data;
};
/**
* Updates an existing external notification template.
* @param id - The template ID to update
* @param data - Partial ExternalTemplate with fields to update
* @returns Promise resolving to the updated ExternalTemplate
* @throws {AxiosError} If update fails or template not found
*/
export const updateExternalTemplate = async (id: string, data: Partial<ExternalTemplate>) => {
const response = await client.put<ExternalTemplate>(`/notifications/external-templates/${id}`, data);
return response.data;
};
/**
* Deletes an external notification template.
* @param id - The template ID to delete
* @throws {AxiosError} If deletion fails or template not found
*/
export const deleteExternalTemplate = async (id: string) => {
await client.delete(`/notifications/external-templates/${id}`);
};
/**
* Previews an external template with sample data.
* @param templateId - Optional existing template ID to preview
* @param template - Optional template content string
* @param data - Optional sample data for rendering
* @returns Promise resolving to preview result
* @throws {AxiosError} If preview fails
*/
export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record<string, unknown>) => {
const payload: Record<string, unknown> = {};
if (templateId) payload.template_id = templateId;
if (template) payload.template = template;
if (data) payload.data = data;
const response = await client.post('/notifications/external-templates/preview', payload);
return response.data;
};
// Security Notification Settings
/** Security notification configuration. */
export interface SecurityNotificationSettings {
enabled: boolean;
min_log_level: string;
notify_waf_blocks: boolean;
notify_acl_denials: boolean;
notify_rate_limit_hits: boolean;
webhook_url?: string;
email_recipients?: string;
}
/**
* Fetches security notification settings.
* @returns Promise resolving to SecurityNotificationSettings
* @throws {AxiosError} If the request fails
*/
export const getSecurityNotificationSettings = async (): Promise<SecurityNotificationSettings> => {
const response = await client.get<SecurityNotificationSettings>('/notifications/settings/security');
return response.data;
};
/**
* Updates security notification settings.
* @param settings - Partial settings to update
* @returns Promise resolving to the updated SecurityNotificationSettings
* @throws {AxiosError} If update fails
*/
export const updateSecurityNotificationSettings = async (
settings: Partial<SecurityNotificationSettings>
): Promise<SecurityNotificationSettings> => {
const response = await client.put<SecurityNotificationSettings>('/notifications/settings/security', settings);
return response.data;
};

View File

@@ -1,90 +0,0 @@
import client from './client';
/** Represents a host parsed from an NPM export. */
export interface NPMHost {
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket_support: boolean;
}
/** Preview of an NPM import with hosts and conflicts. */
export interface NPMImportPreview {
session: {
id: string;
state: string;
source: string;
};
preview: {
hosts: NPMHost[];
conflicts: string[];
errors: string[];
};
conflict_details: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
/** Result of committing an NPM import operation. */
export interface NPMImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* Uploads NPM export content for import preview.
* @param content - The NPM export JSON content as a string
* @returns Promise resolving to NPMImportPreview with parsed hosts
* @throws {AxiosError} If parsing fails or content is invalid
*/
export const uploadNPMExport = async (content: string): Promise<NPMImportPreview> => {
const { data } = await client.post<NPMImportPreview>('/import/npm/upload', { content });
return data;
};
/**
* Commits the NPM import, creating/updating proxy hosts.
* @param sessionUuid - The import session UUID
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
* @param names - Map of custom names for imported hosts
* @returns Promise resolving to NPMImportCommitResult with counts
* @throws {AxiosError} If commit fails
*/
export const commitNPMImport = async (
sessionUuid: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<NPMImportCommitResult> => {
const { data } = await client.post<NPMImportCommitResult>('/import/npm/commit', {
session_uuid: sessionUuid,
resolutions,
names,
});
return data;
};
/**
* Cancels the current NPM import session.
* @throws {AxiosError} If cancellation fails
*/
export const cancelNPMImport = async (): Promise<void> => {
await client.post('/import/npm/cancel');
};

View File

@@ -1,109 +0,0 @@
import client from './client'
/** Plugin status types */
export type PluginStatus = 'pending' | 'loaded' | 'error'
/** Plugin information */
export interface PluginInfo {
id: number
uuid: string
name: string
type: string
enabled: boolean
status: PluginStatus
error?: string
version?: string
author?: string
is_built_in: boolean
description?: string
documentation_url?: string
loaded_at?: string
created_at: string
updated_at: string
}
/** Credential field specification */
export interface CredentialFieldSpec {
name: string
label: string
type: 'text' | 'password' | 'textarea' | 'select'
placeholder?: string
hint?: string
required?: boolean
options?: Array<{
value: string
label: string
}>
}
/** Provider metadata response */
export interface ProviderFieldsResponse {
type: string
name: string
required_fields: CredentialFieldSpec[]
optional_fields: CredentialFieldSpec[]
}
/**
* Fetches all plugins (built-in and external).
* @returns Promise resolving to array of plugin info
* @throws {AxiosError} If the request fails
*/
export async function getPlugins(): Promise<PluginInfo[]> {
const response = await client.get<PluginInfo[]>('/admin/plugins')
return response.data
}
/**
* Fetches a single plugin by ID.
* @param id - The plugin ID
* @returns Promise resolving to the plugin info
* @throws {AxiosError} If not found or request fails
*/
export async function getPlugin(id: number): Promise<PluginInfo> {
const response = await client.get<PluginInfo>(`/admin/plugins/${id}`)
return response.data
}
/**
* Enables a disabled plugin.
* @param id - The plugin ID
* @returns Promise resolving to success message
* @throws {AxiosError} If not found or request fails
*/
export async function enablePlugin(id: number): Promise<{ message: string }> {
const response = await client.post<{ message: string }>(`/admin/plugins/${id}/enable`)
return response.data
}
/**
* Disables an active plugin.
* @param id - The plugin ID
* @returns Promise resolving to success message
* @throws {AxiosError} If not found, in use, or request fails
*/
export async function disablePlugin(id: number): Promise<{ message: string }> {
const response = await client.post<{ message: string }>(`/admin/plugins/${id}/disable`)
return response.data
}
/**
* Reloads all plugins from the plugin directory.
* @returns Promise resolving to success message and count
* @throws {AxiosError} If request fails
*/
export async function reloadPlugins(): Promise<{ message: string; count: number }> {
const response = await client.post<{ message: string; count: number }>('/admin/plugins/reload')
return response.data
}
/**
* Fetches credential field definitions for a DNS provider type.
* @param providerType - The provider type (e.g., "cloudflare", "powerdns")
* @returns Promise resolving to field specifications
* @throws {AxiosError} If provider type not found or request fails
*/
export async function getProviderFields(providerType: string): Promise<ProviderFieldsResponse> {
const response = await client.get<ProviderFieldsResponse>(`/dns-providers/types/${providerType}/fields`)
return response.data
}

View File

@@ -1,104 +0,0 @@
import client from './client'
/** Summary of an available CrowdSec preset. */
export interface CrowdsecPresetSummary {
slug: string
title: string
summary: string
source: string
tags?: string[]
requires_hub: boolean
available: boolean
cached: boolean
cache_key?: string
etag?: string
retrieved_at?: string
}
/** Response from pulling a CrowdSec preset. */
export interface PullCrowdsecPresetResponse {
status: string
slug: string
preview: string
cache_key: string
etag?: string
retrieved_at?: string
source?: string
}
/** Response from applying a CrowdSec preset. */
export interface ApplyCrowdsecPresetResponse {
status: string
backup?: string
reload_hint?: boolean
used_cscli?: boolean
cache_key?: string
slug?: string
}
/** Cached CrowdSec preset preview data. */
export interface CachedCrowdsecPresetPreview {
preview: string
cache_key: string
etag?: string
}
/**
* Lists all available CrowdSec presets.
* @returns Promise resolving to object containing presets array
* @throws {AxiosError} If the request fails
*/
export async function listCrowdsecPresets() {
const resp = await client.get<{ presets: CrowdsecPresetSummary[] }>('/admin/crowdsec/presets')
return resp.data
}
/**
* Gets all CrowdSec presets (alias for listCrowdsecPresets).
* @returns Promise resolving to object containing presets array
* @throws {AxiosError} If the request fails
*/
export async function getCrowdsecPresets() {
return listCrowdsecPresets()
}
/**
* Pulls a CrowdSec preset from the remote source.
* @param slug - The preset slug identifier
* @returns Promise resolving to PullCrowdsecPresetResponse with preview
* @throws {AxiosError} If pull fails or preset not found
*/
export async function pullCrowdsecPreset(slug: string) {
const resp = await client.post<PullCrowdsecPresetResponse>('/admin/crowdsec/presets/pull', { slug })
return resp.data
}
/**
* Applies a CrowdSec preset to the configuration.
* @param payload - Object with preset slug and optional cache_key
* @returns Promise resolving to ApplyCrowdsecPresetResponse
* @throws {AxiosError} If application fails
*/
export async function applyCrowdsecPreset(payload: { slug: string; cache_key?: string }) {
const resp = await client.post<ApplyCrowdsecPresetResponse>('/admin/crowdsec/presets/apply', payload)
return resp.data
}
/**
* Gets a cached CrowdSec preset preview.
* @param slug - The preset slug identifier
* @returns Promise resolving to CachedCrowdsecPresetPreview
* @throws {AxiosError} If not cached or request fails
*/
export async function getCrowdsecPresetCache(slug: string) {
const resp = await client.get<CachedCrowdsecPresetPreview>(`/admin/crowdsec/presets/cache/${encodeURIComponent(slug)}`)
return resp.data
}
export default {
listCrowdsecPresets,
getCrowdsecPresets,
pullCrowdsecPreset,
applyCrowdsecPreset,
getCrowdsecPresetCache,
}

View File

@@ -1,182 +0,0 @@
import client from './client';
export interface Location {
uuid?: string;
path: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
}
export interface Certificate {
id: number;
uuid: string;
name: string;
provider: string;
domains: string;
expires_at: string;
}
export type ApplicationPreset = 'none' | 'plex' | 'jellyfin' | 'emby' | 'homeassistant' | 'nextcloud' | 'vaultwarden';
export interface ProxyHost {
uuid: string;
name: string;
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
http2_support: boolean;
hsts_enabled: boolean;
hsts_subdomains: boolean;
block_exploits: boolean;
websocket_support: boolean;
enable_standard_headers?: boolean;
forward_auth_enabled?: boolean;
waf_disabled?: boolean;
application: ApplicationPreset;
locations: Location[];
advanced_config?: string;
advanced_config_backup?: string;
enabled: boolean;
certificate_id?: number | null;
certificate?: Certificate | null;
access_list_id?: number | null;
security_header_profile_id?: number | null;
dns_provider_id?: number | null;
security_header_profile?: {
id: number;
uuid: string;
name: string;
description: string;
security_score: number;
is_preset: boolean;
} | null;
created_at: string;
updated_at: string;
}
/**
* Fetches all proxy hosts from the API.
* @returns Promise resolving to array of ProxyHost objects
* @throws {AxiosError} If the request fails
*/
export const getProxyHosts = async (): Promise<ProxyHost[]> => {
const { data } = await client.get<ProxyHost[]>('/proxy-hosts');
return data;
};
/**
* Fetches a single proxy host by UUID.
* @param uuid - The unique identifier of the proxy host
* @returns Promise resolving to the ProxyHost object
* @throws {AxiosError} If the request fails or host not found
*/
export const getProxyHost = async (uuid: string): Promise<ProxyHost> => {
const { data } = await client.get<ProxyHost>(`/proxy-hosts/${uuid}`);
return data;
};
/**
* Creates a new proxy host.
* @param host - Partial ProxyHost object with configuration
* @returns Promise resolving to the created ProxyHost
* @throws {AxiosError} If the request fails or validation errors occur
*/
export const createProxyHost = async (host: Partial<ProxyHost>): Promise<ProxyHost> => {
const { data } = await client.post<ProxyHost>('/proxy-hosts', host);
return data;
};
/**
* Updates an existing proxy host.
* @param uuid - The unique identifier of the proxy host to update
* @param host - Partial ProxyHost object with fields to update
* @returns Promise resolving to the updated ProxyHost
* @throws {AxiosError} If the request fails or host not found
*/
export const updateProxyHost = async (uuid: string, host: Partial<ProxyHost>): Promise<ProxyHost> => {
const { data } = await client.put<ProxyHost>(`/proxy-hosts/${uuid}`, host);
return data;
};
/**
* Deletes a proxy host.
* @param uuid - The unique identifier of the proxy host to delete
* @param deleteUptime - Optional flag to also delete associated uptime monitors
* @throws {AxiosError} If the request fails or host not found
*/
export const deleteProxyHost = async (uuid: string, deleteUptime?: boolean): Promise<void> => {
const url = `/proxy-hosts/${uuid}${deleteUptime ? '?delete_uptime=true' : ''}`
await client.delete(url);
};
/**
* Tests connectivity to a backend host.
* @param host - The hostname or IP address to test
* @param port - The port number to test
* @throws {AxiosError} If the connection test fails
*/
export const testProxyHostConnection = async (host: string, port: number): Promise<void> => {
await client.post('/proxy-hosts/test', { forward_host: host, forward_port: port });
};
export interface BulkUpdateACLRequest {
host_uuids: string[];
access_list_id: number | null;
}
export interface BulkUpdateACLResponse {
updated: number;
errors: { uuid: string; error: string }[];
}
/**
* Bulk updates access control list assignments for multiple proxy hosts.
* @param hostUUIDs - Array of proxy host UUIDs to update
* @param accessListID - The access list ID to assign, or null to remove
* @returns Promise resolving to the bulk update result with success/error counts
* @throws {AxiosError} If the request fails
*/
export const bulkUpdateACL = async (
hostUUIDs: string[],
accessListID: number | null
): Promise<BulkUpdateACLResponse> => {
const { data } = await client.put<BulkUpdateACLResponse>('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: accessListID,
});
return data;
};
export interface BulkUpdateSecurityHeadersRequest {
host_uuids: string[];
security_header_profile_id: number | null;
}
export interface BulkUpdateSecurityHeadersResponse {
updated: number;
errors: { uuid: string; error: string }[];
}
/**
* Bulk updates security header profile assignments for multiple proxy hosts.
* @param hostUUIDs - Array of proxy host UUIDs to update
* @param securityHeaderProfileId - The security header profile ID to assign, or null to remove
* @returns Promise resolving to the bulk update result with success/error counts
* @throws {AxiosError} If the request fails
*/
export const bulkUpdateSecurityHeaders = async (
hostUUIDs: string[],
securityHeaderProfileId: number | null
): Promise<BulkUpdateSecurityHeadersResponse> => {
const { data } = await client.put<BulkUpdateSecurityHeadersResponse>(
'/proxy-hosts/bulk-update-security-headers',
{
host_uuids: hostUUIDs,
security_header_profile_id: securityHeaderProfileId,
}
);
return data;
};

View File

@@ -1,94 +0,0 @@
import client from './client';
/** Remote server configuration for Docker host connections. */
export interface RemoteServer {
uuid: string;
name: string;
provider: string;
host: string;
port: number;
username?: string;
enabled: boolean;
reachable: boolean;
last_check?: string;
created_at: string;
updated_at: string;
}
/**
* Fetches all remote servers.
* @param enabledOnly - If true, only returns enabled servers
* @returns Promise resolving to array of RemoteServer objects
* @throws {AxiosError} If the request fails
*/
export const getRemoteServers = async (enabledOnly = false): Promise<RemoteServer[]> => {
const params = enabledOnly ? { enabled: true } : {};
const { data } = await client.get<RemoteServer[]>('/remote-servers', { params });
return data;
};
/**
* Fetches a single remote server by UUID.
* @param uuid - The unique identifier of the remote server
* @returns Promise resolving to the RemoteServer object
* @throws {AxiosError} If the request fails or server not found
*/
export const getRemoteServer = async (uuid: string): Promise<RemoteServer> => {
const { data } = await client.get<RemoteServer>(`/remote-servers/${uuid}`);
return data;
};
/**
* Creates a new remote server.
* @param server - Partial RemoteServer configuration
* @returns Promise resolving to the created RemoteServer
* @throws {AxiosError} If creation fails
*/
export const createRemoteServer = async (server: Partial<RemoteServer>): Promise<RemoteServer> => {
const { data } = await client.post<RemoteServer>('/remote-servers', server);
return data;
};
/**
* Updates an existing remote server.
* @param uuid - The unique identifier of the server to update
* @param server - Partial RemoteServer with fields to update
* @returns Promise resolving to the updated RemoteServer
* @throws {AxiosError} If update fails or server not found
*/
export const updateRemoteServer = async (uuid: string, server: Partial<RemoteServer>): Promise<RemoteServer> => {
const { data } = await client.put<RemoteServer>(`/remote-servers/${uuid}`, server);
return data;
};
/**
* Deletes a remote server.
* @param uuid - The unique identifier of the server to delete
* @throws {AxiosError} If deletion fails or server not found
*/
export const deleteRemoteServer = async (uuid: string): Promise<void> => {
await client.delete(`/remote-servers/${uuid}`);
};
/**
* Tests connectivity to an existing remote server.
* @param uuid - The unique identifier of the server to test
* @returns Promise resolving to object with server address
* @throws {AxiosError} If connection test fails
*/
export const testRemoteServerConnection = async (uuid: string): Promise<{ address: string }> => {
const { data } = await client.post<{ address: string }>(`/remote-servers/${uuid}/test`);
return data;
};
/**
* Tests connectivity to a custom host and port.
* @param host - The hostname or IP to test
* @param port - The port number to test
* @returns Promise resolving to connection result with reachable status
* @throws {AxiosError} If request fails
*/
export const testCustomRemoteServerConnection = async (host: string, port: number): Promise<{ address: string; reachable: boolean; error?: string }> => {
const { data } = await client.post<{ address: string; reachable: boolean; error?: string }>('/remote-servers/test', { host, port });
return data;
};

View File

@@ -1,189 +0,0 @@
import client from './client'
/** Security module status information. */
export interface SecurityStatus {
cerberus?: { enabled: boolean }
crowdsec: {
mode: 'disabled' | 'local'
api_url: string
enabled: boolean
}
waf: {
mode: 'disabled' | 'enabled'
enabled: boolean
}
rate_limit: {
mode?: 'disabled' | 'enabled'
enabled: boolean
}
acl: {
enabled: boolean
}
}
/**
* Gets the current security status for all modules.
* @returns Promise resolving to SecurityStatus
* @throws {AxiosError} If the request fails
*/
export const getSecurityStatus = async (): Promise<SecurityStatus> => {
const response = await client.get<SecurityStatus>('/security/status')
return response.data
}
/** Security configuration payload. */
export interface SecurityConfigPayload {
name?: string
enabled?: boolean
admin_whitelist?: string
crowdsec_mode?: string
crowdsec_api_url?: string
waf_mode?: string
waf_rules_source?: string
waf_learning?: boolean
rate_limit_enable?: boolean
rate_limit_burst?: number
rate_limit_requests?: number
rate_limit_window_sec?: number
}
/**
* Gets the current security configuration.
* @returns Promise resolving to the security configuration
* @throws {AxiosError} If the request fails
*/
export const getSecurityConfig = async () => {
const response = await client.get('/security/config')
return response.data
}
/**
* Updates security configuration.
* @param payload - SecurityConfigPayload with settings to update
* @returns Promise resolving to the updated configuration
* @throws {AxiosError} If update fails
*/
export const updateSecurityConfig = async (payload: SecurityConfigPayload) => {
const response = await client.post('/security/config', payload)
return response.data
}
/**
* Generates a break-glass token for emergency access.
* @returns Promise resolving to object containing the token
* @throws {AxiosError} If generation fails
*/
export const generateBreakGlassToken = async () => {
const response = await client.post('/security/breakglass/generate')
return response.data
}
/**
* Enables the Cerberus security module.
* @param payload - Optional configuration for enabling
* @returns Promise resolving to enable result
* @throws {AxiosError} If enabling fails
*/
export const enableCerberus = async (payload?: Record<string, unknown>) => {
const response = await client.post('/security/enable', payload || {})
return response.data
}
/**
* Disables the Cerberus security module.
* @param payload - Optional configuration for disabling
* @returns Promise resolving to disable result
* @throws {AxiosError} If disabling fails
*/
export const disableCerberus = async (payload?: Record<string, unknown>) => {
const response = await client.post('/security/disable', payload || {})
return response.data
}
/**
* Gets security decisions (bans, captchas) with optional limit.
* @param limit - Maximum number of decisions to return (default: 50)
* @returns Promise resolving to decisions list
* @throws {AxiosError} If the request fails
*/
export const getDecisions = async (limit = 50) => {
const response = await client.get(`/security/decisions?limit=${limit}`)
return response.data
}
/** Payload for creating a security decision. */
export interface CreateDecisionPayload {
type: string
value: string
duration: string
reason?: string
}
/**
* Creates a new security decision (e.g., ban an IP).
* @param payload - Decision configuration
* @returns Promise resolving to the created decision
* @throws {AxiosError} If creation fails
*/
export const createDecision = async (payload: CreateDecisionPayload) => {
const response = await client.post('/security/decisions', payload)
return response.data
}
// WAF Ruleset types
/** WAF security ruleset configuration. */
export interface SecurityRuleSet {
id: number
uuid: string
name: string
source_url: string
mode: string
last_updated: string
content: string
}
/** Response containing WAF rulesets. */
export interface RuleSetsResponse {
rulesets: SecurityRuleSet[]
}
/** Payload for creating/updating a WAF ruleset. */
export interface UpsertRuleSetPayload {
id?: number
name: string
content?: string
source_url?: string
mode?: 'blocking' | 'detection'
}
/**
* Gets all WAF rulesets.
* @returns Promise resolving to RuleSetsResponse
* @throws {AxiosError} If the request fails
*/
export const getRuleSets = async (): Promise<RuleSetsResponse> => {
const response = await client.get<RuleSetsResponse>('/security/rulesets')
return response.data
}
/**
* Creates or updates a WAF ruleset.
* @param payload - Ruleset configuration
* @returns Promise resolving to the upserted ruleset
* @throws {AxiosError} If upsert fails
*/
export const upsertRuleSet = async (payload: UpsertRuleSetPayload) => {
const response = await client.post('/security/rulesets', payload)
return response.data
}
/**
* Deletes a WAF ruleset.
* @param id - The ruleset ID to delete
* @returns Promise resolving to delete result
* @throws {AxiosError} If deletion fails or ruleset not found
*/
export const deleteRuleSet = async (id: number) => {
const response = await client.delete(`/security/rulesets/${id}`)
return response.data
}

View File

@@ -1,188 +0,0 @@
import client from './client';
// Types
export interface SecurityHeaderProfile {
id: number;
uuid: string;
name: string;
hsts_enabled: boolean;
hsts_max_age: number;
hsts_include_subdomains: boolean;
hsts_preload: boolean;
csp_enabled: boolean;
csp_directives: string;
csp_report_only: boolean;
csp_report_uri: string;
x_frame_options: string;
x_content_type_options: boolean;
referrer_policy: string;
permissions_policy: string;
cross_origin_opener_policy: string;
cross_origin_resource_policy: string;
cross_origin_embedder_policy: string;
xss_protection: boolean;
cache_control_no_store: boolean;
security_score: number;
is_preset: boolean;
preset_type: string;
description: string;
created_at: string;
updated_at: string;
}
export interface SecurityHeaderPreset {
preset_type: 'basic' | 'strict' | 'paranoid';
name: string;
description: string;
security_score: number;
config: Partial<SecurityHeaderProfile>;
}
export interface ScoreBreakdown {
score: number;
max_score: number;
breakdown: Record<string, number>;
suggestions: string[];
}
export interface CSPDirective {
directive: string;
values: string[];
}
export interface CreateProfileRequest {
name: string;
description?: string;
hsts_enabled?: boolean;
hsts_max_age?: number;
hsts_include_subdomains?: boolean;
hsts_preload?: boolean;
csp_enabled?: boolean;
csp_directives?: string;
csp_report_only?: boolean;
csp_report_uri?: string;
x_frame_options?: string;
x_content_type_options?: boolean;
referrer_policy?: string;
permissions_policy?: string;
cross_origin_opener_policy?: string;
cross_origin_resource_policy?: string;
cross_origin_embedder_policy?: string;
xss_protection?: boolean;
cache_control_no_store?: boolean;
}
export interface ApplyPresetRequest {
preset_type: string;
name: string;
}
// API Functions
export const securityHeadersApi = {
/**
* Lists all security header profiles.
* @returns Promise resolving to array of SecurityHeaderProfile objects
* @throws {AxiosError} If the request fails
*/
async listProfiles(): Promise<SecurityHeaderProfile[]> {
const response = await client.get<{profiles: SecurityHeaderProfile[]}>('/security/headers/profiles');
return response.data.profiles;
},
/**
* Gets a single security header profile by ID or UUID.
* @param id - The profile ID (number) or UUID (string)
* @returns Promise resolving to the SecurityHeaderProfile object
* @throws {AxiosError} If the request fails or profile not found
*/
async getProfile(id: number | string): Promise<SecurityHeaderProfile> {
const response = await client.get<{profile: SecurityHeaderProfile}>(`/security/headers/profiles/${id}`);
return response.data.profile;
},
/**
* Creates a new security header profile.
* @param data - CreateProfileRequest with profile configuration
* @returns Promise resolving to the created SecurityHeaderProfile
* @throws {AxiosError} If creation fails or validation errors occur
*/
async createProfile(data: CreateProfileRequest): Promise<SecurityHeaderProfile> {
const response = await client.post<{profile: SecurityHeaderProfile}>('/security/headers/profiles', data);
return response.data.profile;
},
/**
* Updates an existing security header profile.
* @param id - The profile ID to update
* @param data - Partial CreateProfileRequest with fields to update
* @returns Promise resolving to the updated SecurityHeaderProfile
* @throws {AxiosError} If update fails or profile not found
*/
async updateProfile(id: number, data: Partial<CreateProfileRequest>): Promise<SecurityHeaderProfile> {
const response = await client.put<{profile: SecurityHeaderProfile}>(`/security/headers/profiles/${id}`, data);
return response.data.profile;
},
/**
* Deletes a security header profile.
* @param id - The profile ID to delete (cannot delete preset profiles)
* @throws {AxiosError} If deletion fails, profile not found, or is a preset
*/
async deleteProfile(id: number): Promise<void> {
await client.delete(`/security/headers/profiles/${id}`);
},
/**
* Gets all built-in security header presets.
* @returns Promise resolving to array of SecurityHeaderPreset objects
* @throws {AxiosError} If the request fails
*/
async getPresets(): Promise<SecurityHeaderPreset[]> {
const response = await client.get<{presets: SecurityHeaderPreset[]}>('/security/headers/presets');
return response.data.presets;
},
/**
* Applies a preset to create or update a security header profile.
* @param data - ApplyPresetRequest with preset type and profile name
* @returns Promise resolving to the created/updated SecurityHeaderProfile
* @throws {AxiosError} If preset application fails
*/
async applyPreset(data: ApplyPresetRequest): Promise<SecurityHeaderProfile> {
const response = await client.post<{profile: SecurityHeaderProfile}>('/security/headers/presets/apply', data);
return response.data.profile;
},
/**
* Calculates the security score for given header settings.
* @param config - Partial CreateProfileRequest with settings to evaluate
* @returns Promise resolving to ScoreBreakdown with score, max, breakdown, and suggestions
* @throws {AxiosError} If calculation fails
*/
async calculateScore(config: Partial<CreateProfileRequest>): Promise<ScoreBreakdown> {
const response = await client.post<ScoreBreakdown>('/security/headers/score', config);
return response.data;
},
/**
* Validates a Content Security Policy string.
* @param csp - The CSP string to validate
* @returns Promise resolving to object with validity status and any errors
* @throws {AxiosError} If validation request fails
*/
async validateCSP(csp: string): Promise<{ valid: boolean; errors: string[] }> {
const response = await client.post<{ valid: boolean; errors: string[] }>('/security/headers/csp/validate', { csp });
return response.data;
},
/**
* Builds a Content Security Policy string from directives.
* @param directives - Array of CSPDirective objects to combine
* @returns Promise resolving to object containing the built CSP string
* @throws {AxiosError} If build request fails
*/
async buildCSP(directives: CSPDirective[]): Promise<{ csp: string }> {
const response = await client.post<{ csp: string }>('/security/headers/csp/build', { directives });
return response.data;
},
};

View File

@@ -1,57 +0,0 @@
import client from './client'
/** Map of setting keys to string values. */
export interface SettingsMap {
[key: string]: string
}
/**
* Fetches all application settings.
* @returns Promise resolving to SettingsMap
* @throws {AxiosError} If the request fails
*/
export const getSettings = async (): Promise<SettingsMap> => {
const response = await client.get('/settings')
return response.data
}
/**
* Updates a single application setting.
* @param key - The setting key to update
* @param value - The new value for the setting
* @param category - Optional category for organization
* @param type - Optional type hint for the setting
* @throws {AxiosError} If the update fails
*/
export const updateSetting = async (key: string, value: string, category?: string, type?: string): Promise<void> => {
await client.post('/settings', { key, value, category, type })
}
/**
* Validates a URL for use as the application URL.
* @param url - The URL to validate
* @returns Promise resolving to validation result
*/
export const validatePublicURL = async (url: string): Promise<{
valid: boolean
normalized?: string
error?: string
}> => {
const response = await client.post('/settings/validate-url', { url })
return response.data
}
/**
* Tests if a URL is reachable from the server with SSRF protection.
* @param url - The URL to test
* @returns Promise resolving to test result with reachability status and latency
*/
export const testPublicURL = async (url: string): Promise<{
reachable: boolean
latency?: number
message?: string
error?: string
}> => {
const response = await client.post('/settings/test-url', { url })
return response.data
}

View File

@@ -1,32 +0,0 @@
import client from './client';
/** Status indicating if initial setup is required. */
export interface SetupStatus {
setupRequired: boolean;
}
/** Request payload for initial setup. */
export interface SetupRequest {
name: string;
email: string;
password: string;
}
/**
* Checks if initial setup is required.
* @returns Promise resolving to SetupStatus
* @throws {AxiosError} If the request fails
*/
export const getSetupStatus = async (): Promise<SetupStatus> => {
const response = await client.get<SetupStatus>('/setup');
return response.data;
};
/**
* Performs initial application setup with admin user creation.
* @param data - SetupRequest with admin user details
* @throws {AxiosError} If setup fails or already completed
*/
export const performSetup = async (data: SetupRequest): Promise<void> => {
await client.post('/setup', data);
};

View File

@@ -1,76 +0,0 @@
import client from './client'
/** SMTP server configuration. */
export interface SMTPConfig {
host: string
port: number
username: string
password: string
from_address: string
encryption: 'none' | 'ssl' | 'starttls'
configured: boolean
}
/** Request payload for SMTP configuration. */
export interface SMTPConfigRequest {
host: string
port: number
username: string
password: string
from_address: string
encryption: 'none' | 'ssl' | 'starttls'
}
/** Request payload for sending a test email. */
export interface TestEmailRequest {
to: string
}
/** Result of an SMTP test operation. */
export interface SMTPTestResult {
success: boolean
message?: string
error?: string
}
/**
* Fetches the current SMTP configuration.
* @returns Promise resolving to SMTPConfig
* @throws {AxiosError} If the request fails
*/
export const getSMTPConfig = async (): Promise<SMTPConfig> => {
const response = await client.get<SMTPConfig>('/settings/smtp')
return response.data
}
/**
* Updates the SMTP configuration.
* @param config - SMTPConfigRequest with new settings
* @returns Promise resolving to success message
* @throws {AxiosError} If update fails
*/
export const updateSMTPConfig = async (config: SMTPConfigRequest): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>('/settings/smtp', config)
return response.data
}
/**
* Tests the SMTP connection with current settings.
* @returns Promise resolving to SMTPTestResult
* @throws {AxiosError} If test request fails
*/
export const testSMTPConnection = async (): Promise<SMTPTestResult> => {
const response = await client.post<SMTPTestResult>('/settings/smtp/test')
return response.data
}
/**
* Sends a test email to verify SMTP configuration.
* @param request - TestEmailRequest with recipient address
* @returns Promise resolving to SMTPTestResult
* @throws {AxiosError} If sending fails
*/
export const sendTestEmail = async (request: TestEmailRequest): Promise<SMTPTestResult> => {
const response = await client.post<SMTPTestResult>('/settings/smtp/test-email', request)
return response.data
}

View File

@@ -1,72 +0,0 @@
import client from './client';
/** Update availability information. */
export interface UpdateInfo {
available: boolean;
latest_version: string;
changelog_url: string;
}
/** System notification entry. */
export interface Notification {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
title: string;
message: string;
read: boolean;
created_at: string;
}
/**
* Checks for available application updates.
* @returns Promise resolving to UpdateInfo
* @throws {AxiosError} If the request fails
*/
export const checkUpdates = async (): Promise<UpdateInfo> => {
const response = await client.get('/system/updates');
return response.data;
};
/**
* Fetches system notifications.
* @param unreadOnly - If true, only returns unread notifications
* @returns Promise resolving to array of Notification objects
* @throws {AxiosError} If the request fails
*/
export const getNotifications = async (unreadOnly = false): Promise<Notification[]> => {
const response = await client.get('/notifications', { params: { unread: unreadOnly } });
return response.data;
};
/**
* Marks a notification as read.
* @param id - The notification ID to mark as read
* @throws {AxiosError} If marking fails or notification not found
*/
export const markNotificationRead = async (id: string): Promise<void> => {
await client.post(`/notifications/${id}/read`);
};
/**
* Marks all notifications as read.
* @throws {AxiosError} If the request fails
*/
export const markAllNotificationsRead = async (): Promise<void> => {
await client.post('/notifications/read-all');
};
/** Response containing the client's public IP address. */
export interface MyIPResponse {
ip: string;
source: string;
}
/**
* Gets the client's public IP address as seen by the server.
* @returns Promise resolving to MyIPResponse with IP address
* @throws {AxiosError} If the request fails
*/
export const getMyIP = async (): Promise<MyIPResponse> => {
const response = await client.get<MyIPResponse>('/system/my-ip');
return response.data;
};

View File

@@ -1,112 +0,0 @@
import client from './client';
/** Uptime monitor configuration. */
export interface UptimeMonitor {
id: string;
upstream_host?: string;
proxy_host_id?: number;
remote_server_id?: number;
name: string;
type: string;
url: string;
interval: number;
enabled: boolean;
status: string;
last_check?: string | null;
latency: number;
max_retries: number;
}
/** Uptime heartbeat (check result) entry. */
export interface UptimeHeartbeat {
id: number;
monitor_id: string;
status: string;
latency: number;
message: string;
created_at: string;
}
/**
* Fetches all uptime monitors.
* @returns Promise resolving to array of UptimeMonitor objects
* @throws {AxiosError} If the request fails
*/
export const getMonitors = async () => {
const response = await client.get<UptimeMonitor[]>('/uptime/monitors');
return response.data;
};
/**
* Fetches heartbeat history for a monitor.
* @param id - The monitor ID
* @param limit - Maximum number of heartbeats to return (default: 50)
* @returns Promise resolving to array of UptimeHeartbeat objects
* @throws {AxiosError} If the request fails or monitor not found
*/
export const getMonitorHistory = async (id: string, limit: number = 50) => {
const response = await client.get<UptimeHeartbeat[]>(`/uptime/monitors/${id}/history?limit=${limit}`);
return response.data;
};
/**
* Updates an uptime monitor configuration.
* @param id - The monitor ID to update
* @param data - Partial UptimeMonitor with fields to update
* @returns Promise resolving to the updated UptimeMonitor
* @throws {AxiosError} If update fails or monitor not found
*/
export const updateMonitor = async (id: string, data: Partial<UptimeMonitor>) => {
const response = await client.put<UptimeMonitor>(`/uptime/monitors/${id}`, data);
return response.data;
};
/**
* Deletes an uptime monitor.
* @param id - The monitor ID to delete
* @returns Promise resolving to void
* @throws {AxiosError} If deletion fails or monitor not found
*/
export const deleteMonitor = async (id: string) => {
const response = await client.delete<void>(`/uptime/monitors/${id}`);
return response.data;
};
/**
* Creates a new uptime monitor.
* @param data - Monitor configuration (name, url, type, interval, max_retries)
* @returns Promise resolving to the created UptimeMonitor
* @throws {AxiosError} If creation fails
*/
export const createMonitor = async (data: {
name: string;
url: string;
type: string;
interval?: number;
max_retries?: number;
}): Promise<UptimeMonitor> => {
const response = await client.post<UptimeMonitor>('/uptime/monitors', data);
return response.data;
};
/**
* Syncs monitors with proxy hosts and remote servers.
* @param body - Optional configuration for sync (interval, max_retries)
* @returns Promise resolving to sync result with message
* @throws {AxiosError} If sync fails
*/
export async function syncMonitors(body?: { interval?: number; max_retries?: number }): Promise<{ message: string }> {
const res = await client.post<{ message: string }>('/uptime/sync', body || {});
return res.data;
}
/**
* Triggers an immediate check for a monitor.
* @param id - The monitor ID to check
* @returns Promise resolving to object with result message
* @throws {AxiosError} If check fails or monitor not found
*/
export const checkMonitor = async (id: string) => {
const response = await client.post<{ message: string }>(`/uptime/monitors/${id}/check`);
return response.data;
};

View File

@@ -1,41 +0,0 @@
import client from './client'
/** Current user profile information. */
export interface UserProfile {
id: number
email: string
name: string
role: string
api_key: string
}
/**
* Fetches the current user's profile.
* @returns Promise resolving to UserProfile
* @throws {AxiosError} If the request fails or not authenticated
*/
export const getProfile = async (): Promise<UserProfile> => {
const response = await client.get('/user/profile')
return response.data
}
/**
* Regenerates the current user's API key.
* @returns Promise resolving to object containing the new API key
* @throws {AxiosError} If regeneration fails
*/
export const regenerateApiKey = async (): Promise<{ api_key: string }> => {
const response = await client.post('/user/api-key')
return response.data
}
/**
* Updates the current user's profile.
* @param data - Object with name, email, and optional current_password for verification
* @returns Promise resolving to success message
* @throws {AxiosError} If update fails or password verification fails
*/
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
const response = await client.post('/user/profile', data)
return response.data
}

View File

@@ -1,93 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from './client'
import {
listUsers,
getUser,
createUser,
inviteUser,
updateUser,
deleteUser,
updateUserPermissions,
validateInvite,
acceptInvite,
} from './users'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
describe('users api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('lists and fetches users', async () => {
mockedClient.get
.mockResolvedValueOnce({ data: [{ id: 1, uuid: 'u1', email: 'a@example.com', name: 'A', role: 'admin', enabled: true, permission_mode: 'allow_all', created_at: '', updated_at: '' }] })
.mockResolvedValueOnce({ data: { id: 2, uuid: 'u2', email: 'b@example.com', name: 'B', role: 'user', enabled: true, permission_mode: 'allow_all', created_at: '', updated_at: '' } })
const users = await listUsers()
expect(mockedClient.get).toHaveBeenCalledWith('/users')
expect(users[0].email).toBe('a@example.com')
const user = await getUser(2)
expect(mockedClient.get).toHaveBeenCalledWith('/users/2')
expect(user.uuid).toBe('u2')
})
it('creates, invites, updates, and deletes users', async () => {
mockedClient.post
.mockResolvedValueOnce({ data: { id: 3, uuid: 'u3', email: 'c@example.com', name: 'C', role: 'user', enabled: true, permission_mode: 'allow_all', created_at: '', updated_at: '' } })
.mockResolvedValueOnce({ data: { id: 4, uuid: 'u4', email: 'invite@example.com', role: 'user', invite_token: 'token', email_sent: true, expires_at: '' } })
mockedClient.put.mockResolvedValueOnce({ data: { message: 'updated' } })
mockedClient.delete.mockResolvedValueOnce({ data: { message: 'deleted' } })
const created = await createUser({ email: 'c@example.com', name: 'C', password: 'pw' })
expect(mockedClient.post).toHaveBeenCalledWith('/users', { email: 'c@example.com', name: 'C', password: 'pw' })
expect(created.id).toBe(3)
const invite = await inviteUser({ email: 'invite@example.com', role: 'user' })
expect(mockedClient.post).toHaveBeenCalledWith('/users/invite', { email: 'invite@example.com', role: 'user' })
expect(invite.invite_token).toBe('token')
await updateUser(3, { enabled: false })
expect(mockedClient.put).toHaveBeenCalledWith('/users/3', { enabled: false })
await deleteUser(3)
expect(mockedClient.delete).toHaveBeenCalledWith('/users/3')
})
it('updates permissions and validates/accepts invites', async () => {
mockedClient.put.mockResolvedValueOnce({ data: { message: 'perms updated' } })
mockedClient.get.mockResolvedValueOnce({ data: { valid: true, email: 'invite@example.com' } })
mockedClient.post.mockResolvedValueOnce({ data: { message: 'accepted', email: 'invite@example.com' } })
const perms = await updateUserPermissions(5, { permission_mode: 'deny_all', permitted_hosts: [1, 2] })
expect(mockedClient.put).toHaveBeenCalledWith('/users/5/permissions', {
permission_mode: 'deny_all',
permitted_hosts: [1, 2],
})
expect(perms.message).toBe('perms updated')
const validation = await validateInvite('token-abc')
expect(mockedClient.get).toHaveBeenCalledWith('/invite/validate', { params: { token: 'token-abc' } })
expect(validation.valid).toBe(true)
const accept = await acceptInvite({ token: 'token-abc', name: 'New', password: 'pw' })
expect(mockedClient.post).toHaveBeenCalledWith('/invite/accept', { token: 'token-abc', name: 'New', password: 'pw' })
expect(accept.message).toBe('accepted')
})
})

View File

@@ -1,213 +0,0 @@
import client from './client'
/** User permission mode type. */
export type PermissionMode = 'allow_all' | 'deny_all'
/** User account information. */
export interface User {
id: number
uuid: string
email: string
name: string
role: 'admin' | 'user' | 'viewer'
enabled: boolean
last_login?: string
invite_status?: 'pending' | 'accepted' | 'expired'
invited_at?: string
permission_mode: PermissionMode
permitted_hosts?: number[]
created_at: string
updated_at: string
}
/** Request payload for creating a user. */
export interface CreateUserRequest {
email: string
name: string
password: string
role?: string
permission_mode?: PermissionMode
permitted_hosts?: number[]
}
/** Request payload for inviting a user. */
export interface InviteUserRequest {
email: string
role?: string
permission_mode?: PermissionMode
permitted_hosts?: number[]
}
/** Response from user invitation. */
export interface InviteUserResponse {
id: number
uuid: string
email: string
role: string
invite_token: string
email_sent: boolean
expires_at: string
}
/** Request payload for updating a user. */
export interface UpdateUserRequest {
name?: string
email?: string
role?: string
enabled?: boolean
}
/** Request payload for updating user permissions. */
export interface UpdateUserPermissionsRequest {
permission_mode: PermissionMode
permitted_hosts: number[]
}
/** Response from invite validation. */
export interface ValidateInviteResponse {
valid: boolean
email: string
}
/** Request payload for accepting an invitation. */
export interface AcceptInviteRequest {
token: string
name: string
password: string
}
/**
* Lists all users.
* @returns Promise resolving to array of User objects
* @throws {AxiosError} If the request fails
*/
export const listUsers = async (): Promise<User[]> => {
const response = await client.get<User[]>('/users')
return response.data
}
/**
* Fetches a single user by ID.
* @param id - The user ID
* @returns Promise resolving to the User object
* @throws {AxiosError} If the request fails or user not found
*/
export const getUser = async (id: number): Promise<User> => {
const response = await client.get<User>(`/users/${id}`)
return response.data
}
/**
* Creates a new user.
* @param data - CreateUserRequest with user details
* @returns Promise resolving to the created User
* @throws {AxiosError} If creation fails or email already exists
*/
export const createUser = async (data: CreateUserRequest): Promise<User> => {
const response = await client.post<User>('/users', data)
return response.data
}
/**
* Invites a new user via email.
* @param data - InviteUserRequest with invitation details
* @returns Promise resolving to InviteUserResponse with token
* @throws {AxiosError} If invitation fails
*/
export const inviteUser = async (data: InviteUserRequest): Promise<InviteUserResponse> => {
const response = await client.post<InviteUserResponse>('/users/invite', data)
return response.data
}
/**
* Updates an existing user.
* @param id - The user ID to update
* @param data - UpdateUserRequest with fields to update
* @returns Promise resolving to success message
* @throws {AxiosError} If update fails or user not found
*/
export const updateUser = async (id: number, data: UpdateUserRequest): Promise<{ message: string }> => {
const response = await client.put<{ message: string }>(`/users/${id}`, data)
return response.data
}
/**
* Deletes a user.
* @param id - The user ID to delete
* @returns Promise resolving to success message
* @throws {AxiosError} If deletion fails or user not found
*/
export const deleteUser = async (id: number): Promise<{ message: string }> => {
const response = await client.delete<{ message: string }>(`/users/${id}`)
return response.data
}
/**
* Updates a user's permissions.
* @param id - The user ID to update
* @param data - UpdateUserPermissionsRequest with new permissions
* @returns Promise resolving to success message
* @throws {AxiosError} If update fails or user not found
*/
export const updateUserPermissions = async (
id: number,
data: UpdateUserPermissionsRequest
): Promise<{ message: string }> => {
const response = await client.put<{ message: string }>(`/users/${id}/permissions`, data)
return response.data
}
// Public endpoints (no auth required)
/**
* Validates an invitation token.
* @param token - The invitation token to validate
* @returns Promise resolving to ValidateInviteResponse
* @throws {AxiosError} If validation fails
*/
export const validateInvite = async (token: string): Promise<ValidateInviteResponse> => {
const response = await client.get<ValidateInviteResponse>('/invite/validate', {
params: { token }
})
return response.data
}
/**
* Accepts an invitation and creates the user account.
* @param data - AcceptInviteRequest with token and user details
* @returns Promise resolving to success message and email
* @throws {AxiosError} If acceptance fails or token invalid/expired
*/
export const acceptInvite = async (data: AcceptInviteRequest): Promise<{ message: string; email: string }> => {
const response = await client.post<{ message: string; email: string }>('/invite/accept', data)
return response.data
}
/** Response from invite URL preview. */
export interface PreviewInviteURLResponse {
preview_url: string
base_url: string
is_configured: boolean
email: string
warning: boolean
warning_message: string
}
/**
* Previews what the invite URL will look like for a given email.
* @param email - The email to preview
* @returns Promise resolving to PreviewInviteURLResponse
*/
export const previewInviteURL = async (email: string): Promise<PreviewInviteURLResponse> => {
const response = await client.post<PreviewInviteURLResponse>('/users/preview-invite-url', { email })
return response.data
}
/**
* Resends an invitation email to a pending user.
* @param id - The user ID to resend invite to
* @returns Promise resolving to InviteUserResponse with new token
*/
export const resendInvite = async (id: number): Promise<InviteUserResponse> => {
const response = await client.post<InviteUserResponse>(`/users/${id}/resend-invite`)
return response.data
}

View File

@@ -1,47 +0,0 @@
import client from './client';
/** Information about a WebSocket connection. */
export interface ConnectionInfo {
id: string;
type: 'logs' | 'cerberus';
connected_at: string;
last_activity_at: string;
remote_addr?: string;
user_agent?: string;
filters?: string;
}
/** Aggregate statistics for WebSocket connections. */
export interface ConnectionStats {
total_active: number;
logs_connections: number;
cerberus_connections: number;
oldest_connection?: string;
last_updated: string;
}
/** Response containing WebSocket connections list. */
export interface ConnectionsResponse {
connections: ConnectionInfo[];
count: number;
}
/**
* Gets all active WebSocket connections.
* @returns Promise resolving to ConnectionsResponse with connections list
* @throws {AxiosError} If the request fails
*/
export const getWebSocketConnections = async (): Promise<ConnectionsResponse> => {
const response = await client.get('/websocket/connections');
return response.data;
};
/**
* Gets aggregate WebSocket connection statistics.
* @returns Promise resolving to ConnectionStats
* @throws {AxiosError} If the request fails
*/
export const getWebSocketStats = async (): Promise<ConnectionStats> => {
const response = await client.get('/websocket/stats');
return response.data;
};

View File

@@ -1,555 +0,0 @@
import { useState } from 'react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { Switch } from './ui/Switch';
import { X, Plus, ExternalLink, Shield, AlertTriangle, Info, Download, Trash2 } from 'lucide-react';
import type { AccessList, AccessListRule } from '../api/accessLists';
import { SECURITY_PRESETS, calculateTotalIPs, formatIPCount, type SecurityPreset } from '../data/securityPresets';
import { getMyIP } from '../api/system';
import toast from 'react-hot-toast';
interface AccessListFormProps {
initialData?: AccessList;
onSubmit: (data: AccessListFormData) => void;
onCancel: () => void;
onDelete?: () => void;
isLoading?: boolean;
isDeleting?: boolean;
}
export interface AccessListFormData {
name: string;
description: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules: string;
country_codes: string;
local_network_only: boolean;
enabled: boolean;
}
const COUNTRIES = [
{ code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' },
{ code: 'ES', name: 'Spain' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'BE', name: 'Belgium' },
{ code: 'SE', name: 'Sweden' },
{ code: 'NO', name: 'Norway' },
{ code: 'DK', name: 'Denmark' },
{ code: 'FI', name: 'Finland' },
{ code: 'PL', name: 'Poland' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'AT', name: 'Austria' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'AU', name: 'Australia' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'JP', name: 'Japan' },
{ code: 'CN', name: 'China' },
{ code: 'IN', name: 'India' },
{ code: 'BR', name: 'Brazil' },
{ code: 'MX', name: 'Mexico' },
{ code: 'AR', name: 'Argentina' },
{ code: 'RU', name: 'Russia' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'TR', name: 'Turkey' },
{ code: 'IL', name: 'Israel' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'EG', name: 'Egypt' },
{ code: 'ZA', name: 'South Africa' },
{ code: 'KR', name: 'South Korea' },
{ code: 'SG', name: 'Singapore' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'TH', name: 'Thailand' },
{ code: 'ID', name: 'Indonesia' },
{ code: 'PH', name: 'Philippines' },
{ code: 'VN', name: 'Vietnam' },
];
export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLoading, isDeleting }: AccessListFormProps) {
const [formData, setFormData] = useState<AccessListFormData>({
name: initialData?.name || '',
description: initialData?.description || '',
type: initialData?.type || 'whitelist',
ip_rules: initialData?.ip_rules || '',
country_codes: initialData?.country_codes || '',
local_network_only: initialData?.local_network_only || false,
enabled: initialData?.enabled ?? true,
});
const [ipRules, setIPRules] = useState<AccessListRule[]>(() => {
if (initialData?.ip_rules) {
try {
return JSON.parse(initialData.ip_rules);
} catch {
return [];
}
}
return [];
});
const [selectedCountries, setSelectedCountries] = useState<string[]>(() => {
if (initialData?.country_codes) {
return initialData.country_codes.split(',').map((c) => c.trim());
}
return [];
});
const [newIP, setNewIP] = useState('');
const [newIPDescription, setNewIPDescription] = useState('');
const [showPresets, setShowPresets] = useState(false);
const [loadingMyIP, setLoadingMyIP] = useState(false);
const isGeoType = formData.type.startsWith('geo_');
const isIPType = !isGeoType;
// Calculate total IPs in current rules
const totalIPs = isIPType && !formData.local_network_only
? calculateTotalIPs(ipRules.map(r => r.cidr))
: 0;
const handleAddIP = () => {
if (!newIP.trim()) return;
const newRule: AccessListRule = {
cidr: newIP.trim(),
description: newIPDescription.trim(),
};
const updatedRules = [...ipRules, newRule];
setIPRules(updatedRules);
setNewIP('');
setNewIPDescription('');
};
const handleRemoveIP = (index: number) => {
setIPRules(ipRules.filter((_, i) => i !== index));
};
const handleAddCountry = (countryCode: string) => {
if (!selectedCountries.includes(countryCode)) {
setSelectedCountries([...selectedCountries, countryCode]);
}
};
const handleRemoveCountry = (countryCode: string) => {
setSelectedCountries(selectedCountries.filter((c) => c !== countryCode));
};
const handleApplyPreset = (preset: SecurityPreset) => {
if (preset.type === 'geo_blacklist' && preset.countryCodes) {
setFormData({ ...formData, type: 'geo_blacklist' });
setSelectedCountries([...new Set([...selectedCountries, ...preset.countryCodes])]);
toast.success(`Applied preset: ${preset.name}`);
} else if (preset.type === 'blacklist' && preset.ipRanges) {
setFormData({ ...formData, type: 'blacklist' });
const newRules = preset.ipRanges.filter(
(newRule) => !ipRules.some((existing) => existing.cidr === newRule.cidr)
);
setIPRules([...ipRules, ...newRules]);
toast.success(`Applied preset: ${preset.name} (${newRules.length} rules added)`);
}
setShowPresets(false);
};
const handleGetMyIP = async () => {
setLoadingMyIP(true);
try {
const result = await getMyIP();
setNewIP(result.ip);
toast.success(`Your IP: ${result.ip} (from ${result.source})`);
} catch {
toast.error('Failed to fetch your IP address');
} finally {
setLoadingMyIP(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: AccessListFormData = {
...formData,
ip_rules: isIPType && !formData.local_network_only ? JSON.stringify(ipRules) : '',
country_codes: isGeoType ? selectedCountries.join(',') : '',
};
onSubmit(data);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Name *
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Access List"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description"
rows={2}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-300 mb-2">
Type *
<a
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-blue-400 hover:text-blue-300 text-xs"
>
<ExternalLink className="inline h-3 w-3" /> Best Practices
</a>
</label>
<select
id="type"
value={formData.type}
onChange={(e) =>
setFormData({ ...formData, type: e.target.value as 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist', local_network_only: false })
}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="whitelist">🛡 IP Whitelist (Allow Only)</option>
<option value="blacklist"><EFBFBD> IP Blacklist (Block Only) - Recommended</option>
<option value="geo_whitelist">🌍 Geo Whitelist (Allow Countries)</option>
<option value="geo_blacklist">🌍 Geo Blacklist (Block Countries) - Recommended</option>
</select>
{(formData.type === 'blacklist' || formData.type === 'geo_blacklist') && (
<div className="mt-2 flex items-start gap-2 p-3 bg-blue-900/20 border border-blue-700/50 rounded-lg">
<Info className="h-4 w-4 text-blue-400 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-300">
<strong>Recommended:</strong> Block lists are safer than allow lists. They block known bad actors while allowing everyone else access, preventing lockouts.
</p>
</div>
)}
</div>
{/* Security Presets */}
{(formData.type === 'blacklist' || formData.type === 'geo_blacklist') && (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-green-400" />
<h3 className="text-sm font-medium text-gray-300">Security Presets</h3>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setShowPresets(!showPresets)}
>
{showPresets ? 'Hide' : 'Show'} Presets
</Button>
</div>
{showPresets && (
<div className="space-y-3 mt-4">
<p className="text-xs text-gray-400 mb-3">
Quick-start templates based on threat intelligence feeds and best practices. Hover over (i) for data sources.
</p>
{/* Security Category - filter by current type */}
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">Recommended Security Presets</h4>
<div className="space-y-2">
{SECURITY_PRESETS.filter(p => p.category === 'security' && p.type === formData.type).map((preset) => (
<div
key={preset.id}
className="bg-gray-900 border border-gray-700 rounded-lg p-3 hover:border-gray-600 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="text-sm font-medium text-white">{preset.name}</h5>
<a
href={preset.dataSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-400"
title={`Data from: ${preset.dataSource}`}
>
<Info className="h-3 w-3" />
</a>
</div>
<p className="text-xs text-gray-400 mb-2">{preset.description}</p>
<div className="flex items-center gap-3 text-xs">
<span className="text-gray-500">~{preset.estimatedIPs} IPs</span>
<span className="text-gray-600">|</span>
<span className="text-gray-500">{preset.dataSource}</span>
</div>
{preset.warning && (
<div className="flex items-start gap-1 mt-2 text-xs text-orange-400">
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{preset.warning}</span>
</div>
)}
</div>
<Button
type="button"
size="sm"
onClick={() => handleApplyPreset(preset)}
className="ml-3"
>
Apply
</Button>
</div>
</div>
))}
</div>
</div>
{/* Advanced Category - filter by current type */}
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">Advanced Presets</h4>
<div className="space-y-2">
{SECURITY_PRESETS.filter(p => p.category === 'advanced' && p.type === formData.type).map((preset) => (
<div
key={preset.id}
className="bg-gray-900 border border-gray-700 rounded-lg p-3 hover:border-gray-600 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="text-sm font-medium text-white">{preset.name}</h5>
<a
href={preset.dataSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-400"
title={`Data from: ${preset.dataSource}`}
>
<Info className="h-3 w-3" />
</a>
</div>
<p className="text-xs text-gray-400 mb-2">{preset.description}</p>
<div className="flex items-center gap-3 text-xs">
<span className="text-gray-500">~{preset.estimatedIPs} IPs</span>
<span className="text-gray-600">|</span>
<span className="text-gray-500">{preset.dataSource}</span>
</div>
{preset.warning && (
<div className="flex items-start gap-1 mt-2 text-xs text-orange-400">
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{preset.warning}</span>
</div>
)}
</div>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => handleApplyPreset(preset)}
className="ml-3"
>
Apply
</Button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-300">Enabled</label>
<p className="text-xs text-gray-500">Apply this access list to hosts</p>
</div>
<Switch
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
</div>
</div>
{/* IP-based Rules */}
{isIPType && (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-300">Local Network Only (RFC1918)</label>
<p className="text-xs text-gray-500">
Allow only private network IPs (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
</p>
</div>
<Switch
checked={formData.local_network_only}
onCheckedChange={(checked) =>
setFormData({ ...formData, local_network_only: checked })
}
/>
</div>
{!formData.local_network_only && (
<>
<div className="mb-2 text-xs text-gray-500">
Note: IP-based blocklists (botnets, cloud scanners, VPN ranges) are better handled by CrowdSec, WAF, or rate limiting. Use IP-based ACLs sparingly for static or known ranges.
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-300">IP Addresses / CIDR Ranges</label>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleGetMyIP}
disabled={loadingMyIP}
className="flex items-center gap-1"
>
<Download className="h-3 w-3" />
{loadingMyIP ? 'Loading...' : 'Get My IP'}
</Button>
</div>
<div className="flex gap-2">
<Input
value={newIP}
onChange={(e) => setNewIP(e.target.value)}
placeholder="192.168.1.0/24 or 10.0.0.1"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddIP())}
/>
<Input
value={newIPDescription}
onChange={(e) => setNewIPDescription(e.target.value)}
placeholder="Description (optional)"
className="flex-1"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddIP())}
/>
<Button type="button" onClick={handleAddIP} size="sm">
<Plus className="h-4 w-4" />
</Button>
</div>
{totalIPs > 0 && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<Info className="h-3 w-3" />
<span>Current rules cover approximately <strong className="text-white">{formatIPCount(totalIPs)}</strong> IP addresses</span>
</div>
)}
</div>
{ipRules.length > 0 && (
<div className="space-y-2">
{ipRules.map((rule, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg border border-gray-600 bg-gray-700"
>
<div>
<p className="font-mono text-sm text-white">{rule.cidr}</p>
{rule.description && (
<p className="text-xs text-gray-400">{rule.description}</p>
)}
</div>
<button
type="button"
onClick={() => handleRemoveIP(index)}
className="text-gray-400 hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</>
)}
</div>
)}
{/* Geo-blocking Rules */}
{isGeoType && (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Select Countries</label>
<select
onChange={(e) => {
if (e.target.value) {
handleAddCountry(e.target.value);
e.target.value = '';
}
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Add a country...</option>
{COUNTRIES.filter((c) => !selectedCountries.includes(c.code)).map((country) => (
<option key={country.code} value={country.code}>
{country.name} ({country.code})
</option>
))}
</select>
</div>
{selectedCountries.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedCountries.map((code) => {
const country = COUNTRIES.find((c) => c.code === code);
return (
<span
key={code}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-gray-700 text-gray-200 border border-gray-600"
>
{country?.name || code}
<X
className="h-3 w-3 cursor-pointer hover:text-red-400"
onClick={() => handleRemoveCountry(code)}
/>
</span>
);
})}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex justify-between gap-2">
<div>
{initialData && onDelete && (
<Button
type="button"
variant="danger"
onClick={onDelete}
disabled={isLoading || isDeleting}
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={onCancel} disabled={isLoading || isDeleting}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={isLoading || isDeleting}>
{isLoading ? 'Saving...' : initialData ? 'Update' : 'Create'}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -1,77 +0,0 @@
import { useAccessLists } from '../hooks/useAccessLists';
import { ExternalLink } from 'lucide-react';
interface AccessListSelectorProps {
value: number | null;
onChange: (id: number | null) => void;
}
export default function AccessListSelector({ value, onChange }: AccessListSelectorProps) {
const { data: accessLists } = useAccessLists();
const selectedACL = accessLists?.find((acl) => acl.id === value);
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Access Control List
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
</label>
<select
value={value || 0}
onChange={(e) => onChange(parseInt(e.target.value) || null)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>No Access Control (Public)</option>
{accessLists
?.filter((acl) => acl.enabled)
.map((acl) => (
<option key={acl.id} value={acl.id}>
{acl.name} ({acl.type.replace('_', ' ')})
</option>
))}
</select>
{selectedACL && (
<div className="mt-2 p-3 bg-gray-800 border border-gray-700 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-200">{selectedACL.name}</span>
<span className="px-2 py-0.5 text-xs bg-gray-700 border border-gray-600 rounded">
{selectedACL.type.replace('_', ' ')}
</span>
</div>
{selectedACL.description && (
<p className="text-xs text-gray-400 mb-2">{selectedACL.description}</p>
)}
{selectedACL.local_network_only && (
<div className="text-xs text-blue-400">
🏠 Local Network Only (RFC1918)
</div>
)}
{selectedACL.type.startsWith('geo_') && selectedACL.country_codes && (
<div className="text-xs text-gray-400">
🌍 Countries: {selectedACL.country_codes}
</div>
)}
</div>
)}
<p className="text-xs text-gray-500 mt-1">
Restrict access based on IP address, CIDR ranges, or geographic location.{' '}
<a href="/security/access-lists" className="text-blue-400 hover:underline">
Manage lists
</a>
{' • '}
<a
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="inline h-3 w-3" />
Best Practices
</a>
</p>
</div>
);
}

View File

@@ -1,332 +0,0 @@
import { useState, useEffect } from 'react';
import { Plus, X, AlertCircle, Check, Code } from 'lucide-react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { NativeSelect } from './ui/NativeSelect';
import { Card } from './ui/Card';
import { Badge } from './ui/Badge';
import { Alert } from './ui/Alert';
import type { CSPDirective } from '../api/securityHeaders';
interface CSPBuilderProps {
value: string; // JSON string of CSPDirective[]
onChange: (value: string) => void;
onValidate?: (valid: boolean, errors: string[]) => void;
}
const CSP_DIRECTIVES = [
'default-src',
'script-src',
'style-src',
'img-src',
'font-src',
'connect-src',
'frame-src',
'object-src',
'media-src',
'worker-src',
'form-action',
'base-uri',
'frame-ancestors',
'manifest-src',
'prefetch-src',
];
const CSP_VALUES = [
"'self'",
"'none'",
"'unsafe-inline'",
"'unsafe-eval'",
'data:',
'https:',
'http:',
'blob:',
'filesystem:',
"'strict-dynamic'",
"'report-sample'",
"'unsafe-hashes'",
];
const CSP_PRESETS: Record<string, CSPDirective[]> = {
'Strict Default': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'"] },
{ directive: 'style-src', values: ["'self'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:'] },
{ directive: 'font-src', values: ["'self'", 'data:'] },
{ directive: 'connect-src', values: ["'self'"] },
{ directive: 'frame-src', values: ["'none'"] },
{ directive: 'object-src', values: ["'none'"] },
],
'Allow Inline Styles': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'"] },
{ directive: 'style-src', values: ["'self'", "'unsafe-inline'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:'] },
{ directive: 'font-src', values: ["'self'", 'data:'] },
],
'Development Mode': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'", "'unsafe-inline'", "'unsafe-eval'"] },
{ directive: 'style-src', values: ["'self'", "'unsafe-inline'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:', 'http:'] },
],
};
export function CSPBuilder({ value, onChange, onValidate }: CSPBuilderProps) {
const [directives, setDirectives] = useState<CSPDirective[]>([]);
const [newDirective, setNewDirective] = useState('default-src');
const [newValue, setNewValue] = useState('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [showPreview, setShowPreview] = useState(false);
// Parse initial value
useEffect(() => {
try {
if (value) {
const parsed = JSON.parse(value) as CSPDirective[];
setDirectives(parsed);
} else {
setDirectives([]);
}
} catch {
setDirectives([]);
}
}, [value]);
// Generate CSP string preview
const generateCSPString = (dirs: CSPDirective[]): string => {
return dirs
.map((dir) => `${dir.directive} ${dir.values.join(' ')}`)
.join('; ');
};
const cspString = generateCSPString(directives);
// Update parent component
const updateDirectives = (newDirectives: CSPDirective[]) => {
setDirectives(newDirectives);
onChange(JSON.stringify(newDirectives));
validateCSP(newDirectives);
};
const validateCSP = (dirs: CSPDirective[]) => {
const errors: string[] = [];
// Check for duplicate directives
const directiveNames = dirs.map((d) => d.directive);
const duplicates = directiveNames.filter((name, index) => directiveNames.indexOf(name) !== index);
if (duplicates.length > 0) {
errors.push(`Duplicate directives found: ${duplicates.join(', ')}`);
}
// Check for dangerous combinations
const hasUnsafeInline = dirs.some((d) =>
d.values.some((v) => v === "'unsafe-inline'" || v === "'unsafe-eval'")
);
if (hasUnsafeInline) {
errors.push('Using unsafe-inline or unsafe-eval weakens CSP protection');
}
// Check if default-src is set
const hasDefaultSrc = dirs.some((d) => d.directive === 'default-src');
if (!hasDefaultSrc && dirs.length > 0) {
errors.push('Consider setting default-src as a fallback for all directives');
}
setValidationErrors(errors);
onValidate?.(errors.length === 0, errors);
};
const handleAddDirective = () => {
if (!newValue.trim()) return;
const existingIndex = directives.findIndex((d) => d.directive === newDirective);
let updated: CSPDirective[];
if (existingIndex >= 0) {
// Add to existing directive
const existing = directives[existingIndex];
if (!existing.values.includes(newValue.trim())) {
const updatedDirective = {
...existing,
values: [...existing.values, newValue.trim()],
};
updated = [
...directives.slice(0, existingIndex),
updatedDirective,
...directives.slice(existingIndex + 1),
];
} else {
return; // Value already exists
}
} else {
// Create new directive
updated = [...directives, { directive: newDirective, values: [newValue.trim()] }];
}
updateDirectives(updated);
setNewValue('');
};
const handleRemoveDirective = (directive: string) => {
updateDirectives(directives.filter((d) => d.directive !== directive));
};
const handleRemoveValue = (directive: string, value: string) => {
updateDirectives(
directives.map((d) =>
d.directive === directive
? { ...d, values: d.values.filter((v) => v !== value) }
: d
).filter((d) => d.values.length > 0)
);
};
const handleApplyPreset = (presetName: string) => {
const preset = CSP_PRESETS[presetName];
if (preset) {
updateDirectives(preset);
}
};
return (
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Content Security Policy Builder</h3>
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
<Code className="w-4 h-4 mr-2" />
{showPreview ? 'Hide' : 'Show'} Preview
</Button>
</div>
{/* Preset Buttons */}
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400 self-center">Quick Presets:</span>
{Object.keys(CSP_PRESETS).map((presetName) => (
<Button
key={presetName}
variant="outline"
size="sm"
onClick={() => handleApplyPreset(presetName)}
>
{presetName}
</Button>
))}
</div>
{/* Add Directive Form */}
<div className="flex gap-2">
<NativeSelect
value={newDirective}
onChange={(e) => setNewDirective(e.target.value)}
className="w-48"
>
{CSP_DIRECTIVES.map((dir) => (
<option key={dir} value={dir}>
{dir}
</option>
))}
</NativeSelect>
<div className="flex-1 flex gap-2">
<Input
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddDirective()}
placeholder="Enter value or select from suggestions..."
list="csp-values"
/>
<datalist id="csp-values">
{CSP_VALUES.map((val) => (
<option key={val} value={val} />
))}
</datalist>
<Button onClick={handleAddDirective} disabled={!newValue.trim()}>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Current Directives */}
<div className="space-y-2">
{directives.length === 0 ? (
<Alert variant="info">
<AlertCircle className="w-4 h-4" />
<span>No CSP directives configured. Add directives above to build your policy.</span>
</Alert>
) : (
directives.map((dir) => (
<div key={dir.directive} className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{dir.directive}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveDirective(dir.directive)}
className="ml-auto"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1">
{dir.values.map((val) => (
<Badge
key={val}
variant="outline"
className="flex items-center gap-1 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => handleRemoveValue(dir.directive, val)}
>
<span className="font-mono text-xs">{val}</span>
<X className="w-3 h-3" />
</Badge>
))}
</div>
</div>
</div>
))
)}
</div>
{/* Validation Errors */}
{validationErrors.length > 0 && (
<Alert variant="warning">
<AlertCircle className="w-4 h-4" />
<div>
<p className="font-semibold mb-1">CSP Validation Warnings:</p>
<ul className="list-disc list-inside text-sm space-y-1">
{validationErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
</Alert>
)}
{validationErrors.length === 0 && directives.length > 0 && (
<Alert variant="success">
<Check className="w-4 h-4" />
<span>CSP configuration looks good!</span>
</Alert>
)}
{/* CSP String Preview */}
{showPreview && cspString && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Generated CSP Header:</label>
<pre className="p-3 bg-gray-900 dark:bg-gray-950 text-green-400 rounded-lg overflow-x-auto text-xs font-mono">
{cspString || '(empty)'}
</pre>
</div>
)}
</Card>
);
}

View File

@@ -1,207 +0,0 @@
import { useState, useMemo } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { useCertificates } from '../hooks/useCertificates'
import { deleteCertificate } from '../api/certificates'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { createBackup } from '../api/backups'
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
import { toast } from '../utils/toast'
type SortColumn = 'name' | 'expires'
type SortDirection = 'asc' | 'desc'
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
const { hosts } = useProxyHosts()
const queryClient = useQueryClient()
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const deleteMutation = useMutation({
// Perform backup before actual deletion
mutationFn: async (id: number) => {
await createBackup()
await deleteCertificate(id)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
toast.success('Certificate deleted')
},
onError: (error: Error) => {
toast.error(`Failed to delete certificate: ${error.message}`)
},
})
const sortedCertificates = useMemo(() => {
return [...certificates].sort((a, b) => {
let comparison = 0
switch (sortColumn) {
case 'name': {
const aName = (a.name || a.domain || '').toLowerCase()
const bName = (b.name || b.domain || '').toLowerCase()
comparison = aName.localeCompare(bName)
break
}
case 'expires': {
const aDate = new Date(a.expires_at).getTime()
const bDate = new Date(b.expires_at).getTime()
comparison = aDate - bDate
break
}
}
return sortDirection === 'asc' ? comparison : -comparison
})
}, [certificates, sortColumn, sortDirection])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('asc')
}
}
const SortIcon = ({ column }: { column: SortColumn }) => {
if (sortColumn !== column) return null
return sortDirection === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
if (isLoading) return <LoadingSpinner />
if (error) return <div className="text-red-500">Failed to load certificates</div>
return (
<>
{deleteMutation.isPending && (
<ConfigReloadOverlay
message="Returning to shore..."
submessage="Certificate departure in progress"
type="charon"
/>
)}
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
<tr>
<th
onClick={() => handleSort('name')}
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
>
<div className="flex items-center gap-1">
Name
<SortIcon column="name" />
</div>
</th>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Issuer</th>
<th
onClick={() => handleSort('expires')}
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
>
<div className="flex items-center gap-1">
Expires
<SortIcon column="expires" />
</div>
</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{certificates.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
No certificates found.
</td>
</tr>
) : (
sortedCertificates.map((cert) => (
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<span>{cert.issuer}</span>
{cert.issuer?.toLowerCase().includes('staging') && (
<span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 rounded">
STAGING
</span>
)}
</div>
</td>
<td className="px-6 py-4">
{new Date(cert.expires_at).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<StatusBadge status={cert.status} />
</td>
<td className="px-6 py-4">
{cert.id && (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) && (
<button
onClick={() => {
// Determine if certificate is in use by any proxy host
const inUse = hosts.some(h => {
const cid = h.certificate_id ?? h.certificate?.id
return cid === cert.id
})
if (inUse) {
toast.error('Certificate cannot be deleted because it is in use by a proxy host')
return
}
// Allow deletion for custom/staging certs not in use (status check removed)
const message = cert.provider === 'custom'
? 'Are you sure you want to delete this certificate? This will create a backup before deleting.'
: 'Delete this staging certificate? It will be regenerated on next request.'
if (confirm(message)) {
deleteMutation.mutate(cert.id!)
}
}}
className="text-red-400 hover:text-red-300 transition-colors"
title={cert.provider === 'custom' ? 'Delete Certificate' : 'Delete Staging Certificate'}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</>
)
}
function StatusBadge({ status }: { status: string }) {
const styles = {
valid: 'bg-green-900/30 text-green-400 border-green-800',
expiring: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
expired: 'bg-red-900/30 text-red-400 border-red-800',
untrusted: 'bg-orange-900/30 text-orange-400 border-orange-800',
}
const labels = {
valid: 'Valid',
expiring: 'Expiring Soon',
expired: 'Expired',
untrusted: 'Untrusted (Staging)',
}
const style = styles[status as keyof typeof styles] || styles.valid
const label = labels[status as keyof typeof labels] || status
return (
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
{label}
</span>
)
}

View File

@@ -1,143 +0,0 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { FileKey, Loader2 } from 'lucide-react'
import { Card, CardHeader, CardContent, Badge, Skeleton, Progress } from './ui'
import type { Certificate } from '../api/certificates'
import type { ProxyHost } from '../api/proxyHosts'
interface CertificateStatusCardProps {
certificates: Certificate[]
hosts: ProxyHost[]
isLoading?: boolean
}
export default function CertificateStatusCard({ certificates, hosts, isLoading }: CertificateStatusCardProps) {
const validCount = certificates.filter(c => c.status === 'valid').length
const expiringCount = certificates.filter(c => c.status === 'expiring').length
const untrustedCount = certificates.filter(c => c.status === 'untrusted').length
// Build a set of all domains that have certificates (case-insensitive)
// ACME certificates (Let's Encrypt) are auto-managed and don't set certificate_id,
// so we match by domain name instead
const certifiedDomains = useMemo(() => {
const domains = new Set<string>()
certificates.forEach(cert => {
// Handle missing or undefined domain field
if (!cert.domain) return
// Certificate domain field can be comma-separated
cert.domain.split(',').forEach(d => {
const trimmed = d.trim().toLowerCase()
if (trimmed) domains.add(trimmed)
})
})
return domains
}, [certificates])
// Calculate pending hosts: SSL-enabled hosts without any domain covered by a certificate
const { pendingCount, totalSSLHosts, hostsWithCerts } = useMemo(() => {
const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled)
let withCerts = 0
sslHosts.forEach(host => {
// Check if any of the host's domains have a certificate
const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase())
if (hostDomains.some(domain => certifiedDomains.has(domain))) {
withCerts++
}
})
return {
pendingCount: sslHosts.length - withCerts,
totalSSLHosts: sslHosts.length,
hostsWithCerts: withCerts,
}
}, [hosts, certifiedDomains])
const hasProvisioning = pendingCount > 0
const progressPercent = totalSSLHosts > 0
? Math.round((hostsWithCerts / totalSSLHosts) * 100)
: 100
if (isLoading) {
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-28" />
</div>
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-8 w-16" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
</div>
</CardContent>
</Card>
)
}
return (
<Link to="/certificates" className="block group">
<Card variant="interactive" className="h-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-brand-500/10 p-2 text-brand-500">
<FileKey className="h-5 w-5" />
</div>
<span className="text-sm font-medium text-content-secondary">SSL Certificates</span>
</div>
{hasProvisioning && (
<Badge variant="primary" size="sm" className="animate-pulse">
Provisioning
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-3xl font-bold text-content-primary tabular-nums">
{certificates.length}
</div>
{/* Status breakdown */}
<div className="flex flex-wrap gap-2">
{validCount > 0 && (
<Badge variant="success" size="sm">
{validCount} valid
</Badge>
)}
{expiringCount > 0 && (
<Badge variant="warning" size="sm">
{expiringCount} expiring
</Badge>
)}
{untrustedCount > 0 && (
<Badge variant="outline" size="sm">
{untrustedCount} staging
</Badge>
)}
{certificates.length === 0 && (
<Badge variant="outline" size="sm">
No certificates
</Badge>
)}
</div>
{/* Pending indicator */}
{hasProvisioning && (
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center gap-2 text-brand-400 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{pendingCount} host{pendingCount !== 1 ? 's' : ''} awaiting certificate</span>
</div>
<Progress value={progressPercent} variant="default" />
<div className="text-xs text-content-muted">{progressPercent}% provisioned</div>
</div>
)}
</CardContent>
</Card>
</Link>
)
}

View File

@@ -1,609 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Plus, Edit, Trash2, CheckCircle, XCircle, TestTube } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
Button,
Input,
Label,
Checkbox,
EmptyState,
} from './ui'
import {
useCredentials,
useCreateCredential,
useUpdateCredential,
useDeleteCredential,
useTestCredential,
type DNSProviderCredential,
type CredentialRequest,
} from '../hooks/useCredentials'
import type { DNSProvider, DNSProviderTypeInfo } from '../api/dnsProviders'
import { toast } from '../utils/toast'
interface CredentialManagerProps {
open: boolean
onOpenChange: (open: boolean) => void
provider: DNSProvider
providerTypeInfo?: DNSProviderTypeInfo
}
export default function CredentialManager({
open,
onOpenChange,
provider,
providerTypeInfo,
}: CredentialManagerProps) {
const { t } = useTranslation()
const { data: credentials = [], isLoading, refetch } = useCredentials(provider.id)
const deleteMutation = useDeleteCredential()
const testMutation = useTestCredential()
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingCredential, setEditingCredential] = useState<DNSProviderCredential | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const [testingId, setTestingId] = useState<number | null>(null)
const handleAddCredential = () => {
setEditingCredential(null)
setIsFormOpen(true)
}
const handleEditCredential = (credential: DNSProviderCredential) => {
setEditingCredential(credential)
setIsFormOpen(true)
}
const handleDeleteClick = (id: number) => {
setDeleteConfirm(id)
}
const handleDeleteConfirm = async (id: number) => {
try {
await deleteMutation.mutateAsync({ providerId: provider.id, credentialId: id })
toast.success(t('credentials.deleteSuccess', 'Credential deleted successfully'))
setDeleteConfirm(null)
refetch()
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
toast.error(
t('credentials.deleteFailed', 'Failed to delete credential') +
': ' +
(err.response?.data?.error || err.message)
)
}
}
const handleTestCredential = async (id: number) => {
setTestingId(id)
try {
const result = await testMutation.mutateAsync({
providerId: provider.id,
credentialId: id,
})
if (result.success) {
toast.success(result.message || t('credentials.testSuccess', 'Credential test passed'))
} else {
toast.error(result.error || t('credentials.testFailed', 'Credential test failed'))
}
refetch()
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
toast.error(
t('credentials.testFailed', 'Failed to test credential') +
': ' +
(err.response?.data?.error || err.message)
)
} finally {
setTestingId(null)
}
}
const handleFormSuccess = () => {
toast.success(
editingCredential
? t('credentials.updateSuccess', 'Credential updated successfully')
: t('credentials.createSuccess', 'Credential created successfully')
)
setIsFormOpen(false)
refetch()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t('credentials.manageTitle', 'Manage Credentials')}: {provider.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Add Button */}
<div className="flex justify-between items-center">
<Button onClick={handleAddCredential} size="sm">
<Plus className="w-4 h-4 mr-2" />
{t('credentials.addCredential', 'Add Credential')}
</Button>
</div>
{/* Loading State */}
{isLoading && (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading', 'Loading...')}
</div>
)}
{/* Empty State */}
{!isLoading && credentials.length === 0 && (
<EmptyState
icon={<CheckCircle className="w-10 h-10" />}
title={t('credentials.noCredentials', 'No credentials configured')}
description={t(
'credentials.noCredentialsDescription',
'Add credentials to enable zone-specific DNS challenge configuration'
)}
action={{
label: t('credentials.addFirst', 'Add First Credential'),
onClick: handleAddCredential,
}}
/>
)}
{/* Credentials Table */}
{!isLoading && credentials.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">
{t('credentials.label', 'Label')}
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
{t('credentials.zones', 'Zones')}
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
{t('credentials.status', 'Status')}
</th>
<th className="px-4 py-3 text-right text-sm font-medium">
{t('common.actions', 'Actions')}
</th>
</tr>
</thead>
<tbody className="divide-y">
{credentials.map((credential) => (
<tr key={credential.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<div className="font-medium">{credential.label}</div>
{!credential.enabled && (
<span className="text-xs text-muted-foreground">
{t('common.disabled', 'Disabled')}
</span>
)}
</td>
<td className="px-4 py-3 text-sm">
{credential.zone_filter || (
<span className="text-muted-foreground italic">
{t('credentials.allZones', 'All zones (catch-all)')}
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{credential.failure_count > 0 ? (
<XCircle className="w-4 h-4 text-destructive" />
) : (
<CheckCircle className="w-4 h-4 text-success" />
)}
<span className="text-sm">
{credential.success_count}/{credential.failure_count}
</span>
</div>
{credential.last_used_at && (
<div className="text-xs text-muted-foreground">
{t('credentials.lastUsed', 'Last used')}:{' '}
{new Date(credential.last_used_at).toLocaleString()}
</div>
)}
{credential.last_error && (
<div className="text-xs text-destructive mt-1">
{credential.last_error}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleTestCredential(credential.id)}
disabled={testingId === credential.id}
>
<TestTube className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditCredential(credential)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteClick(credential.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close', 'Close')}
</Button>
</DialogFooter>
</DialogContent>
{/* Credential Form Dialog */}
{isFormOpen && (
<CredentialForm
open={isFormOpen}
onOpenChange={setIsFormOpen}
providerId={provider.id}
providerTypeInfo={providerTypeInfo}
credential={editingCredential}
onSuccess={handleFormSuccess}
/>
)}
{/* Delete Confirmation Dialog */}
{deleteConfirm !== null && (
<Dialog open={deleteConfirm !== null} onOpenChange={() => setDeleteConfirm(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('credentials.deleteConfirm', 'Delete Credential?')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t(
'credentials.deleteWarning',
'Are you sure you want to delete this credential? This action cannot be undone.'
)}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="danger"
onClick={() => handleDeleteConfirm(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{t('common.delete', 'Delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Dialog>
)
}
interface CredentialFormProps {
open: boolean
onOpenChange: (open: boolean) => void
providerId: number
providerTypeInfo?: DNSProviderTypeInfo
credential: DNSProviderCredential | null
onSuccess: () => void
}
function CredentialForm({
open,
onOpenChange,
providerId,
providerTypeInfo,
credential,
onSuccess,
}: CredentialFormProps) {
const { t } = useTranslation()
const createMutation = useCreateCredential()
const updateMutation = useUpdateCredential()
const testMutation = useTestCredential()
const [label, setLabel] = useState('')
const [zoneFilter, setZoneFilter] = useState('')
const [credentials, setCredentials] = useState<Record<string, string>>({})
const [propagationTimeout, setPropagationTimeout] = useState(120)
const [pollingInterval, setPollingInterval] = useState(5)
const [enabled, setEnabled] = useState(true)
const [errors, setErrors] = useState<Record<string, string>>({})
useEffect(() => {
if (credential) {
setLabel(credential.label)
setZoneFilter(credential.zone_filter)
setPropagationTimeout(credential.propagation_timeout)
setPollingInterval(credential.polling_interval)
setEnabled(credential.enabled)
setCredentials({}) // Don't pre-fill credentials (they're encrypted)
} else {
resetForm()
}
}, [credential, open])
const resetForm = () => {
setLabel('')
setZoneFilter('')
setCredentials({})
setPropagationTimeout(120)
setPollingInterval(5)
setEnabled(true)
setErrors({})
}
const validateZoneFilter = (value: string): boolean => {
if (!value) return true // Empty is valid (catch-all)
const zones = value.split(',').map((z) => z.trim())
for (const zone of zones) {
// Basic domain validation
if (zone && !/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(zone)) {
setErrors((prev) => ({
...prev,
zone_filter: t('credentials.invalidZone', 'Invalid domain format: ') + zone,
}))
return false
}
}
setErrors((prev) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { zone_filter: _, ...rest } = prev
return rest
})
return true
}
const handleCredentialChange = (fieldName: string, value: string) => {
setCredentials((prev) => ({ ...prev, [fieldName]: value }))
}
const handleSubmit = async () => {
// Validate
if (!label.trim()) {
setErrors({ label: t('credentials.labelRequired', 'Label is required') })
return
}
if (!validateZoneFilter(zoneFilter)) {
return
}
// Check required credential fields
const missingFields: string[] = []
providerTypeInfo?.fields
.filter((f) => f.required)
.forEach((field) => {
if (!credentials[field.name]) {
missingFields.push(field.label)
}
})
if (missingFields.length > 0 && !credential) {
// Only enforce for new credentials
toast.error(
t('credentials.missingFields', 'Missing required fields: ') + missingFields.join(', ')
)
return
}
const data: CredentialRequest = {
label: label.trim(),
zone_filter: zoneFilter.trim(),
credentials,
propagation_timeout: propagationTimeout,
polling_interval: pollingInterval,
enabled,
}
try {
if (credential) {
await updateMutation.mutateAsync({
providerId,
credentialId: credential.id,
data,
})
} else {
await createMutation.mutateAsync({ providerId, data })
}
onSuccess()
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
toast.error(
t('credentials.saveFailed', 'Failed to save credential') +
': ' +
(err.response?.data?.error || err.message)
)
}
}
const handleTest = async () => {
if (!credential) {
toast.info(t('credentials.saveBeforeTest', 'Please save the credential before testing'))
return
}
try {
const result = await testMutation.mutateAsync({
providerId,
credentialId: credential.id,
})
if (result.success) {
toast.success(result.message || t('credentials.testSuccess', 'Test passed'))
} else {
toast.error(result.error || t('credentials.testFailed', 'Test failed'))
}
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
toast.error(
t('credentials.testFailed', 'Test failed') +
': ' +
(err.response?.data?.error || err.message)
)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{credential
? t('credentials.editCredential', 'Edit Credential')
: t('credentials.addCredential', 'Add Credential')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Label */}
<div>
<Label htmlFor="label">
{t('credentials.label', 'Label')} <span className="text-destructive">*</span>
</Label>
<Input
id="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('credentials.labelPlaceholder', 'e.g., Production, Customer A')}
error={errors.label}
/>
</div>
{/* Zone Filter */}
<div>
<Label htmlFor="zone_filter">{t('credentials.zoneFilter', 'Zone Filter')}</Label>
<Input
id="zone_filter"
value={zoneFilter}
onChange={(e) => {
setZoneFilter(e.target.value)
validateZoneFilter(e.target.value)
}}
placeholder="example.com, *.staging.example.com"
error={errors.zone_filter}
/>
<p className="text-xs text-muted-foreground mt-1">
{t(
'credentials.zoneFilterHint',
'Comma-separated domains. Leave empty for catch-all. Supports wildcards (*.example.com)'
)}
</p>
</div>
{/* Credentials Fields */}
{providerTypeInfo?.fields.map((field) => (
<div key={field.name}>
<Label htmlFor={field.name}>
{field.label} {field.required && <span className="text-destructive">*</span>}
</Label>
<Input
id={field.name}
type={field.type === 'password' ? 'password' : 'text'}
value={credentials[field.name] || ''}
onChange={(e) => handleCredentialChange(field.name, e.target.value)}
placeholder={
credential
? t('credentials.leaveBlankToKeep', '(leave blank to keep existing)')
: field.default || ''
}
/>
{field.hint && (
<p className="text-xs text-muted-foreground mt-1">{field.hint}</p>
)}
</div>
))}
{/* Enabled Checkbox */}
<div className="flex items-center gap-2">
<Checkbox
id="enabled"
checked={enabled}
onCheckedChange={(checked) => setEnabled(checked === true)}
/>
<Label htmlFor="enabled" className="cursor-pointer">
{t('credentials.enabled', 'Enabled')}
</Label>
</div>
{/* Advanced Options */}
<details className="border rounded-lg p-4">
<summary className="cursor-pointer font-medium">
{t('common.advancedOptions', 'Advanced Options')}
</summary>
<div className="space-y-4 mt-4">
<div>
<Label htmlFor="propagation_timeout">
{t('dnsProviders.propagationTimeout', 'Propagation Timeout (seconds)')}
</Label>
<Input
id="propagation_timeout"
type="number"
min="10"
max="600"
value={propagationTimeout}
onChange={(e) => setPropagationTimeout(parseInt(e.target.value) || 120)}
/>
</div>
<div>
<Label htmlFor="polling_interval">
{t('dnsProviders.pollingInterval', 'Polling Interval (seconds)')}
</Label>
<Input
id="polling_interval"
type="number"
min="1"
max="60"
value={pollingInterval}
onChange={(e) => setPollingInterval(parseInt(e.target.value) || 5)}
/>
</div>
</div>
</details>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel', 'Cancel')}
</Button>
{credential && (
<Button
variant="secondary"
onClick={handleTest}
disabled={testMutation.isPending}
>
{t('common.test', 'Test')}
</Button>
)}
<Button
onClick={handleSubmit}
disabled={createMutation.isPending || updateMutation.isPending}
>
{t('common.save', 'Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,129 +0,0 @@
import { CheckCircle2, AlertCircle, Info } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge, Button, Alert } from './ui'
import type { DetectionResult } from '../api/dnsDetection'
import type { DNSProvider } from '../api/dnsProviders'
interface DNSDetectionResultProps {
result: DetectionResult
onUseSuggested?: (provider: DNSProvider) => void
onSelectManually?: () => void
isLoading?: boolean
}
export function DNSDetectionResult({
result,
onUseSuggested,
onSelectManually,
isLoading = false,
}: DNSDetectionResultProps) {
const { t } = useTranslation()
if (isLoading) {
return (
<Alert variant="info">
<Info className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{t('dns_detection.detecting')}</p>
</div>
</Alert>
)
}
if (result.error) {
return (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{t('dns_detection.error', { error: result.error })}</p>
</div>
</Alert>
)
}
if (!result.detected) {
return (
<Alert variant="info">
<Info className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{t('dns_detection.not_detected')}</p>
{result.nameservers.length > 0 && (
<div className="mt-2">
<p className="text-xs text-content-secondary">{t('dns_detection.nameservers')}:</p>
<ul className="text-xs text-content-secondary mt-1 space-y-0.5">
{result.nameservers.map((ns, i) => (
<li key={i} className="font-mono">
{ns}
</li>
))}
</ul>
</div>
)}
</div>
</Alert>
)
}
const getConfidenceBadgeVariant = (confidence: string) => {
switch (confidence) {
case 'high':
return 'success'
case 'medium':
return 'warning'
case 'low':
return 'outline'
default:
return 'outline'
}
}
const getConfidenceLabel = (confidence: string) => {
return t(`dns_detection.confidence_${confidence}`)
}
return (
<Alert variant="success" className="border-brand-500/30 bg-brand-500/5">
<CheckCircle2 className="h-4 w-4 text-brand-500" />
<div className="ml-2 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium">
{t('dns_detection.detected', { provider: result.provider_type })}
</p>
<Badge variant={getConfidenceBadgeVariant(result.confidence)} size="sm">
{getConfidenceLabel(result.confidence)}
</Badge>
</div>
{result.suggested_provider && (
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
variant="primary"
onClick={() => onUseSuggested?.(result.suggested_provider!)}
>
{t('dns_detection.use_suggested', { provider: result.suggested_provider.name })}
</Button>
<Button size="sm" variant="outline" onClick={onSelectManually}>
{t('dns_detection.select_manually')}
</Button>
</div>
)}
{result.nameservers.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-content-secondary cursor-pointer hover:text-content-primary">
{t('dns_detection.nameservers')} ({result.nameservers.length})
</summary>
<ul className="text-xs text-content-secondary mt-2 space-y-0.5 ml-4">
{result.nameservers.map((ns, i) => (
<li key={i} className="font-mono">
{ns}
</li>
))}
</ul>
</details>
)}
</div>
</Alert>
)
}

View File

@@ -1,216 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Edit,
Trash2,
TestTube,
Star,
CheckCircle,
XCircle,
AlertTriangle,
} from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Badge,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui'
import type { DNSProvider } from '../api/dnsProviders'
interface DNSProviderCardProps {
provider: DNSProvider
onEdit: (provider: DNSProvider) => void
onDelete: (id: number) => void
onTest: (id: number) => void
isTesting?: boolean
}
export default function DNSProviderCard({
provider,
onEdit,
onDelete,
onTest,
isTesting = false,
}: DNSProviderCardProps) {
const { t } = useTranslation()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const getStatusBadge = () => {
if (!provider.has_credentials) {
return (
<Badge variant="warning">
<AlertTriangle className="w-3 h-3 mr-1" />
{t('dnsProviders.unconfigured')}
</Badge>
)
}
if (provider.last_error) {
return (
<Badge variant="destructive">
<XCircle className="w-3 h-3 mr-1" />
{t('dnsProviders.error')}
</Badge>
)
}
if (provider.enabled) {
return (
<Badge variant="success">
<CheckCircle className="w-3 h-3 mr-1" />
{t('dnsProviders.active')}
</Badge>
)
}
return (
<Badge variant="secondary">
{t('common.disabled')}
</Badge>
)
}
const getProviderIcon = (type: string) => {
const iconMap: Record<string, string> = {
cloudflare: '☁️',
route53: '🔶',
digitalocean: '🐙',
googleclouddns: '🔵',
namecheap: '🏢',
godaddy: '🟢',
azure: '⚡',
hetzner: '🟠',
vultr: '🔷',
dnsimple: '💎',
}
return iconMap[type] || '🌐'
}
const handleDeleteConfirm = () => {
onDelete(provider.id)
setShowDeleteDialog(false)
}
return (
<>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="text-3xl">{getProviderIcon(provider.provider_type)}</div>
<div>
<CardTitle className="flex items-center gap-2">
{provider.name}
{provider.is_default && (
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" aria-label={t('dnsProviders.default')} />
)}
</CardTitle>
<p className="text-sm text-content-secondary mt-1">
{t(`dnsProviders.types.${provider.provider_type}`, provider.provider_type)}
</p>
</div>
</div>
{getStatusBadge()}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Usage Stats */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-content-muted">{t('dnsProviders.lastUsed')}</p>
<p className="font-medium text-content-primary">
{provider.last_used_at
? formatDistanceToNow(new Date(provider.last_used_at), { addSuffix: true })
: t('dnsProviders.neverUsed')}
</p>
</div>
<div>
<p className="text-content-muted">{t('dnsProviders.successRate')}</p>
<p className="font-medium text-content-primary">
{provider.success_count} / {provider.failure_count}
</p>
</div>
</div>
{/* Settings */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-content-muted">{t('dnsProviders.propagationTimeout')}</p>
<p className="font-medium text-content-primary">{provider.propagation_timeout}s</p>
</div>
<div>
<p className="text-content-muted">{t('dnsProviders.pollingInterval')}</p>
<p className="font-medium text-content-primary">{provider.polling_interval}s</p>
</div>
</div>
{/* Last Error */}
{provider.last_error && (
<div className="bg-error/10 border border-error/20 rounded-lg p-3">
<p className="text-xs font-medium text-error mb-1">{t('dnsProviders.lastError')}</p>
<p className="text-xs text-content-secondary">{provider.last_error}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(provider)}
className="flex-1"
>
<Edit className="w-4 h-4 mr-2" />
{t('common.edit')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onTest(provider.id)}
isLoading={isTesting}
disabled={!provider.has_credentials}
className="flex-1"
>
<TestTube className="w-4 h-4 mr-2" />
{t('common.test')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dnsProviders.deleteProvider')}</DialogTitle>
<DialogDescription>
{t('dnsProviders.deleteConfirmation', { name: provider.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="secondary" onClick={() => setShowDeleteDialog(false)}>
{t('common.cancel')}
</Button>
<Button variant="danger" onClick={handleDeleteConfirm}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -1,482 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronUp, ExternalLink, CheckCircle, XCircle, Settings } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Checkbox,
Alert,
Textarea,
} from './ui'
import { useDNSProviderTypes, useDNSProviderMutations, type DNSProvider } from '../hooks/useDNSProviders'
import type { DNSProviderRequest, DNSProviderTypeInfo } from '../api/dnsProviders'
import { defaultProviderSchemas } from '../data/dnsProviderSchemas'
import { useEnableMultiCredentials, useCredentials } from '../hooks/useCredentials'
import { useProviderFields } from '../hooks/usePlugins'
import CredentialManager from './CredentialManager'
interface DNSProviderFormProps {
open: boolean
onOpenChange: (open: boolean) => void
provider?: DNSProvider | null
onSuccess: () => void
}
export default function DNSProviderForm({
open,
onOpenChange,
provider = null,
onSuccess,
}: DNSProviderFormProps) {
const { t } = useTranslation()
const { data: providerTypes, isLoading: typesLoading } = useDNSProviderTypes()
const { createMutation, updateMutation, testCredentialsMutation } = useDNSProviderMutations()
const enableMultiCredsMutation = useEnableMultiCredentials()
const { data: existingCredentials } = useCredentials(provider?.id || 0)
const [name, setName] = useState('')
const [providerType, setProviderType] = useState<string>('')
const { data: dynamicFields } = useProviderFields(providerType)
const [credentials, setCredentials] = useState<Record<string, string>>({})
const [propagationTimeout, setPropagationTimeout] = useState(120)
const [pollingInterval, setPollingInterval] = useState(5)
const [isDefault, setIsDefault] = useState(false)
const [useMultiCredentials, setUseMultiCredentials] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [showCredentialManager, setShowCredentialManager] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
// Populate form when editing
useEffect(() => {
if (provider) {
setName(provider.name)
setProviderType(provider.provider_type)
setPropagationTimeout(provider.propagation_timeout)
setPollingInterval(provider.polling_interval)
setIsDefault(provider.is_default)
setUseMultiCredentials((provider as { use_multi_credentials?: boolean }).use_multi_credentials || false)
setCredentials({}) // Don't pre-fill credentials (they're encrypted)
} else {
resetForm()
}
}, [provider, open])
const resetForm = () => {
setName('')
setProviderType('')
setCredentials({})
setPropagationTimeout(120)
setPollingInterval(5)
setIsDefault(false)
setUseMultiCredentials(false)
setShowAdvanced(false)
setShowCredentialManager(false)
setTestResult(null)
}
const getSelectedProviderInfo = (): DNSProviderTypeInfo | undefined => {
if (!providerType) return undefined
// Prefer dynamic fields from API if available
if (dynamicFields) {
return {
type: dynamicFields.type as DNSProviderTypeInfo['type'],
name: dynamicFields.name,
fields: [
...dynamicFields.required_fields.map(f => ({ ...f, required: true })),
...dynamicFields.optional_fields.map(f => ({ ...f, required: false })),
],
documentation_url: '',
}
}
// Fallback to static types or schemas
return (
providerTypes?.find((pt) => pt.type === providerType) ||
(defaultProviderSchemas[providerType as keyof typeof defaultProviderSchemas] as DNSProviderTypeInfo)
)
}
const handleCredentialChange = (fieldName: string, value: string) => {
setCredentials((prev) => ({ ...prev, [fieldName]: value }))
}
const handleTestConnection = async () => {
const selectedProvider = getSelectedProviderInfo()
if (!selectedProvider) return
const data: DNSProviderRequest = {
name: name || 'Test',
provider_type: providerType as DNSProviderRequest['provider_type'],
credentials,
propagation_timeout: propagationTimeout,
polling_interval: pollingInterval,
}
try {
const result = await testCredentialsMutation.mutateAsync(data)
setTestResult({
success: result.success,
message: result.message || result.error || t('dnsProviders.testSuccess'),
})
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
setTestResult({
success: false,
message: err.response?.data?.error || err.message || t('dnsProviders.testFailed'),
})
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setTestResult(null)
const data: DNSProviderRequest = {
name,
provider_type: providerType as DNSProviderRequest['provider_type'],
credentials,
propagation_timeout: propagationTimeout,
polling_interval: pollingInterval,
is_default: isDefault,
}
try {
if (provider) {
await updateMutation.mutateAsync({ id: provider.id, data })
} else {
await createMutation.mutateAsync(data)
}
onSuccess()
onOpenChange(false)
resetForm()
} catch (error) {
console.error('Failed to save DNS provider:', error)
}
}
const selectedProviderInfo = getSelectedProviderInfo()
const isSubmitting = createMutation.isPending || updateMutation.isPending
const isTesting = testCredentialsMutation.isPending
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{provider ? t('dnsProviders.editProvider') : t('dnsProviders.addProvider')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
{/* Provider Type */}
<div>
<Label htmlFor="provider-type">{t('dnsProviders.providerType')}</Label>
<Select
value={providerType}
onValueChange={setProviderType}
disabled={!!provider} // Can't change type when editing
>
<SelectTrigger id="provider-type" aria-label={t('dnsProviders.providerType')}>
<SelectValue placeholder={t('dnsProviders.selectProviderType')} />
</SelectTrigger>
<SelectContent>
{typesLoading ? (
<SelectItem value="loading" disabled>
{t('common.loading')}
</SelectItem>
) : (
(providerTypes || Object.values(defaultProviderSchemas)).map((type) => (
<SelectItem key={type.type} value={type.type!}>
{type.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* Provider Name */}
<Input
id="provider-name"
label={t('dnsProviders.providerName')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('dnsProviders.providerNamePlaceholder')}
required
aria-label={t('dnsProviders.providerName')}
/>
{/* Dynamic Credential Fields */}
{selectedProviderInfo && (
<>
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-base">{t('dnsProviders.credentials')}</Label>
{selectedProviderInfo.documentation_url && (
<a
href={selectedProviderInfo.documentation_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-brand-500 hover:text-brand-600 flex items-center gap-1"
>
{t('dnsProviders.viewDocs')}
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
{selectedProviderInfo.fields?.map((field) => {
// Handle select field type
if (field.type === 'select' && field.options) {
return (
<div key={field.name} className="space-y-1.5">
<Label htmlFor={`field-${field.name}`}>{field.label}</Label>
<Select
value={credentials[field.name] || field.default || ''}
onValueChange={(value) => handleCredentialChange(field.name, value)}
>
<SelectTrigger id={`field-${field.name}`}>
<SelectValue placeholder={field.placeholder || `Select ${field.label}`} />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{field.hint && (
<p className="text-sm text-content-muted">{field.hint}</p>
)}
</div>
)
}
// Handle textarea field type
if (field.type === 'textarea') {
return (
<div key={field.name} className="space-y-1.5">
<Label htmlFor={`field-${field.name}`}>{field.label}</Label>
<Textarea
id={`field-${field.name}`}
value={credentials[field.name] || ''}
onChange={(e) => handleCredentialChange(field.name, e.target.value)}
placeholder={field.placeholder || field.default}
required={field.required && !provider}
rows={4}
/>
{field.hint && (
<p className="text-sm text-content-muted">{field.hint}</p>
)}
</div>
)
}
// Default: text or password input fields
return (
<Input
key={field.name}
label={field.label}
type={field.type}
value={credentials[field.name] || ''}
onChange={(e) => handleCredentialChange(field.name, e.target.value)}
placeholder={field.placeholder || field.default}
helperText={field.hint}
required={field.required && !provider}
/>
)
})}
</div>
{/* Test Connection */}
<div className="space-y-2">
<Button
type="button"
variant="secondary"
onClick={handleTestConnection}
isLoading={isTesting}
disabled={!providerType || !name}
className="w-full"
>
{t('dnsProviders.testConnection')}
</Button>
{testResult && (
<Alert variant={testResult.success ? 'success' : 'error'}>
<div className="flex items-start gap-2">
{testResult.success ? (
<CheckCircle className="w-4 h-4 mt-0.5" />
) : (
<XCircle className="w-4 h-4 mt-0.5" />
)}
<div>
<p className="font-medium">
{testResult.success
? t('dnsProviders.testSuccess')
: t('dnsProviders.testFailed')}
</p>
<p className="text-sm mt-1">{testResult.message}</p>
</div>
</div>
</Alert>
)}
</div>
{/* Multi-Credential Mode (only when editing) */}
{provider && (
<div className="border-t pt-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
id="use-multi-credentials"
checked={useMultiCredentials}
onCheckedChange={async (checked) => {
if (checked && !useMultiCredentials) {
// Enabling multi-credential mode
try {
await enableMultiCredsMutation.mutateAsync(provider.id)
setUseMultiCredentials(true)
} catch (error: unknown) {
console.error('Failed to enable multi-credentials:', error)
}
} else if (!checked && useMultiCredentials && existingCredentials?.length) {
// Warn before disabling if credentials exist
if (
!confirm(
t(
'credentials.disableWarning',
'Disabling multi-credential mode will remove all configured credentials. Continue?'
)
)
) {
return
}
setUseMultiCredentials(false)
} else {
setUseMultiCredentials(checked === true)
}
}}
disabled={enableMultiCredsMutation.isPending}
/>
<Label htmlFor="use-multi-credentials" className="cursor-pointer">
{t('credentials.useMultiCredentials', 'Use Multiple Credentials (Advanced)')}
</Label>
</div>
{useMultiCredentials && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setShowCredentialManager(true)}
>
<Settings className="w-4 h-4 mr-2" />
{t('credentials.manageCredentials', 'Manage Credentials')}
</Button>
)}
</div>
{useMultiCredentials && (
<Alert variant="info">
<p className="text-sm">
{t(
'credentials.multiCredentialInfo',
'Multi-credential mode allows you to configure different credentials for specific zones or domains.'
)}
</p>
</Alert>
)}
</div>
)}
</>
)}
{/* Advanced Settings */}
<div className="border-t pt-4">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 text-sm font-medium text-content-secondary hover:text-content-primary transition-colors"
>
{showAdvanced ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
{t('dnsProviders.advancedSettings')}
</button>
{showAdvanced && (
<div className="mt-4 space-y-3">
<Input
label={t('dnsProviders.propagationTimeout')}
type="number"
value={propagationTimeout}
onChange={(e) => setPropagationTimeout(parseInt(e.target.value, 10))}
helperText={t('dnsProviders.propagationTimeoutHint')}
min={30}
max={600}
/>
<Input
label={t('dnsProviders.pollingInterval')}
type="number"
value={pollingInterval}
onChange={(e) => setPollingInterval(parseInt(e.target.value, 10))}
helperText={t('dnsProviders.pollingIntervalHint')}
min={1}
max={60}
/>
<div className="flex items-center gap-2">
<Checkbox
id="is-default"
checked={isDefault}
onCheckedChange={(checked) => setIsDefault(checked as boolean)}
/>
<Label htmlFor="is-default" className="cursor-pointer">
{t('dnsProviders.setAsDefault')}
</Label>
</div>
</div>
)}
</div>
{/* Form Actions */}
<DialogFooter className="pt-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{t('common.cancel')}
</Button>
<Button type="submit" isLoading={isSubmitting} disabled={!providerType || !name}>
{provider ? t('common.update') : t('common.create')}
</Button>
</DialogFooter>
</form>
{/* Credential Manager Modal */}
{provider && showCredentialManager && (
<CredentialManager
open={showCredentialManager}
onOpenChange={setShowCredentialManager}
provider={provider}
providerTypeInfo={selectedProviderInfo}
/>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -1,105 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Star } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Label,
} from './ui'
import { useDNSProviders } from '../hooks/useDNSProviders'
interface DNSProviderSelectorProps {
value?: number
onChange: (providerId: number | undefined) => void
required?: boolean
disabled?: boolean
label?: string
helperText?: string
error?: string
}
export default function DNSProviderSelector({
value,
onChange,
required = false,
disabled = false,
label,
helperText,
error,
}: DNSProviderSelectorProps) {
const { t } = useTranslation()
const { data: providers = [], isLoading } = useDNSProviders()
// Filter to only enabled providers with credentials
const availableProviders = providers.filter(
(p) => p.enabled && p.has_credentials
)
const handleValueChange = (value: string) => {
if (value === 'none') {
onChange(undefined)
} else {
onChange(parseInt(value, 10))
}
}
return (
<div className="w-full">
{label && (
<Label className="block text-sm font-medium text-content-secondary mb-1.5">
{label}
{required && <span className="text-error ml-1">*</span>}
</Label>
)}
<Select
value={value ? value.toString() : 'none'}
onValueChange={handleValueChange}
disabled={disabled || isLoading}
>
<SelectTrigger error={!!error}>
<SelectValue placeholder={t('dnsProviders.selectProvider')} />
</SelectTrigger>
<SelectContent>
{!required && (
<SelectItem value="none">
{t('dnsProviders.noProvider')}
</SelectItem>
)}
{isLoading && (
<SelectItem value="loading" disabled>
{t('common.loading')}
</SelectItem>
)}
{!isLoading && availableProviders.length === 0 && (
<SelectItem value="empty" disabled>
{t('dnsProviders.noProvidersAvailable')}
</SelectItem>
)}
{availableProviders.map((provider) => (
<SelectItem key={provider.id} value={provider.id.toString()}>
<div className="flex items-center gap-2">
{provider.name}
{provider.is_default && (
<Star className="w-3 h-3 text-yellow-500 fill-yellow-500" />
)}
<span className="text-content-muted text-xs">
({t(`dnsProviders.types.${provider.provider_type}`, provider.provider_type)})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{error && (
<p className="mt-1.5 text-sm text-error" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p className="mt-1.5 text-sm text-content-muted">{helperText}</p>
)}
</div>
)
}

View File

@@ -1,30 +0,0 @@
interface Props {
session: { id: string }
onReview: () => void
onCancel: () => void
}
export default function ImportBanner({ session, onReview, onCancel }: Props) {
return (
<div className="bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded mb-6 flex items-center justify-between">
<div>
<div className="font-medium">Pending Import Session</div>
<div className="text-sm text-yellow-400/80">Session ID: {session.id}</div>
</div>
<div className="flex gap-3">
<button
onClick={onReview}
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-500 text-black rounded text-sm font-medium"
>
Review Changes
</button>
<button
onClick={onCancel}
className="px-3 py-1 bg-gray-800 hover:bg-gray-700 text-yellow-300 border border-yellow-700 rounded text-sm font-medium"
>
Cancel
</button>
</div>
</div>
)
}

View File

@@ -1,349 +0,0 @@
import React, { useState } from 'react'
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
interface HostPreview {
domain_names: string
name?: string
forward_scheme?: string
forward_host?: string
forward_port?: number
ssl_forced?: boolean
websocket_support?: boolean
[key: string]: unknown
}
interface ConflictDetail {
existing: {
forward_scheme: string
forward_host: string
forward_port: number
ssl_forced: boolean
websocket: boolean
enabled: boolean
}
imported: {
forward_scheme: string
forward_host: string
forward_port: number
ssl_forced: boolean
websocket: boolean
}
}
interface Props {
hosts: HostPreview[]
conflicts: string[]
conflictDetails?: Record<string, ConflictDetail>
errors: string[]
caddyfileContent?: string
onCommit: (resolutions: Record<string, string>, names: Record<string, string>) => Promise<void>
onCancel: () => void
}
export default function ImportReviewTable({ hosts, conflicts, conflictDetails, errors, caddyfileContent, onCommit, onCancel }: Props) {
const [resolutions, setResolutions] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {}
conflicts.forEach((d: string) => { init[d] = 'keep' })
return init
})
const [names, setNames] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {}
hosts.forEach((h) => {
// Default name to domain name (first domain if comma-separated)
init[h.domain_names] = h.name || h.domain_names.split(',')[0].trim()
})
return init
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showSource, setShowSource] = useState(false)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const handleCommit = async () => {
// Validate all names are filled
const emptyNames = hosts.filter(h => !names[h.domain_names]?.trim())
if (emptyNames.length > 0) {
setError(`Please provide a name for all hosts. Missing: ${emptyNames.map(h => h.domain_names).join(', ')}`)
return
}
setSubmitting(true)
setError(null)
try {
await onCommit(resolutions, names)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to commit import')
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
{caddyfileContent && (
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="p-4 border-b border-gray-800 flex items-center justify-between cursor-pointer" onClick={() => setShowSource(!showSource)}>
<h2 className="text-lg font-semibold text-white">Source Caddyfile Content</h2>
<span className="text-gray-400 text-sm">{showSource ? 'Hide' : 'Show'}</span>
</div>
{showSource && (
<div className="p-4 bg-gray-900 overflow-x-auto">
<pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap">{caddyfileContent}</pre>
</div>
)}
</div>
)}
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">Review Imported Hosts</h2>
<div className="flex gap-3">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
Back
</button>
<button
onClick={handleCommit}
disabled={submitting}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{submitting ? 'Committing...' : 'Commit Import'}
</button>
</div>
</div>
{error && (
<div className="m-4 bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
{errors?.length > 0 && (
<div className="m-4 bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded">
<div className="font-medium mb-2">Issues found during parsing</div>
<ul className="list-disc list-inside text-sm">
{errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Domain Names
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Conflict Resolution
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{hosts.map((h) => {
const domain = h.domain_names
const hasConflict = conflicts.includes(domain)
const isExpanded = expandedRows.has(domain)
const details = conflictDetails?.[domain]
return (
<React.Fragment key={domain}>
<tr className="hover:bg-gray-900/50">
<td className="px-6 py-4">
<input
type="text"
value={names[domain] || ''}
onChange={e => setNames({ ...names, [domain]: e.target.value })}
placeholder="Enter name"
className={`w-full bg-gray-900 border rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${
!names[domain]?.trim() ? 'border-red-500' : 'border-gray-700'
}`}
/>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{hasConflict && (
<button
onClick={() => {
const newExpanded = new Set(expandedRows)
if (isExpanded) newExpanded.delete(domain)
else newExpanded.add(domain)
setExpandedRows(newExpanded)
}}
className="text-gray-400 hover:text-white"
>
{isExpanded ? '▼' : '▶'}
</button>
)}
<div className="text-sm font-medium text-white">{domain}</div>
</div>
</td>
<td className="px-6 py-4">
{hasConflict ? (
<span className="flex items-center gap-1 text-yellow-400 text-xs">
<AlertTriangle className="w-3 h-3" />
Conflict
</span>
) : (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
<CheckCircle2 className="w-3 h-3" />
New
</span>
)}
</td>
<td className="px-6 py-4">
{hasConflict ? (
<select
value={resolutions[domain]}
onChange={e => setResolutions({ ...resolutions, [domain]: e.target.value })}
className="bg-gray-900 border border-gray-700 text-white rounded px-3 py-1.5 text-sm"
>
<option value="keep">Keep Existing (Skip Import)</option>
<option value="overwrite">Replace with Imported</option>
</select>
) : (
<span className="text-gray-400 text-sm">Will be imported</span>
)}
</td>
</tr>
{hasConflict && isExpanded && details && (
<tr className="bg-gray-900/30">
<td colSpan={4} className="px-6 py-4">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-6">
{/* Existing Configuration */}
<div className="border border-blue-500/30 rounded-lg p-4 bg-blue-900/10">
<h4 className="text-sm font-semibold text-blue-400 mb-3 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
Current Configuration
</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-400">Target:</dt>
<dd className="text-white font-mono">
{details.existing.forward_scheme}://{details.existing.forward_host}:{details.existing.forward_port}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">SSL Forced:</dt>
<dd className={details.existing.ssl_forced ? 'text-green-400' : 'text-gray-400'}>
{details.existing.ssl_forced ? 'Yes' : 'No'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">WebSocket:</dt>
<dd className={details.existing.websocket ? 'text-green-400' : 'text-gray-400'}>
{details.existing.websocket ? 'Enabled' : 'Disabled'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">Status:</dt>
<dd className={details.existing.enabled ? 'text-green-400' : 'text-red-400'}>
{details.existing.enabled ? 'Enabled' : 'Disabled'}
</dd>
</div>
</dl>
</div>
{/* Imported Configuration */}
<div className="border border-purple-500/30 rounded-lg p-4 bg-purple-900/10">
<h4 className="text-sm font-semibold text-purple-400 mb-3 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
Imported Configuration
</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-400">Target:</dt>
<dd className={`font-mono ${
details.imported.forward_host !== details.existing.forward_host ||
details.imported.forward_port !== details.existing.forward_port ||
details.imported.forward_scheme !== details.existing.forward_scheme
? 'text-yellow-400 font-semibold'
: 'text-white'
}`}>
{details.imported.forward_scheme}://{details.imported.forward_host}:{details.imported.forward_port}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">SSL Forced:</dt>
<dd className={`${
details.imported.ssl_forced !== details.existing.ssl_forced
? 'text-yellow-400 font-semibold'
: details.imported.ssl_forced ? 'text-green-400' : 'text-gray-400'
}`}>
{details.imported.ssl_forced ? 'Yes' : 'No'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">WebSocket:</dt>
<dd className={`${
details.imported.websocket !== details.existing.websocket
? 'text-yellow-400 font-semibold'
: details.imported.websocket ? 'text-green-400' : 'text-gray-400'
}`}>
{details.imported.websocket ? 'Enabled' : 'Disabled'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">Status:</dt>
<dd className="text-gray-400">
(Imported hosts are disabled by default)
</dd>
</div>
</dl>
</div>
</div>
{/* Recommendation */}
<div className="bg-gray-800/50 rounded-lg p-3 border-l-4 border-blue-500">
<p className="text-sm text-gray-300">
<strong className="text-blue-400">💡 Recommendation:</strong>{' '}
{getRecommendation(details)}
</p>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}
function getRecommendation(details: ConflictDetail): string {
const hasTargetChange =
details.imported.forward_host !== details.existing.forward_host ||
details.imported.forward_port !== details.existing.forward_port ||
details.imported.forward_scheme !== details.existing.forward_scheme
const hasConfigChange =
details.imported.ssl_forced !== details.existing.ssl_forced ||
details.imported.websocket !== details.existing.websocket
if (hasTargetChange) {
return 'The imported configuration points to a different backend server. Choose "Replace" if you want to update the target, or "Keep Existing" if the current setup is correct.'
}
if (hasConfigChange) {
return 'The imported configuration has different SSL or WebSocket settings. Choose "Replace" to update these settings, or "Keep Existing" to maintain current configuration.'
}
return 'The configurations are identical. You can safely keep the existing configuration.'
}

View File

@@ -1,91 +0,0 @@
import { useState } from 'react'
import { uploadCaddyfilesMulti } from '../api/import'
type Props = {
visible: boolean
onClose: () => void
onUploaded?: () => void
}
export default function ImportSitesModal({ visible, onClose, onUploaded }: Props) {
const [sites, setSites] = useState<string[]>([''])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!visible) return null
const setSite = (index: number, value: string) => {
const s = [...sites]
s[index] = value
setSites(s)
}
const addSite = () => setSites(prev => [...prev, ''])
const removeSite = (index: number) => setSites(prev => prev.filter((_, i) => i !== index))
const handleSubmit = async () => {
setError(null)
setLoading(true)
try {
const cleaned = sites.map(s => s || '')
await uploadCaddyfilesMulti(cleaned)
setLoading(false)
if (onUploaded) onUploaded()
onClose()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
setError(msg || 'Upload failed')
setLoading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-dark-card rounded-lg p-6 w-[900px] max-w-full">
<h3 className="text-xl font-semibold text-white mb-4">Multi-site Import</h3>
<p className="text-gray-400 text-sm mb-4">Add each site's Caddyfile content separately, then parse them together.</p>
<div className="space-y-4 max-h-[60vh] overflow-auto mb-4">
{sites.map((s, idx) => (
<div key={idx} className="border border-gray-800 rounded-lg p-3">
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-gray-300">Site {idx + 1}</div>
<div>
{sites.length > 1 && (
<button
onClick={() => removeSite(idx)}
className="text-red-400 text-sm hover:underline mr-2"
>
Remove
</button>
)}
</div>
</div>
<textarea
value={s}
onChange={e => setSite(idx, e.target.value)}
placeholder={`example.com {\n reverse_proxy localhost:8080\n}`}
className="w-full h-48 bg-gray-900 border border-gray-700 rounded-lg p-3 text-white font-mono text-sm"
/>
</div>
))}
</div>
{error && <div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-2 rounded mb-4">{error}</div>}
<div className="flex gap-3 justify-end">
<button onClick={addSite} className="px-4 py-2 bg-gray-800 text-white rounded">+ Add site</button>
<button onClick={onClose} className="px-4 py-2 bg-gray-700 text-white rounded">Cancel</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 bg-blue-active text-white rounded disabled:opacity-60"
>
{loading ? 'Processing...' : 'Parse and Review'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,39 +0,0 @@
import { Globe } from 'lucide-react'
import { useLanguage } from '../hooks/useLanguage'
import { Language } from '../context/LanguageContextValue'
const languageOptions: { code: Language; label: string; nativeLabel: string }[] = [
{ code: 'en', label: 'English', nativeLabel: 'English' },
{ code: 'es', label: 'Spanish', nativeLabel: 'Español' },
{ code: 'fr', label: 'French', nativeLabel: 'Français' },
{ code: 'de', label: 'German', nativeLabel: 'Deutsch' },
{ code: 'zh', label: 'Chinese', nativeLabel: '中文' },
]
export function LanguageSelector() {
const { language, setLanguage } = useLanguage()
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setLanguage(e.target.value as Language)
}
return (
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-content-secondary" />
<select
id="language-selector"
data-testid="language-selector"
aria-label="Language"
value={language}
onChange={handleChange}
className="bg-surface-elevated border border-border rounded-md px-3 py-2 text-content-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
>
{languageOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.nativeLabel}
</option>
))}
</select>
</div>
)
}

View File

@@ -1,375 +0,0 @@
import { ReactNode, useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ThemeToggle } from './ThemeToggle'
import { Button } from './ui/Button'
import { useAuth } from '../hooks/useAuth'
import { checkHealth } from '../api/health'
import { getFeatureFlags } from '../api/featureFlags'
import NotificationCenter from './NotificationCenter'
import SystemStatus from './SystemStatus'
import { Menu, ChevronDown, ChevronRight } from 'lucide-react'
interface LayoutProps {
children: ReactNode
}
type NavItem = {
name: string
path?: string
icon?: string
children?: NavItem[]
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation()
const { t } = useTranslation()
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = localStorage.getItem('sidebarCollapsed')
return saved ? JSON.parse(saved) : false
})
const [expandedMenus, setExpandedMenus] = useState<string[]>([])
const { logout, user } = useAuth()
useEffect(() => {
localStorage.setItem('sidebarCollapsed', JSON.stringify(isCollapsed))
}, [isCollapsed])
const toggleMenu = (name: string) => {
setExpandedMenus(prev =>
prev.includes(name)
? prev.filter(item => item !== name)
: [...prev, name]
)
}
const { data: health } = useQuery({
queryKey: ['health'],
queryFn: checkHealth,
staleTime: 1000 * 60 * 60, // 1 hour
})
const { data: featureFlags } = useQuery({
queryKey: ['feature-flags'],
queryFn: getFeatureFlags,
staleTime: 1000 * 60 * 5, // 5 minutes
})
const navigation: NavItem[] = [
{ name: t('navigation.dashboard'), path: '/', icon: '📊' },
{ name: t('navigation.proxyHosts'), path: '/proxy-hosts', icon: '🌐' },
{ name: t('navigation.remoteServers'), path: '/remote-servers', icon: '🖥️' },
{ name: t('navigation.domains'), path: '/domains', icon: '🌍' },
{ name: t('navigation.certificates'), path: '/certificates', icon: '🔒' },
{ name: t('navigation.dns'), path: '/dns', icon: '☁️', children: [
{ name: t('navigation.dnsProviders'), path: '/dns/providers', icon: '🧭' },
{ name: t('navigation.plugins'), path: '/dns/plugins', icon: '🔌' },
] },
{ name: t('navigation.uptime'), path: '/uptime', icon: '📈' },
{ name: t('navigation.security'), path: '/security', icon: '🛡️', children: [
{ name: t('navigation.dashboard'), path: '/security', icon: '🛡️' },
{ name: t('navigation.crowdsec'), path: '/security/crowdsec', icon: '🛡️' },
{ name: t('navigation.accessLists'), path: '/security/access-lists', icon: '🔒' },
{ name: t('navigation.rateLimiting'), path: '/security/rate-limiting', icon: '⚡' },
{ name: t('navigation.waf'), path: '/security/waf', icon: '🛡️' },
{ name: t('navigation.securityHeaders'), path: '/security/headers', icon: '🔐' },
{ name: t('navigation.encryption'), path: '/security/encryption', icon: '🔑' },
]},
{
name: t('navigation.settings'),
path: '/settings',
icon: '⚙️',
children: [
{ name: t('navigation.system'), path: '/settings/system', icon: '⚙️' },
{ name: t('navigation.notifications'), path: '/settings/notifications', icon: '🔔' },
{ name: t('navigation.email'), path: '/settings/smtp', icon: '📧' },
{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' },
{ name: t('navigation.accountManagement'), path: '/settings/account-management', icon: '👥' },
]
},
{
name: t('navigation.tasks'),
path: '/tasks',
icon: '📋',
children: [
{
name: t('navigation.import'),
path: '/tasks/import',
children: [
{ name: t('navigation.caddyfile'), path: '/tasks/import/caddyfile', icon: '📥' },
{ name: t('navigation.crowdsec'), path: '/tasks/import/crowdsec', icon: '🛡️' },
{ name: t('navigation.importNPM'), path: '/tasks/import/npm', icon: '📦' },
{ name: t('navigation.importJSON'), path: '/tasks/import/json', icon: '📄' },
]
},
{ name: t('navigation.backups'), path: '/tasks/backups', icon: '💾' },
{ name: t('navigation.logs'), path: '/tasks/logs', icon: '📝' },
]
},
].filter(item => {
// Optional Features Logic
// Default to visible (true) if flags are loading or undefined
if (item.name === t('navigation.uptime')) return featureFlags?.['feature.uptime.enabled'] !== false
if (item.name === t('navigation.security')) return featureFlags?.['feature.cerberus.enabled'] !== false
return true
})
return (
<div className="min-h-screen bg-light-bg dark:bg-dark-bg flex transition-colors duration-200">
{/* Mobile Header */}
<div className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-4 z-40">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => setMobileSidebarOpen(!mobileSidebarOpen)} data-testid="mobile-menu-toggle">
<Menu className="w-5 h-5" />
</Button>
<img src="/logo.png" alt="Charon" className="h-10 w-auto" />
</div>
<div className="flex items-center gap-2">
<NotificationCenter />
<ThemeToggle />
</div>
</div>
{/* Sidebar */}
<aside className={`
fixed lg:fixed inset-y-0 left-0 z-30 transform transition-all duration-200 ease-in-out
bg-white dark:bg-dark-sidebar border-r border-gray-200 dark:border-gray-800 flex flex-col
${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
${isCollapsed ? 'w-20' : 'w-64'}
`}>
<div className={`h-20 flex items-center justify-center border-b border-gray-200 dark:border-gray-800`}>
{isCollapsed ? (
<img src="/logo.png" alt="Charon" className="h-12 w-auto" />
) : (
<img src="/banner.png" alt="Charon" className="h-14 w-auto max-w-[200px] object-contain" />
)}
</div>
<div className="flex flex-col flex-1 px-4 mt-16 lg:mt-6 min-h-0">
<nav className="flex-1 space-y-1 overflow-y-auto">
{navigation.map((item) => {
if (item.children) {
// Collapsible Group
const isExpanded = expandedMenus.includes(item.name)
const isActive = location.pathname.startsWith(item.path!)
// If sidebar is collapsed, render as a simple link (icon only)
if (isCollapsed) {
return (
<Link
key={item.name}
to={item.path!}
onClick={() => setMobileSidebarOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors justify-center ${
isActive
? 'bg-blue-100 text-blue-700 dark:bg-blue-active dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
}`}
title={item.name}
>
<span className="text-lg">{item.icon}</span>
</Link>
)
}
// If sidebar is expanded, render as collapsible accordion
return (
<div key={item.name} className="space-y-1">
<button
onClick={() => toggleMenu(item.name)}
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'text-blue-700 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center gap-3">
<span className="text-lg">{item.icon}</span>
<span>{item.name}</span>
</div>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{isExpanded && (
<div className="pl-11 space-y-1">
{item.children.map((child: NavItem) => {
// If this child has its own children, render a nested accordion
if (child.children && child.children.length > 0) {
const nestedExpandedKey = `${item.name}:${child.name}`
const isNestedOpen = expandedMenus.includes(nestedExpandedKey)
return (
<div key={child.path} className="space-y-1">
<button
onClick={() => toggleMenu(nestedExpandedKey)}
className={`w-full flex items-center justify-between py-2 px-3 rounded-md text-sm transition-colors ${
location.pathname.startsWith(child.path!)
? 'text-blue-700 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center gap-2">
<span className="text-lg">{child.icon}</span>
<span>{child.name}</span>
</div>
{isNestedOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{isNestedOpen && (
<div className="pl-6 space-y-1">
{child.children.map((sub: NavItem) => (
<Link
key={sub.path}
to={sub.path!}
onClick={() => setMobileSidebarOpen(false)}
className={`block py-2 px-3 rounded-md text-sm transition-colors ${
location.pathname === sub.path
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800/50'
}`}
>
{sub.name}
</Link>
))}
</div>
)}
</div>
)
}
const isChildActive = location.pathname === child.path
return (
<Link
key={child.path}
to={child.path!}
onClick={() => setMobileSidebarOpen(false)}
className={`block py-2 px-3 rounded-md text-sm transition-colors ${
isChildActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800/50'
}`}
>
{child.name}
</Link>
)
})}
</div>
)}
</div>
)
}
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path!}
onClick={() => setMobileSidebarOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-100 text-blue-700 dark:bg-blue-active dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
} ${isCollapsed ? 'justify-center' : ''}`}
title={isCollapsed ? item.name : ''}
>
<span className="text-lg">{item.icon}</span>
{!isCollapsed && item.name}
</Link>
)
})}
</nav>
<div className={`mt-2 border-t border-gray-200 dark:border-gray-800 pt-4 flex-shrink-0 ${isCollapsed ? 'hidden' : ''}`}>
<div className="text-xs text-gray-500 dark:text-gray-500 text-center mb-2 flex flex-col gap-0.5">
<span>Version {health?.version || 'dev'}</span>
{health?.git_commit && health.git_commit !== 'unknown' && (
<span className="text-[10px] opacity-75 font-mono">
({health.git_commit.substring(0, 7)})
</span>
)}
</div>
<button
onClick={() => {
setMobileSidebarOpen(false)
logout()
}}
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-medium transition-colors text-red-600 dark:text-red-400 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900"
>
<span className="text-lg">🚪</span>
{t('auth.logout')}
</button>
</div>
{/* Collapsed Logout */}
{isCollapsed && (
<div className="mt-2 border-t border-gray-200 dark:border-gray-800 pt-4 pb-4 flex-shrink-0">
<button
onClick={() => {
setMobileSidebarOpen(false)
logout()
}}
className="w-full flex items-center justify-center p-3 rounded-lg transition-colors text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
title={t('auth.logout')}
>
<span className="text-lg">🚪</span>
</button>
</div>
)}
</div>
</aside>
{/* Overlay for mobile */}
{/* Mobile Overlay */}
{mobileSidebarOpen && (
<div
className="fixed inset-0 bg-gray-900/50 z-20 lg:hidden"
onClick={() => setMobileSidebarOpen(false)}
/>
)}
{/* Main Content */}
<main className={`flex-1 min-w-0 pt-16 lg:pt-0 flex flex-col transition-all duration-200 ${isCollapsed ? 'lg:ml-20' : 'lg:ml-64'}`}>
{/* Desktop Header */}
<header className="hidden lg:flex items-center justify-between px-8 h-20 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 sticky top-0 z-10">
<div className="w-1/3 flex items-center gap-4">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={isCollapsed ? t('navigation.expandSidebar') : t('navigation.collapseSidebar')}
>
<Menu className="w-5 h-5" />
</button>
</div>
<div className="w-1/3 flex justify-center">
{/* Banner moved to sidebar */}
</div>
<div className="w-1/3 flex justify-end items-center gap-4">
{user && (
<Link to="/settings/account" className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{user.name}
</Link>
)}
<SystemStatus />
<NotificationCenter />
<ThemeToggle />
</div>
</header>
<div className="flex-1 overflow-y-auto">
<div className="p-4 lg:p-8 max-w-7xl mx-auto w-full">
{children}
</div>
</div>
</main>
</div>
)
}

View File

@@ -1,517 +0,0 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import {
connectLiveLogs,
connectSecurityLogs,
LiveLogEntry,
LiveLogFilter,
SecurityLogEntry,
SecurityLogFilter,
} from '../api/logs';
import { Button } from './ui/Button';
import { Pause, Play, Trash2, Filter, Shield, Globe } from 'lucide-react';
/**
* Log viewing mode: application logs vs security access logs
*/
export type LogMode = 'application' | 'security';
interface LiveLogViewerProps {
/** Filters for application log mode */
filters?: LiveLogFilter;
/** Filters for security log mode */
securityFilters?: SecurityLogFilter;
/** Initial log viewing mode */
mode?: LogMode;
/** Maximum number of log entries to retain */
maxLogs?: number;
/** Additional CSS classes */
className?: string;
}
/**
* Unified display entry for both application and security logs
*/
interface DisplayLogEntry {
timestamp: string;
level: string;
source: string;
message: string;
blocked?: boolean;
blockReason?: string;
clientIP?: string;
method?: string;
host?: string;
uri?: string;
status?: number;
duration?: number;
userAgent?: string;
details?: Record<string, unknown>;
}
/**
* Convert a LiveLogEntry to unified display format
*/
const toDisplayFromLive = (entry: LiveLogEntry): DisplayLogEntry => ({
timestamp: entry.timestamp,
level: entry.level,
source: entry.source || 'app',
message: entry.message,
details: entry.data,
});
/**
* Convert a SecurityLogEntry to unified display format
*/
const toDisplayFromSecurity = (entry: SecurityLogEntry): DisplayLogEntry => ({
timestamp: entry.timestamp,
level: entry.level,
source: entry.source,
message: entry.blocked
? `🚫 BLOCKED: ${entry.block_reason || 'Access denied'}`
: `${entry.method} ${entry.uri}${entry.status}`,
blocked: entry.blocked,
blockReason: entry.block_reason,
clientIP: entry.client_ip,
method: entry.method,
host: entry.host,
uri: entry.uri,
status: entry.status,
duration: entry.duration,
userAgent: entry.user_agent,
details: entry.details,
});
/**
* Get background/text styling based on log entry properties
*/
const getEntryStyle = (log: DisplayLogEntry): string => {
if (log.blocked) {
return 'bg-red-900/30 border-l-2 border-red-500';
}
const level = log.level.toLowerCase();
if (level.includes('error') || level.includes('fatal')) return 'text-red-400';
if (level.includes('warn')) return 'text-yellow-400';
return '';
};
/**
* Get badge color for security source
*/
const getSourceBadgeColor = (source: string): string => {
const colors: Record<string, string> = {
waf: 'bg-orange-600',
crowdsec: 'bg-purple-600',
ratelimit: 'bg-blue-600',
acl: 'bg-green-600',
normal: 'bg-gray-600',
cerberus: 'bg-indigo-600',
app: 'bg-gray-500',
};
return colors[source.toLowerCase()] || 'bg-gray-500';
};
/**
* Format timestamp for display
*/
const formatTimestamp = (timestamp: string): string => {
try {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return timestamp;
}
};
/**
* Get level color for application logs
*/
const getLevelColor = (level: string): string => {
const normalized = level.toLowerCase();
if (normalized.includes('error') || normalized.includes('fatal')) return 'text-red-400';
if (normalized.includes('warn')) return 'text-yellow-400';
if (normalized.includes('info')) return 'text-blue-400';
if (normalized.includes('debug')) return 'text-gray-400';
return 'text-gray-300';
};
// Stable default filter objects to prevent useEffect re-triggers on parent re-render
const EMPTY_LIVE_FILTER: LiveLogFilter = {};
const EMPTY_SECURITY_FILTER: SecurityLogFilter = {};
export function LiveLogViewer({
filters = EMPTY_LIVE_FILTER,
securityFilters = EMPTY_SECURITY_FILTER,
mode = 'security',
maxLogs = 500,
className = '',
}: LiveLogViewerProps) {
const [logs, setLogs] = useState<DisplayLogEntry[]>([]);
const [isPaused, setIsPaused] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [currentMode, setCurrentMode] = useState<LogMode>(mode);
const [textFilter, setTextFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const [showBlockedOnly, setShowBlockedOnly] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
const closeConnectionRef = useRef<(() => void) | null>(null);
const shouldAutoScroll = useRef(true);
const isPausedRef = useRef(isPaused);
// Keep ref in sync with state for use in WebSocket handlers
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
// Handle mode change - clear logs and update filters
const handleModeChange = useCallback((newMode: LogMode) => {
setCurrentMode(newMode);
setLogs([]);
setTextFilter('');
setLevelFilter('');
setSourceFilter('');
setShowBlockedOnly(false);
}, []);
// Connection effect - reconnects when mode or external filters change
useEffect(() => {
// Close existing connection
if (closeConnectionRef.current) {
closeConnectionRef.current();
closeConnectionRef.current = null;
}
const handleOpen = () => {
console.log(`${currentMode} log viewer connected`);
setIsConnected(true);
setConnectionError(null);
};
const handleError = (error: Event) => {
console.error(`${currentMode} log viewer error:`, error);
setIsConnected(false);
setConnectionError('Failed to connect to log stream. Check your authentication or try refreshing.');
};
const handleClose = () => {
console.log(`${currentMode} log viewer disconnected`);
setIsConnected(false);
};
if (currentMode === 'security') {
// Connect to security logs endpoint
const handleSecurityMessage = (entry: SecurityLogEntry) => {
// Use ref to check paused state - avoids WebSocket reconnection when pausing
if (isPausedRef.current) return;
const displayEntry = toDisplayFromSecurity(entry);
setLogs((prev) => {
const updated = [...prev, displayEntry];
return updated.length > maxLogs ? updated.slice(-maxLogs) : updated;
});
};
// Build filters including blocked_only if selected
const effectiveFilters: SecurityLogFilter = {
...securityFilters,
blocked_only: showBlockedOnly || securityFilters.blocked_only,
};
closeConnectionRef.current = connectSecurityLogs(
effectiveFilters,
handleSecurityMessage,
handleOpen,
handleError,
handleClose
);
} else {
// Connect to application logs endpoint
const handleLiveMessage = (entry: LiveLogEntry) => {
// Use ref to check paused state - avoids WebSocket reconnection when pausing
if (isPausedRef.current) return;
const displayEntry = toDisplayFromLive(entry);
setLogs((prev) => {
const updated = [...prev, displayEntry];
return updated.length > maxLogs ? updated.slice(-maxLogs) : updated;
});
};
closeConnectionRef.current = connectLiveLogs(
filters,
handleLiveMessage,
handleOpen,
handleError,
handleClose
);
}
return () => {
if (closeConnectionRef.current) {
closeConnectionRef.current();
closeConnectionRef.current = null;
}
setIsConnected(false);
};
// Note: isPaused is intentionally excluded - we use isPausedRef to avoid reconnecting when pausing
}, [currentMode, filters, securityFilters, maxLogs, showBlockedOnly]);
// Auto-scroll effect
useEffect(() => {
if (shouldAutoScroll.current && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs]);
// Track manual scrolling
const handleScroll = () => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
// Enable auto-scroll if scrolled to bottom (within 50px threshold)
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 50;
}
};
const handleClear = () => {
setLogs([]);
};
const handleTogglePause = () => {
setIsPaused(!isPaused);
};
// Client-side filtering
const filteredLogs = logs.filter((log) => {
// Text filter - search in message, URI, host, IP
if (textFilter) {
const searchText = textFilter.toLowerCase();
const matchFields = [
log.message,
log.uri,
log.host,
log.clientIP,
log.blockReason,
].filter(Boolean).map(s => s!.toLowerCase());
if (!matchFields.some(field => field.includes(searchText))) {
return false;
}
}
// Level filter
if (levelFilter && log.level.toLowerCase() !== levelFilter.toLowerCase()) {
return false;
}
// Source filter (security mode only)
if (sourceFilter && log.source.toLowerCase() !== sourceFilter.toLowerCase()) {
return false;
}
return true;
});
return (
<div className={`bg-gray-900 rounded-lg border border-gray-700 ${className}`}>
{/* Header with mode toggle and controls */}
<div className="flex items-center justify-between p-3 border-b border-gray-700">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">
{currentMode === 'security' ? 'Security Access Logs' : 'Live Security Logs'}
</h3>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
isConnected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'
}`}
data-testid="connection-status"
>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
{connectionError && (
<div className="text-xs text-red-400 bg-red-900/20 px-2 py-1 rounded" data-testid="connection-error">
{connectionError}
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Mode toggle */}
<div className="flex bg-gray-800 rounded-md p-0.5" data-testid="mode-toggle">
<button
onClick={() => handleModeChange('application')}
className={`px-2 py-1 text-xs rounded flex items-center gap-1 transition-colors ${
currentMode === 'application' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
}`}
title="Application logs"
>
<Globe className="w-4 h-4" />
<span className="hidden sm:inline">App</span>
</button>
<button
onClick={() => handleModeChange('security')}
className={`px-2 py-1 text-xs rounded flex items-center gap-1 transition-colors ${
currentMode === 'security' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
}`}
title="Security access logs"
>
<Shield className="w-4 h-4" />
<span className="hidden sm:inline">Security</span>
</button>
</div>
{/* Pause/Resume */}
<Button
variant="ghost"
size="sm"
onClick={handleTogglePause}
className="flex items-center gap-1"
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</Button>
{/* Clear */}
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="flex items-center gap-1"
title="Clear logs"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
<Filter className="w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Filter by text..."
value={textFilter}
onChange={(e) => setTextFilter(e.target.value)}
className="flex-1 min-w-32 px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500"
/>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500"
>
<option value="">All Levels</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
</select>
{/* Security mode specific filters */}
{currentMode === 'security' && (
<>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className="px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500"
>
<option value="">All Sources</option>
<option value="waf">WAF</option>
<option value="crowdsec">CrowdSec</option>
<option value="ratelimit">Rate Limit</option>
<option value="acl">ACL</option>
<option value="normal">Normal</option>
</select>
<label className="flex items-center gap-1 text-xs text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={showBlockedOnly}
onChange={(e) => setShowBlockedOnly(e.target.checked)}
className="rounded border-gray-600 bg-gray-700 text-blue-500 focus:ring-blue-500"
/>
Blocked only
</label>
</>
)}
</div>
{/* Log display */}
<div
ref={logContainerRef}
onScroll={handleScroll}
className="h-96 overflow-y-auto p-3 font-mono text-xs bg-black"
style={{ scrollBehavior: 'smooth' }}
>
{filteredLogs.length === 0 && (
<div className="text-gray-500 text-center py-8">
{logs.length === 0 ? 'No logs yet. Waiting for events...' : 'No logs match the current filters.'}
</div>
)}
{filteredLogs.map((log, index) => (
<div
key={index}
className={`mb-1 hover:bg-gray-900 px-1 -mx-1 rounded ${getEntryStyle(log)}`}
data-testid="log-entry"
>
<span className="text-gray-500">{formatTimestamp(log.timestamp)}</span>
{/* Source badge for security mode */}
{currentMode === 'security' && (
<span className={`ml-2 px-1 rounded text-xs text-white ${getSourceBadgeColor(log.source)}`}>
{log.source.toUpperCase()}
</span>
)}
{/* Level badge for application mode */}
{currentMode === 'application' && (
<span className={`ml-2 font-semibold ${getLevelColor(log.level)}`}>
{log.level.toUpperCase()}
</span>
)}
{/* Client IP for security logs */}
{currentMode === 'security' && log.clientIP && (
<span className="ml-2 text-cyan-400">{log.clientIP}</span>
)}
{/* Source tag for application logs */}
{currentMode === 'application' && log.source && log.source !== 'app' && (
<span className="ml-2 text-purple-400">[{log.source}]</span>
)}
{/* Message */}
<span className="ml-2 text-gray-200">{log.message}</span>
{/* Block reason badge */}
{log.blocked && log.blockReason && (
<span className="ml-2 text-red-400 text-xs">[{log.blockReason}]</span>
)}
{/* Status code for security logs */}
{currentMode === 'security' && log.status && !log.blocked && (
<span className={`ml-2 ${log.status >= 400 ? 'text-red-400' : log.status >= 300 ? 'text-yellow-400' : 'text-green-400'}`}>
[{log.status}]
</span>
)}
{/* Duration for security logs */}
{currentMode === 'security' && log.duration !== undefined && (
<span className="ml-1 text-gray-500">
{log.duration < 1 ? `${(log.duration * 1000).toFixed(1)}ms` : `${log.duration.toFixed(2)}s`}
</span>
)}
{/* Additional data */}
{log.details && Object.keys(log.details).length > 0 && (
<div className="ml-8 text-gray-400 text-xs">
{JSON.stringify(log.details, null, 2)}
</div>
)}
</div>
))}
</div>
{/* Footer with log count */}
<div className="p-2 border-t border-gray-700 bg-gray-800 text-xs text-gray-400 flex items-center justify-between" data-testid="log-count">
<span>
Showing {filteredLogs.length} of {logs.length} logs
</span>
{isPaused && <span className="text-yellow-400"> Paused</span>}
</div>
</div>
);
}

View File

@@ -1,331 +0,0 @@
export function LoadingSpinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-3',
lg: 'w-12 h-12 border-4',
}
return (
<div
className={`${sizeClasses[size]} border-blue-600 border-t-transparent rounded-full animate-spin`}
role="status"
aria-label="Loading"
/>
)
}
/**
* CharonLoader - Boat on Waves animation (Charon ferrying across the Styx)
* Used for general proxy/configuration operations
*/
export function CharonLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-12 h-12',
md: 'w-20 h-20',
lg: 'w-28 h-28',
}
return (
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Loading">
<svg viewBox="0 0 100 100" className="w-full h-full">
{/* Water waves */}
<path
d="M0,60 Q10,55 20,60 T40,60 T60,60 T80,60 T100,60"
fill="none"
stroke="#3b82f6"
strokeWidth="2"
className="animate-pulse"
/>
<path
d="M0,65 Q10,60 20,65 T40,65 T60,65 T80,65 T100,65"
fill="none"
stroke="#60a5fa"
strokeWidth="2"
className="animate-pulse"
style={{ animationDelay: '0.3s' }}
/>
<path
d="M0,70 Q10,65 20,70 T40,70 T60,70 T80,70 T100,70"
fill="none"
stroke="#93c5fd"
strokeWidth="2"
className="animate-pulse"
style={{ animationDelay: '0.6s' }}
/>
{/* Boat (bobbing animation) */}
<g className="animate-bob-boat" style={{ transformOrigin: '50% 50%' }}>
{/* Hull */}
<path
d="M30,45 L30,50 Q35,55 50,55 T70,50 L70,45 Z"
fill="#1e293b"
stroke="#334155"
strokeWidth="1.5"
/>
{/* Deck */}
<rect x="32" y="42" width="36" height="3" fill="#475569" />
{/* Mast */}
<line x1="50" y1="42" x2="50" y2="25" stroke="#94a3b8" strokeWidth="2" />
{/* Sail */}
<path
d="M50,25 L65,30 L50,40 Z"
fill="#e0e7ff"
stroke="#818cf8"
strokeWidth="1"
className="animate-pulse-glow"
/>
{/* Charon silhouette */}
<circle cx="45" cy="38" r="3" fill="#334155" />
<rect x="44" y="41" width="2" height="4" fill="#334155" />
</g>
</svg>
</div>
)
}
/**
* CharonCoinLoader - Spinning Obol Coin animation (Payment to the Ferryman)
* Used for authentication/login operations
*/
export function CharonCoinLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-12 h-12',
md: 'w-20 h-20',
lg: 'w-28 h-28',
}
return (
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Authenticating">
<svg viewBox="0 0 100 100" className="w-full h-full">
{/* Outer glow */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="#f59e0b"
strokeWidth="1"
opacity="0.3"
className="animate-pulse"
/>
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="#fbbf24"
strokeWidth="1"
opacity="0.4"
className="animate-pulse"
style={{ animationDelay: '0.3s' }}
/>
{/* Spinning coin */}
<g className="animate-spin-y" style={{ transformOrigin: '50% 50%' }}>
{/* Coin face */}
<ellipse
cx="50"
cy="50"
rx="30"
ry="30"
fill="url(#goldGradient)"
stroke="#d97706"
strokeWidth="2"
/>
{/* Inner circle */}
<ellipse
cx="50"
cy="50"
rx="24"
ry="24"
fill="none"
stroke="#92400e"
strokeWidth="1.5"
/>
{/* Charon's boat symbol (simplified) */}
<path
d="M35,50 L40,45 L60,45 L65,50 L60,52 L40,52 Z"
fill="#78350f"
opacity="0.8"
/>
<line x1="50" y1="45" x2="50" y2="38" stroke="#78350f" strokeWidth="2" />
<path d="M50,38 L58,42 L50,46 Z" fill="#78350f" opacity="0.6" />
</g>
{/* Gradient definition */}
<defs>
<radialGradient id="goldGradient">
<stop offset="0%" stopColor="#fcd34d" />
<stop offset="50%" stopColor="#f59e0b" />
<stop offset="100%" stopColor="#d97706" />
</radialGradient>
</defs>
</svg>
</div>
)
}
/**
* CerberusLoader - Three-Headed Guardian animation
* Used for security operations (WAF, CrowdSec, ACL, Rate Limiting)
*/
export function CerberusLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-12 h-12',
md: 'w-20 h-20',
lg: 'w-28 h-28',
}
return (
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Security Loading">
<svg viewBox="0 0 100 100" className="w-full h-full">
{/* Shield background */}
<path
d="M50,10 L80,25 L80,50 Q80,75 50,90 Q20,75 20,50 L20,25 Z"
fill="#7f1d1d"
stroke="#991b1b"
strokeWidth="2"
className="animate-pulse"
/>
{/* Inner shield detail */}
<path
d="M50,15 L75,27 L75,50 Q75,72 50,85 Q25,72 25,50 L25,27 Z"
fill="none"
stroke="#dc2626"
strokeWidth="1.5"
opacity="0.6"
/>
{/* Three heads (simplified circles with animation) */}
{/* Left head */}
<g className="animate-rotate-head" style={{ transformOrigin: '35% 45%' }}>
<circle cx="35" cy="45" r="8" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
<circle cx="33" cy="43" r="1.5" fill="#fca5a5" />
<circle cx="37" cy="43" r="1.5" fill="#fca5a5" />
<path d="M32,48 Q35,50 38,48" stroke="#b91c1c" strokeWidth="1" fill="none" />
</g>
{/* Center head (larger) */}
<g className="animate-pulse-glow">
<circle cx="50" cy="42" r="10" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
<circle cx="47" cy="40" r="1.5" fill="#fca5a5" />
<circle cx="53" cy="40" r="1.5" fill="#fca5a5" />
<path d="M46,47 Q50,50 54,47" stroke="#b91c1c" strokeWidth="1.5" fill="none" />
</g>
{/* Right head */}
<g className="animate-rotate-head" style={{ transformOrigin: '65% 45%', animationDelay: '0.5s' }}>
<circle cx="65" cy="45" r="8" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
<circle cx="63" cy="43" r="1.5" fill="#fca5a5" />
<circle cx="67" cy="43" r="1.5" fill="#fca5a5" />
<path d="M62,48 Q65,50 68,48" stroke="#b91c1c" strokeWidth="1" fill="none" />
</g>
{/* Body */}
<ellipse cx="50" cy="65" rx="18" ry="12" fill="#7f1d1d" stroke="#991b1b" strokeWidth="1.5" />
{/* Paws */}
<circle cx="40" cy="72" r="4" fill="#991b1b" />
<circle cx="50" cy="72" r="4" fill="#991b1b" />
<circle cx="60" cy="72" r="4" fill="#991b1b" />
</svg>
</div>
)
}
/**
* ConfigReloadOverlay - Full-screen blocking overlay for Caddy configuration reloads
*
* Displays thematic loading animation based on operation type:
* - 'charon' (blue): Proxy hosts, certificates, general config operations
* - 'coin' (gold): Authentication/login operations
* - 'cerberus' (red): Security operations (WAF, CrowdSec, ACL, Rate Limiting)
*
* @param message - Primary message (e.g., "Ferrying new host...")
* @param submessage - Secondary context (e.g., "Charon is crossing the Styx")
* @param type - Theme variant: 'charon', 'coin', or 'cerberus'
*/
export function ConfigReloadOverlay({
message = 'Ferrying configuration...',
submessage = 'Charon is crossing the Styx',
type = 'charon',
}: {
message?: string
submessage?: string
type?: 'charon' | 'coin' | 'cerberus'
}) {
const Loader =
type === 'cerberus' ? CerberusLoader :
type === 'coin' ? CharonCoinLoader :
CharonLoader
const bgColor =
type === 'cerberus' ? 'bg-red-950/90' :
type === 'coin' ? 'bg-amber-950/90' :
'bg-blue-950/90'
const borderColor =
type === 'cerberus' ? 'border-red-900/50' :
type === 'coin' ? 'border-amber-900/50' :
'border-blue-900/50'
return (
<div className="fixed inset-0 bg-slate-900/70 backdrop-blur-sm flex items-center justify-center z-50">
<div className={`${bgColor} ${borderColor} border-2 rounded-lg p-8 flex flex-col items-center gap-4 shadow-2xl max-w-md mx-4`}>
<Loader size="lg" />
<div className="text-center">
<p className="text-slate-100 text-lg font-semibold mb-1">{message}</p>
<p className="text-slate-300 text-sm">{submessage}</p>
</div>
</div>
</div>
)
}
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
return (
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-slate-800 rounded-lg p-6 flex flex-col items-center gap-4 shadow-xl">
<LoadingSpinner size="lg" />
<p className="text-slate-300">{message}</p>
</div>
</div>
)
}
export function LoadingCard() {
return (
<div className="bg-slate-800 rounded-lg p-6 animate-pulse">
<div className="h-6 bg-slate-700 rounded w-1/3 mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-slate-700 rounded w-full"></div>
<div className="h-4 bg-slate-700 rounded w-5/6"></div>
<div className="h-4 bg-slate-700 rounded w-4/6"></div>
</div>
</div>
)
}
export function EmptyState({
icon = '📦',
title,
description,
action,
}: {
icon?: string
title: string
description: string
action?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="text-6xl mb-4">{icon}</div>
<h3 className="text-xl font-semibold text-slate-200 mb-2">{title}</h3>
<p className="text-slate-400 mb-6 max-w-md">{description}</p>
{action}
</div>
)
}

View File

@@ -1,112 +0,0 @@
import React from 'react';
import { Search, Download, RefreshCw } from 'lucide-react';
import { Button } from './ui/Button';
interface LogFiltersProps {
search: string;
onSearchChange: (value: string) => void;
status: string;
onStatusChange: (value: string) => void;
level: string;
onLevelChange: (value: string) => void;
host: string;
onHostChange: (value: string) => void;
sort: 'asc' | 'desc';
onSortChange: (value: 'asc' | 'desc') => void;
onRefresh: () => void;
onDownload: () => void;
isLoading: boolean;
}
export const LogFilters: React.FC<LogFiltersProps> = ({
search,
onSearchChange,
status,
onStatusChange,
level,
onLevelChange,
host,
onHostChange,
sort,
onSortChange,
onRefresh,
onDownload,
isLoading
}) => {
return (
<div className="flex flex-col md:flex-row gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-400" />
</div>
<input
type="text"
placeholder="Search logs..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="block w-full pl-10 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
/>
</div>
<div className="w-full md:w-48">
<input
type="text"
placeholder="Filter by Host"
value={host}
onChange={(e) => onHostChange(e.target.value)}
className="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
/>
</div>
<div className="w-full md:w-32">
<select
value={level}
onChange={(e) => onLevelChange(e.target.value)}
className="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
>
<option value="">All Levels</option>
<option value="DEBUG">Debug</option>
<option value="INFO">Info</option>
<option value="WARN">Warn</option>
<option value="ERROR">Error</option>
</select>
</div>
<div className="w-full md:w-32">
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
>
<option value="">All Status</option>
<option value="2xx">2xx Success</option>
<option value="3xx">3xx Redirect</option>
<option value="4xx">4xx Client Error</option>
<option value="5xx">5xx Server Error</option>
</select>
</div>
<div className="w-full md:w-32">
<select
value={sort}
onChange={(e) => onSortChange(e.target.value as 'asc' | 'desc')}
className="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
>
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
</div>
<div className="flex gap-2">
<Button onClick={onRefresh} variant="secondary" size="sm" isLoading={isLoading}>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button onClick={onDownload} variant="secondary" size="sm">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
</div>
);
};

View File

@@ -1,100 +0,0 @@
import React from 'react';
import { CaddyAccessLog } from '../api/logs';
import { format } from 'date-fns';
interface LogTableProps {
logs: CaddyAccessLog[];
isLoading: boolean;
}
export const LogTable: React.FC<LogTableProps> = ({ logs, isLoading }) => {
if (isLoading) {
return (
<div className="w-full h-64 flex items-center justify-center text-gray-500">
Loading logs...
</div>
);
}
if (!logs || logs.length === 0) {
return (
<div className="w-full h-64 flex items-center justify-center text-gray-500">
No logs found matching criteria.
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Time</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Method</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Host</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Path</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">IP</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Latency</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{logs.map((log, idx) => {
// Check if this is a structured access log or a plain text system log
const isAccessLog = log.status > 0 || (log.request && log.request.method);
if (!isAccessLog) {
return (
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{format(new Date(log.ts * 1000), 'MMM d HH:mm:ss')}
</td>
<td colSpan={7} className="px-6 py-4 text-sm text-gray-900 dark:text-white font-mono whitespace-pre-wrap break-all">
{log.msg}
</td>
</tr>
);
}
return (
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{format(new Date(log.ts * 1000), 'MMM d HH:mm:ss')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{log.status > 0 && (
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${log.status >= 500 ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' :
log.status >= 400 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
log.status >= 300 ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'}`}>
{log.status}
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{log.request?.method}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{log.request?.host}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate" title={log.request?.uri}>
{log.request?.uri}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{log.request?.remote_ip}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{log.duration > 0 ? (log.duration * 1000).toFixed(2) + 'ms' : ''}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate" title={log.msg}>
{log.msg}
</td>
</tr>
)})}
</tbody>
</table>
</div>
);
};

View File

@@ -1,156 +0,0 @@
import { useState, type FC } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Bell, X, Info, AlertTriangle, AlertCircle, CheckCircle, ExternalLink } from 'lucide-react';
import { getNotifications, markNotificationRead, markAllNotificationsRead, checkUpdates } from '../api/system';
const NotificationCenter: FC = () => {
const [isOpen, setIsOpen] = useState(false);
const queryClient = useQueryClient();
const { data: notifications = [] } = useQuery({
queryKey: ['notifications'],
queryFn: () => getNotifications(true),
refetchInterval: 30000, // Poll every 30s
});
const { data: updateInfo } = useQuery({
queryKey: ['system-updates'],
queryFn: checkUpdates,
staleTime: 1000 * 60 * 60, // 1 hour
});
const markReadMutation = useMutation({
mutationFn: markNotificationRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const markAllReadMutation = useMutation({
mutationFn: markAllNotificationsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const unreadCount = notifications.length + (updateInfo?.available ? 1 : 0);
const hasCritical = notifications.some(n => n.type === 'error');
const hasWarning = notifications.some(n => n.type === 'warning') || updateInfo?.available;
const getBellColor = () => {
if (hasCritical) return 'text-red-500 hover:text-red-600';
if (hasWarning) return 'text-yellow-500 hover:text-yellow-600';
return 'text-green-500 hover:text-green-600';
};
const getIcon = (type: string) => {
switch (type) {
case 'success': return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'warning': return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
case 'error': return <AlertCircle className="w-5 h-5 text-red-500" />;
default: return <Info className="w-5 h-5 text-blue-500" />;
}
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className={`relative p-2 focus:outline-none transition-colors ${getBellColor()}`}
aria-label="Notifications"
>
<Bell className="w-6 h-6" />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/4 -translate-y-1/4 bg-red-600 rounded-full">
{unreadCount}
</span>
)}
</button>
{isOpen && (
<>
<div
data-testid="notification-backdrop"
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
></div>
<div className="absolute right-0 z-20 w-80 mt-2 overflow-hidden bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 ring-black ring-opacity-5">
<div className="flex items-center justify-between px-4 py-2 border-b dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Notifications</h3>
{notifications.length > 0 && (
<button
onClick={() => markAllReadMutation.mutate()}
className="text-xs text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Mark all read
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{/* Update Notification */}
{updateInfo?.available && (
<div className="flex items-start px-4 py-3 border-b dark:border-gray-700 bg-yellow-50 dark:bg-yellow-900/10 hover:bg-yellow-100 dark:hover:bg-yellow-900/20">
<div className="flex-shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-yellow-500" />
</div>
<div className="ml-3 w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white">
Update Available: {updateInfo.latest_version}
</p>
<a
href={updateInfo.changelog_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 flex items-center"
>
View Changelog <ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
)}
{notifications.length === 0 && !updateInfo?.available ? (
<div className="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">
No new notifications
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className="flex items-start px-4 py-3 border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div className="flex-shrink-0 mt-0.5">
{getIcon(notification.type)}
</div>
<div className="ml-3 w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{notification.title}
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{notification.message}
</p>
<p className="mt-1 text-xs text-gray-400">
{new Date(notification.created_at).toLocaleString()}
</p>
</div>
<div className="ml-4 flex-shrink-0 flex">
<button
onClick={() => markReadMutation.mutate(notification.id)}
className="bg-white dark:bg-gray-800 rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none"
>
<span className="sr-only">Close</span>
<X className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
</div>
</>
)}
</div>
);
};
export default NotificationCenter;

View File

@@ -1,57 +0,0 @@
import React from 'react';
import { calculatePasswordStrength } from '../utils/passwordStrength';
interface Props {
password: string;
}
export const PasswordStrengthMeter: React.FC<Props> = ({ password }) => {
const { score, label, color, feedback } = calculatePasswordStrength(password);
// Calculate width percentage based on score (0-4)
// 0: 5%, 1: 25%, 2: 50%, 3: 75%, 4: 100%
const width = Math.max(5, (score / 4) * 100);
// Map color name to Tailwind classes
const getColorClass = (c: string) => {
switch (c) {
case 'red': return 'bg-red-500';
case 'yellow': return 'bg-yellow-500';
case 'green': return 'bg-green-500';
default: return 'bg-gray-300';
}
};
const getTextColorClass = (c: string) => {
switch (c) {
case 'red': return 'text-red-500';
case 'yellow': return 'text-yellow-600';
case 'green': return 'text-green-600';
default: return 'text-gray-500';
}
};
if (!password) return null;
return (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center text-xs">
<span className={`font-medium ${getTextColorClass(color)}`}>
{label}
</span>
{feedback.length > 0 && (
<span className="text-gray-500 dark:text-gray-400">
{feedback[0]}
</span>
)}
</div>
<div className="h-1.5 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ease-out ${getColorClass(color)}`}
style={{ width: `${width}%` }}
/>
</div>
</div>
);
};

View File

@@ -1,269 +0,0 @@
import { useState, useEffect } from 'react';
import { Plus, X, Code } from 'lucide-react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { NativeSelect } from './ui/NativeSelect';
import { Card } from './ui/Card';
import { Badge } from './ui/Badge';
import { Alert } from './ui/Alert';
interface PermissionsPolicyItem {
feature: string;
allowlist: string[];
}
interface PermissionsPolicyBuilderProps {
value: string; // JSON string of PermissionsPolicyItem[]
onChange: (value: string) => void;
}
const FEATURES = [
'accelerometer',
'ambient-light-sensor',
'autoplay',
'battery',
'camera',
'display-capture',
'document-domain',
'encrypted-media',
'fullscreen',
'geolocation',
'gyroscope',
'magnetometer',
'microphone',
'midi',
'payment',
'picture-in-picture',
'publickey-credentials-get',
'screen-wake-lock',
'sync-xhr',
'usb',
'web-share',
'xr-spatial-tracking',
];
const ALLOWLIST_PRESETS = [
{ label: 'None (disable)', value: '' },
{ label: 'Self', value: 'self' },
{ label: 'All (*)', value: '*' },
];
export function PermissionsPolicyBuilder({ value, onChange }: PermissionsPolicyBuilderProps) {
const [policies, setPolicies] = useState<PermissionsPolicyItem[]>([]);
const [newFeature, setNewFeature] = useState('camera');
const [newAllowlist, setNewAllowlist] = useState('');
const [customOrigin, setCustomOrigin] = useState('');
const [showPreview, setShowPreview] = useState(false);
// Parse initial value
useEffect(() => {
try {
if (value) {
const parsed = JSON.parse(value) as PermissionsPolicyItem[];
setPolicies(parsed);
} else {
setPolicies([]);
}
} catch {
setPolicies([]);
}
}, [value]);
// Generate Permissions-Policy string preview
const generatePolicyString = (pols: PermissionsPolicyItem[]): string => {
return pols
.map((pol) => {
if (pol.allowlist.length === 0) {
return `${pol.feature}=()`;
}
const allowlistStr = pol.allowlist.join(' ');
return `${pol.feature}=(${allowlistStr})`;
})
.join(', ');
};
const policyString = generatePolicyString(policies);
// Update parent component
const updatePolicies = (newPolicies: PermissionsPolicyItem[]) => {
setPolicies(newPolicies);
onChange(JSON.stringify(newPolicies));
};
const handleAddFeature = () => {
const existingIndex = policies.findIndex((p) => p.feature === newFeature);
let allowlist: string[] = [];
if (newAllowlist === 'self') {
allowlist = ['self'];
} else if (newAllowlist === '*') {
allowlist = ['*'];
} else if (customOrigin.trim()) {
allowlist = [customOrigin.trim()];
}
if (existingIndex >= 0) {
// Update existing
const updated = [...policies];
updated[existingIndex] = { feature: newFeature, allowlist };
updatePolicies(updated);
} else {
// Add new
updatePolicies([...policies, { feature: newFeature, allowlist }]);
}
setCustomOrigin('');
};
const handleRemoveFeature = (feature: string) => {
updatePolicies(policies.filter((p) => p.feature !== feature));
};
const handleQuickAdd = (features: string[]) => {
const newPolicies = features.map((feature) => ({
feature,
allowlist: [],
}));
// Merge with existing (don't duplicate)
const merged = [...policies];
newPolicies.forEach((newPolicy) => {
if (!merged.some((p) => p.feature === newPolicy.feature)) {
merged.push(newPolicy);
}
});
updatePolicies(merged);
};
return (
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Permissions Policy Builder</h3>
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
<Code className="w-4 h-4 mr-2" />
{showPreview ? 'Hide' : 'Show'} Preview
</Button>
</div>
{/* Quick Add Buttons */}
<div className="space-y-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Quick Add:</span>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleQuickAdd(['camera', 'microphone', 'geolocation'])}
>
Disable Common Features
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickAdd(['payment', 'usb', 'midi'])}
>
Disable Sensitive APIs
</Button>
</div>
</div>
{/* Add Feature Form */}
<div className="space-y-2">
<div className="flex gap-2">
<NativeSelect
value={newFeature}
onChange={(e) => setNewFeature(e.target.value)}
className="w-48"
>
{FEATURES.map((feature) => (
<option key={feature} value={feature}>
{feature}
</option>
))}
</NativeSelect>
<NativeSelect
value={newAllowlist}
onChange={(e) => setNewAllowlist(e.target.value)}
className="w-40"
>
{ALLOWLIST_PRESETS.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}
</option>
))}
</NativeSelect>
{newAllowlist === '' && (
<Input
type="text"
value={customOrigin}
onChange={(e) => setCustomOrigin(e.target.value)}
placeholder="or enter origin (e.g., https://example.com)"
className="flex-1"
/>
)}
<Button onClick={handleAddFeature}>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Current Policies */}
<div className="space-y-2">
{policies.length === 0 ? (
<Alert variant="info">
<span>No permissions policies configured. Add features above to restrict browser capabilities.</span>
</Alert>
) : (
policies.map((policy) => (
<div key={policy.feature} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-white flex-shrink-0">
{policy.feature}
</span>
<div className="flex-1">
{policy.allowlist.length === 0 ? (
<Badge variant="error">Disabled</Badge>
) : policy.allowlist.includes('*') ? (
<Badge variant="success">Allowed (all origins)</Badge>
) : policy.allowlist.includes('self') ? (
<Badge variant="outline">Self only</Badge>
) : (
<div className="flex flex-wrap gap-1">
{policy.allowlist.map((origin) => (
<Badge key={origin} variant="outline" className="font-mono text-xs">
{origin}
</Badge>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveFeature(policy.feature)}
>
<X className="w-4 h-4" />
</Button>
</div>
))
)}
</div>
{/* Policy String Preview */}
{showPreview && policyString && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Generated Permissions-Policy Header:</label>
<pre className="p-3 bg-gray-900 dark:bg-gray-950 text-green-400 rounded-lg overflow-x-auto text-xs font-mono">
{policyString || '(empty)'}
</pre>
</div>
)}
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,205 +0,0 @@
import { useEffect, useState } from 'react'
import { Loader2, Check, X, CircleHelp } from 'lucide-react'
import { type RemoteServer, testCustomRemoteServerConnection } from '../api/remoteServers'
interface Props {
server?: RemoteServer
onSubmit: (data: Partial<RemoteServer>) => Promise<void>
onCancel: () => void
}
export default function RemoteServerForm({ server, onSubmit, onCancel }: Props) {
const [formData, setFormData] = useState({
name: server?.name || '',
provider: server?.provider || 'generic',
host: server?.host || '',
port: server?.port ?? 22,
username: server?.username || '',
enabled: server?.enabled ?? true,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
useEffect(() => {
setFormData({
name: server?.name || '',
provider: server?.provider || 'generic',
host: server?.host || '',
port: server?.port ?? 22,
username: server?.username || '',
enabled: server?.enabled ?? true,
})
}, [server])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await onSubmit(formData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save server')
} finally {
setLoading(false)
}
}
const handleTestConnection = async () => {
if (!formData.host || !formData.port) return
setTestStatus('testing')
setError(null)
try {
const result = await testCustomRemoteServerConnection(formData.host, formData.port)
if (result.reachable) {
setTestStatus('success')
setTimeout(() => setTestStatus('idle'), 3000)
} else {
setTestStatus('error')
setError(`Connection failed: ${result.error || 'Unknown error'}`)
}
} catch {
setTestStatus('error')
setError('Connection failed')
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-lg w-full">
<div className="p-6 border-b border-gray-800">
<h2 className="text-2xl font-bold text-white">
{server ? 'Edit Remote Server' : 'Add Remote Server'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Name</label>
<input
type="text"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="My Production Server"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Provider</label>
<select
value={formData.provider}
onChange={e => {
const newProvider = e.target.value;
setFormData({
...formData,
provider: newProvider,
// Set default port for Docker
port: newProvider === 'docker' ? 2375 : (newProvider === 'generic' ? 22 : formData.port)
})
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="generic">Generic</option>
<option value="docker">Docker</option>
<option value="kubernetes">Kubernetes</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Host</label>
<input
type="text"
required
value={formData.host}
onChange={e => setFormData({ ...formData, host: e.target.value })}
placeholder="192.168.1.100"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Port</label>
<input
type="number"
min={1}
max={65535}
value={formData.port}
onChange={e => {
const v = parseInt(e.target.value)
setFormData({ ...formData, port: Number.isNaN(v) ? 0 : v })
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{formData.provider !== 'docker' && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input
type="text"
value={formData.username}
onChange={e => setFormData({ ...formData, username: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Enabled</span>
</label>
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
<button
type="button"
onClick={handleTestConnection}
disabled={testStatus === 'testing' || !formData.host || !formData.port}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 mr-auto ${
testStatus === 'success' ? 'bg-green-600 text-white' :
testStatus === 'error' ? 'bg-red-600 text-white' :
'bg-gray-700 hover:bg-gray-600 text-white'
}`}
>
{testStatus === 'testing' ? <Loader2 className="w-4 h-4 animate-spin" /> :
testStatus === 'success' ? <Check className="w-4 h-4" /> :
testStatus === 'error' ? <X className="w-4 h-4" /> :
<CircleHelp className="w-4 h-4" />}
Test Connection
</button>
<button
type="button"
onClick={onCancel}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : (server ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { LoadingOverlay } from './LoadingStates';
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <LoadingOverlay message="Authenticating..." />; // Consistent loading UX
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
export default RequireAuth;

View File

@@ -1,467 +0,0 @@
import { useState, useEffect } from 'react';
import { AlertTriangle, Save, X } from 'lucide-react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { Textarea } from './ui/Textarea';
import { Switch } from './ui/Switch';
import { NativeSelect } from './ui/NativeSelect';
import { Card } from './ui/Card';
import { Alert } from './ui/Alert';
import { CSPBuilder } from './CSPBuilder';
import { PermissionsPolicyBuilder } from './PermissionsPolicyBuilder';
import { SecurityScoreDisplay } from './SecurityScoreDisplay';
import { useCalculateSecurityScore } from '../hooks/useSecurityHeaders';
import type { SecurityHeaderProfile, CreateProfileRequest } from '../api/securityHeaders';
interface SecurityHeaderProfileFormProps {
initialData?: SecurityHeaderProfile;
onSubmit: (data: CreateProfileRequest) => void;
onCancel: () => void;
onDelete?: () => void;
isLoading?: boolean;
isDeleting?: boolean;
}
export function SecurityHeaderProfileForm({
initialData,
onSubmit,
onCancel,
onDelete,
isLoading,
isDeleting,
}: SecurityHeaderProfileFormProps) {
const [formData, setFormData] = useState<CreateProfileRequest>({
name: initialData?.name || '',
description: initialData?.description || '',
hsts_enabled: initialData?.hsts_enabled ?? true,
hsts_max_age: initialData?.hsts_max_age || 31536000,
hsts_include_subdomains: initialData?.hsts_include_subdomains ?? true,
hsts_preload: initialData?.hsts_preload ?? false,
csp_enabled: initialData?.csp_enabled ?? false,
csp_directives: initialData?.csp_directives || '',
csp_report_only: initialData?.csp_report_only ?? false,
csp_report_uri: initialData?.csp_report_uri || '',
x_frame_options: initialData?.x_frame_options || 'DENY',
x_content_type_options: initialData?.x_content_type_options ?? true,
referrer_policy: initialData?.referrer_policy || 'strict-origin-when-cross-origin',
permissions_policy: initialData?.permissions_policy || '',
cross_origin_opener_policy: initialData?.cross_origin_opener_policy || 'same-origin',
cross_origin_resource_policy: initialData?.cross_origin_resource_policy || 'same-origin',
cross_origin_embedder_policy: initialData?.cross_origin_embedder_policy || '',
xss_protection: initialData?.xss_protection ?? true,
cache_control_no_store: initialData?.cache_control_no_store ?? false,
});
const [cspValid, setCspValid] = useState(true);
const [, setCspErrors] = useState<string[]>([]);
const calculateScoreMutation = useCalculateSecurityScore();
const { mutate: calculateScore } = calculateScoreMutation;
// Calculate score when form data changes
useEffect(() => {
const timer = setTimeout(() => {
calculateScore(formData);
}, 500);
return () => clearTimeout(timer);
}, [formData, calculateScore]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
return;
}
onSubmit(formData);
};
const updateField = <K extends keyof CreateProfileRequest>(
field: K,
value: CreateProfileRequest[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const isPreset = initialData?.is_preset ?? false;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Profile Name *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="e.g., Production Security Headers"
required
disabled={isPreset}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<Textarea
value={formData.description || ''}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Optional description of this security profile..."
rows={2}
disabled={isPreset}
/>
</div>
{isPreset && (
<Alert variant="info">
This is a system preset and cannot be modified. Clone it to create a custom profile.
</Alert>
)}
</Card>
{/* Live Security Score */}
{calculateScoreMutation.data && (
<SecurityScoreDisplay
score={calculateScoreMutation.data.score}
maxScore={calculateScoreMutation.data.max_score}
breakdown={calculateScoreMutation.data.breakdown}
suggestions={calculateScoreMutation.data.suggestions}
size="md"
showDetails={true}
/>
)}
{/* HSTS Section */}
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
HTTP Strict Transport Security (HSTS)
</h3>
<Switch
checked={formData.hsts_enabled}
onCheckedChange={(checked) => updateField('hsts_enabled', checked)}
disabled={isPreset}
/>
</div>
{formData.hsts_enabled && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Age (seconds)
</label>
<Input
type="number"
value={formData.hsts_max_age}
onChange={(e) => updateField('hsts_max_age', parseInt(e.target.value) || 0)}
min={0}
disabled={isPreset}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Recommended: 31536000 (1 year) or 63072000 (2 years)
</p>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Include Subdomains
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Apply HSTS to all subdomains
</p>
</div>
<Switch
checked={formData.hsts_include_subdomains}
onCheckedChange={(checked) => updateField('hsts_include_subdomains', checked)}
disabled={isPreset}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Preload
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Submit to browser preload lists
</p>
</div>
<Switch
checked={formData.hsts_preload}
onCheckedChange={(checked) => updateField('hsts_preload', checked)}
disabled={isPreset}
/>
</div>
{formData.hsts_preload && (
<Alert variant="warning">
<AlertTriangle className="w-4 h-4" />
<div>
<p className="font-semibold">Warning: HSTS Preload is Permanent</p>
<p className="text-sm mt-1">
Once submitted to browser preload lists, removal can take months. Only enable if you're
committed to HTTPS forever.
</p>
</div>
</Alert>
)}
</>
)}
</Card>
{/* CSP Section */}
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Content Security Policy (CSP)
</h3>
<Switch
checked={formData.csp_enabled}
onCheckedChange={(checked) => updateField('csp_enabled', checked)}
disabled={isPreset}
/>
</div>
{formData.csp_enabled && (
<>
<CSPBuilder
value={formData.csp_directives || ''}
onChange={(value) => updateField('csp_directives', value)}
onValidate={(valid, errors) => {
setCspValid(valid);
setCspErrors(errors);
}}
/>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Report-Only Mode
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Test CSP without blocking content
</p>
</div>
<Switch
checked={formData.csp_report_only}
onCheckedChange={(checked) => updateField('csp_report_only', checked)}
disabled={isPreset}
/>
</div>
{formData.csp_report_only && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Report URI (optional)
</label>
<Input
type="url"
value={formData.csp_report_uri || ''}
onChange={(e) => updateField('csp_report_uri', e.target.value)}
placeholder="https://example.com/csp-report"
disabled={isPreset}
/>
</div>
)}
</>
)}
</Card>
{/* Frame Options */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Clickjacking Protection</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
X-Frame-Options
</label>
<NativeSelect
value={formData.x_frame_options}
onChange={(e) => updateField('x_frame_options', e.target.value)}
disabled={isPreset}
>
<option value="DENY">DENY (Recommended - no framing allowed)</option>
<option value="SAMEORIGIN">SAMEORIGIN (allow same origin framing)</option>
<option value="">None (allow all framing - not recommended)</option>
</NativeSelect>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
X-Content-Type-Options: nosniff
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Prevent MIME type sniffing attacks
</p>
</div>
<Switch
checked={formData.x_content_type_options}
onCheckedChange={(checked) => updateField('x_content_type_options', checked)}
disabled={isPreset}
/>
</div>
</Card>
{/* Privacy Headers */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Privacy Controls</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Referrer-Policy
</label>
<NativeSelect
value={formData.referrer_policy}
onChange={(e) => updateField('referrer_policy', e.target.value)}
disabled={isPreset}
>
<option value="no-referrer">no-referrer (Most Private)</option>
<option value="no-referrer-when-downgrade">no-referrer-when-downgrade</option>
<option value="origin">origin</option>
<option value="origin-when-cross-origin">origin-when-cross-origin</option>
<option value="same-origin">same-origin</option>
<option value="strict-origin">strict-origin</option>
<option value="strict-origin-when-cross-origin">strict-origin-when-cross-origin (Recommended)</option>
<option value="unsafe-url">unsafe-url (Least Private)</option>
</NativeSelect>
</div>
</Card>
{/* Permissions Policy */}
<PermissionsPolicyBuilder
value={formData.permissions_policy || ''}
onChange={(value) => updateField('permissions_policy', value)}
/>
{/* Cross-Origin Headers */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Cross-Origin Isolation</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Opener-Policy
</label>
<NativeSelect
value={formData.cross_origin_opener_policy}
onChange={(e) => updateField('cross_origin_opener_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None</option>
<option value="unsafe-none">unsafe-none</option>
<option value="same-origin-allow-popups">same-origin-allow-popups</option>
<option value="same-origin">same-origin (Recommended)</option>
</NativeSelect>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Resource-Policy
</label>
<NativeSelect
value={formData.cross_origin_resource_policy}
onChange={(e) => updateField('cross_origin_resource_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None</option>
<option value="same-site">same-site</option>
<option value="same-origin">same-origin (Recommended)</option>
<option value="cross-origin">cross-origin</option>
</NativeSelect>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Embedder-Policy
</label>
<NativeSelect
value={formData.cross_origin_embedder_policy}
onChange={(e) => updateField('cross_origin_embedder_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None (Default)</option>
<option value="require-corp">require-corp (Strict)</option>
</NativeSelect>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Only enable if you need SharedArrayBuffer or high-resolution timers
</p>
</div>
</Card>
{/* Additional Options */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Additional Options</h3>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
X-XSS-Protection
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Legacy XSS protection header
</p>
</div>
<Switch
checked={formData.xss_protection}
onCheckedChange={(checked) => updateField('xss_protection', checked)}
disabled={isPreset}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Cache-Control: no-store
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Prevent caching of sensitive content
</p>
</div>
<Switch
checked={formData.cache_control_no_store}
onCheckedChange={(checked) => updateField('cache_control_no_store', checked)}
disabled={isPreset}
/>
</div>
</Card>
{/* Form Actions */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<div>
{onDelete && !isPreset && (
<Button
type="button"
variant="danger"
onClick={onDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Profile'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || isPreset || (!cspValid && formData.csp_enabled)}
>
<Save className="w-4 h-4 mr-2" />
{isLoading ? 'Saving...' : 'Save Profile'}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -1,233 +0,0 @@
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { Button } from './ui/Button';
import { Switch } from './ui/Switch';
import {
useSecurityNotificationSettings,
useUpdateSecurityNotificationSettings,
} from '../hooks/useNotifications';
interface SecurityNotificationSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SecurityNotificationSettingsModal({
isOpen,
onClose,
}: SecurityNotificationSettingsModalProps) {
const { data: settings, isLoading } = useSecurityNotificationSettings();
const updateMutation = useUpdateSecurityNotificationSettings();
const [formData, setFormData] = useState({
enabled: false,
min_log_level: 'warn',
notify_waf_blocks: true,
notify_acl_denials: true,
notify_rate_limit_hits: true,
webhook_url: '',
email_recipients: '',
});
useEffect(() => {
if (settings) {
setFormData({
enabled: settings.enabled,
min_log_level: settings.min_log_level,
notify_waf_blocks: settings.notify_waf_blocks,
notify_acl_denials: settings.notify_acl_denials,
notify_rate_limit_hits: settings.notify_rate_limit_hits,
webhook_url: settings.webhook_url || '',
email_recipients: settings.email_recipients || '',
});
}
}, [settings]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(formData, {
onSuccess: () => {
onClose();
},
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-semibold text-white">Security Notification Settings</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{isLoading && (
<div className="text-center text-gray-400">Loading settings...</div>
)}
{!isLoading && (
<>
{/* Master Toggle */}
<div className="flex items-center justify-between">
<div>
<label htmlFor="enable-notifications" className="text-sm font-medium text-white">Enable Notifications</label>
<p className="text-xs text-gray-400 mt-1">
Receive alerts when security events occur
</p>
</div>
<Switch
id="enable-notifications"
checked={formData.enabled}
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
/>
</div>
{/* Minimum Log Level */}
<div>
<label htmlFor="min-log-level" className="block text-sm font-medium text-white mb-2">
Minimum Log Level
</label>
<select
id="min-log-level"
value={formData.min_log_level}
onChange={(e) => setFormData({ ...formData, min_log_level: e.target.value })}
disabled={!formData.enabled}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
>
<option value="debug">Debug (All logs)</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="fatal">Fatal (Critical only)</option>
</select>
<p className="text-xs text-gray-400 mt-1">
Only logs at this level or higher will trigger notifications
</p>
</div>
{/* Event Type Filters */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-white">Notify On:</h3>
<div className="flex items-center justify-between">
<div>
<label htmlFor="notify-waf" className="text-sm text-white">WAF Blocks</label>
<p className="text-xs text-gray-400">
When the Web Application Firewall blocks a request
</p>
</div>
<Switch
id="notify-waf"
checked={formData.notify_waf_blocks}
onChange={(e) =>
setFormData({ ...formData, notify_waf_blocks: e.target.checked })
}
disabled={!formData.enabled}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label htmlFor="notify-acl" className="text-sm text-white">ACL Denials</label>
<p className="text-xs text-gray-400">
When an IP is denied by Access Control Lists
</p>
</div>
<Switch
id="notify-acl"
checked={formData.notify_acl_denials}
onChange={(e) =>
setFormData({ ...formData, notify_acl_denials: e.target.checked })
}
disabled={!formData.enabled}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label htmlFor="notify-rate-limit" className="text-sm text-white">Rate Limit Hits</label>
<p className="text-xs text-gray-400">
When a client exceeds rate limiting thresholds
</p>
</div>
<Switch
id="notify-rate-limit"
checked={formData.notify_rate_limit_hits}
onChange={(e) =>
setFormData({ ...formData, notify_rate_limit_hits: e.target.checked })
}
disabled={!formData.enabled}
/>
</div>
</div>
{/* Webhook URL (optional, for future use) */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Webhook URL (Optional)
</label>
<input
type="url"
value={formData.webhook_url}
onChange={(e) => setFormData({ ...formData, webhook_url: e.target.value })}
placeholder="https://your-webhook-endpoint.com/alert"
disabled={!formData.enabled}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<p className="text-xs text-gray-400 mt-1">
POST requests will be sent to this URL when events occur
</p>
</div>
{/* Email Recipients (optional, for future use) */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Email Recipients (Optional)
</label>
<input
type="text"
value={formData.email_recipients}
onChange={(e) => setFormData({ ...formData, email_recipients: e.target.value })}
placeholder="admin@example.com, security@example.com"
disabled={!formData.enabled}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<p className="text-xs text-gray-400 mt-1">
Comma-separated email addresses
</p>
</div>
</>
)}
{/* Footer */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700">
<Button variant="secondary" onClick={onClose} type="button">
Cancel
</Button>
<Button
variant="primary"
type="submit"
isLoading={updateMutation.isPending}
disabled={isLoading}
>
Save Settings
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,209 +0,0 @@
import { useState } from 'react';
import { Shield, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
import { Card } from './ui/Card';
import { Badge } from './ui/Badge';
import { Progress } from './ui/Progress';
interface SecurityScoreDisplayProps {
score: number;
maxScore?: number;
breakdown?: Record<string, number>;
suggestions?: string[];
size?: 'sm' | 'md' | 'lg';
showDetails?: boolean;
}
const CATEGORY_LABELS: Record<string, string> = {
hsts: 'HSTS',
csp: 'Content Security Policy',
x_frame_options: 'X-Frame-Options',
x_content_type_options: 'X-Content-Type-Options',
referrer_policy: 'Referrer Policy',
permissions_policy: 'Permissions Policy',
cross_origin: 'Cross-Origin Headers',
};
const CATEGORY_DESCRIPTIONS: Record<string, string> = {
hsts: 'HTTP Strict Transport Security enforces HTTPS connections',
csp: 'Content Security Policy prevents XSS and injection attacks',
x_frame_options: 'Prevents clickjacking by controlling iframe embedding',
x_content_type_options: 'Prevents MIME type sniffing attacks',
referrer_policy: 'Controls referrer information sent with requests',
permissions_policy: 'Restricts browser features and APIs',
cross_origin: 'Cross-Origin isolation headers for enhanced security',
};
export function SecurityScoreDisplay({
score,
maxScore = 100,
breakdown = {},
suggestions = [],
size = 'md',
showDetails = true,
}: SecurityScoreDisplayProps) {
const [expandedBreakdown, setExpandedBreakdown] = useState(false);
const [expandedSuggestions, setExpandedSuggestions] = useState(false);
const percentage = Math.round((score / maxScore) * 100);
const getScoreColor = () => {
if (percentage >= 75) return 'text-green-600 dark:text-green-400';
if (percentage >= 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
};
const getScoreBgColor = () => {
if (percentage >= 75) return 'bg-green-100 dark:bg-green-900/20';
if (percentage >= 50) return 'bg-yellow-100 dark:bg-yellow-900/20';
return 'bg-red-100 dark:bg-red-900/20';
};
const getScoreVariant = (): 'success' | 'warning' | 'error' => {
if (percentage >= 75) return 'success';
if (percentage >= 50) return 'warning';
return 'error';
};
const sizeClasses = {
sm: 'w-12 h-12 text-sm',
md: 'w-20 h-20 text-2xl',
lg: 'w-32 h-32 text-4xl',
};
if (size === 'sm') {
return (
<div className="flex items-center gap-2">
<div
className={`${sizeClasses[size]} rounded-full ${getScoreBgColor()} flex items-center justify-center font-bold ${getScoreColor()}`}
>
{score}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">/ {maxScore}</div>
</div>
);
}
return (
<Card className="p-4">
<div className="flex items-start gap-4">
{/* Circular Score Display */}
<div className="flex-shrink-0">
<div
className={`${sizeClasses[size]} rounded-full ${getScoreBgColor()} flex flex-col items-center justify-center font-bold ${getScoreColor()}`}
>
<div className="flex items-baseline">
<span>{score}</span>
<span className="text-sm opacity-75">/{maxScore}</span>
</div>
<div className="text-xs font-normal opacity-75">Security</div>
</div>
</div>
{/* Score Info */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Shield className="w-5 h-5 text-gray-400" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Security Score</h3>
<Badge variant={getScoreVariant()}>{percentage}%</Badge>
</div>
{/* Overall Progress Bar */}
<Progress value={percentage} variant={getScoreVariant()} className="mb-4" />
{showDetails && (
<>
{/* Breakdown Section */}
{Object.keys(breakdown).length > 0 && (
<div className="mt-4">
<button
onClick={() => setExpandedBreakdown(!expandedBreakdown)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
{expandedBreakdown ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
Score Breakdown by Category
</button>
{expandedBreakdown && (
<div className="mt-3 space-y-3 pl-6">
{Object.entries(breakdown).map(([category, categoryScore]) => {
const categoryMax = getCategoryMax(category);
const categoryPercent = Math.round((categoryScore / categoryMax) * 100);
return (
<div key={category} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span
className="text-gray-700 dark:text-gray-300"
title={CATEGORY_DESCRIPTIONS[category]}
>
{CATEGORY_LABELS[category] || category}
</span>
<span className="text-gray-600 dark:text-gray-400 font-mono">
{categoryScore}/{categoryMax}
</span>
</div>
<Progress
value={categoryPercent}
variant={categoryPercent >= 70 ? 'success' : categoryPercent >= 40 ? 'warning' : 'error'}
/>
</div>
);
})}
</div>
)}
</div>
)}
{/* Suggestions Section */}
{suggestions.length > 0 && (
<div className="mt-4">
<button
onClick={() => setExpandedSuggestions(!expandedSuggestions)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
{expandedSuggestions ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
Security Suggestions ({suggestions.length})
</button>
{expandedSuggestions && (
<ul className="mt-3 space-y-2 pl-6">
{suggestions.map((suggestion, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<AlertCircle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
<span>{suggestion}</span>
</li>
))}
</ul>
)}
</div>
)}
</>
)}
</div>
</div>
</Card>
);
}
// Helper function to determine max score for each category
function getCategoryMax(category: string): number {
const maxScores: Record<string, number> = {
hsts: 25,
csp: 25,
x_frame_options: 10,
x_content_type_options: 10,
referrer_policy: 10,
permissions_policy: 10,
cross_origin: 10,
};
return maxScores[category] || 10;
}

View File

@@ -1,38 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getSetupStatus } from '../api/setup';
interface SetupGuardProps {
children: React.ReactNode;
}
export const SetupGuard: React.FC<SetupGuardProps> = ({ children }) => {
const navigate = useNavigate();
const { data: status, isLoading } = useQuery({
queryKey: ['setupStatus'],
queryFn: getSetupStatus,
retry: false,
});
useEffect(() => {
if (status?.setupRequired) {
navigate('/setup');
}
}, [status, navigate]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="text-blue-500">Loading...</div>
</div>
);
}
if (status?.setupRequired) {
return null; // Will redirect
}
return <>{children}</>;
};

View File

@@ -1,17 +0,0 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { checkUpdates } from '../api/system';
const SystemStatus: React.FC = () => {
// We still query for updates here to keep the cache fresh,
// but the UI is now handled by NotificationCenter
useQuery({
queryKey: ['system-updates'],
queryFn: checkUpdates,
staleTime: 1000 * 60 * 60, // 1 hour
});
return null;
};
export default SystemStatus;

View File

@@ -1,12 +0,0 @@
import { useTheme } from '../hooks/useTheme'
import { Button } from './ui/Button'
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<Button variant="ghost" size="sm" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
{theme === 'dark' ? '☀️' : '🌙'}
</Button>
)
}

View File

@@ -1,60 +0,0 @@
import { useEffect, useState } from 'react'
import { toastCallbacks, Toast } from '../utils/toast'
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
useEffect(() => {
const callback = (toast: Toast) => {
setToasts(prev => [...prev, toast])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== toast.id))
}, 5000)
}
toastCallbacks.add(callback)
return () => {
toastCallbacks.delete(callback)
}
}, [])
const removeToast = (id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none" data-testid="toast-container">
{toasts.map(toast => (
<div
key={toast.id}
role="status"
aria-live="polite"
data-testid={`toast-${toast.type}`}
className={`pointer-events-auto px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] animate-slide-in ${
toast.type === 'success'
? 'bg-green-600 text-white'
: toast.type === 'error'
? 'bg-red-600 text-white'
: toast.type === 'warning'
? 'bg-yellow-600 text-white'
: 'bg-blue-600 text-white'
}`}
>
<div className="flex-1">
{toast.type === 'success' && <span className="mr-2"></span>}
{toast.type === 'error' && <span className="mr-2"></span>}
{toast.type === 'warning' && <span className="mr-2"></span>}
{toast.type === 'info' && <span className="mr-2"></span>}
{toast.message}
</div>
<button
onClick={() => removeToast(toast.id)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Close"
>
×
</button>
</div>
))}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More