chore: git cache cleanup
This commit is contained in:
150
frontend/src/App.tsx
Normal file
150
frontend/src/App.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
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 RequireRole from './components/RequireRole'
|
||||
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 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'))
|
||||
const PassthroughLanding = lazy(() => import('./pages/PassthroughLanding'))
|
||||
|
||||
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="/passthrough" element={
|
||||
<RequireAuth>
|
||||
<PassthroughLanding />
|
||||
</RequireAuth>
|
||||
} />
|
||||
<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 />} />
|
||||
|
||||
{/* Legacy redirects for old user management paths */}
|
||||
<Route path="users" element={<Navigate to="/settings/users" replace />} />
|
||||
<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={<RequireRole allowed={['admin', 'user']}><Settings /></RequireRole>}>
|
||||
<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="users" element={<RequireRole allowed={['admin']}><UsersPage /></RequireRole>} />
|
||||
{/* Legacy redirects */}
|
||||
<Route path="account" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="account-management" element={<Navigate to="/settings/users" replace />} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
59
frontend/src/__tests__/i18n.test.ts
Normal file
59
frontend/src/__tests__/i18n.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
179
frontend/src/api/__tests__/accessLists.test.ts
Normal file
179
frontend/src/api/__tests__/accessLists.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
frontend/src/api/__tests__/backups.test.ts
Normal file
34
frontend/src/api/__tests__/backups.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
52
frontend/src/api/__tests__/certificates.test.ts
Normal file
52
frontend/src/api/__tests__/certificates.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
205
frontend/src/api/__tests__/client.test.ts
Normal file
205
frontend/src/api/__tests__/client.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
type ResponseHandler = (value: unknown) => unknown
|
||||
type ErrorHandler = (error: ResponseError) => Promise<never>
|
||||
|
||||
type ResponseError = {
|
||||
response?: {
|
||||
status?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
config?: {
|
||||
url?: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Use vi.hoisted() to declare variables accessible in hoisted mocks
|
||||
const capturedHandlers = vi.hoisted(() => ({
|
||||
onFulfilled: undefined as ResponseHandler | undefined,
|
||||
onRejected: undefined as ErrorHandler | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('axios', () => {
|
||||
const mockClient = {
|
||||
defaults: {
|
||||
headers: {
|
||||
common: {} as Record<string, string>,
|
||||
},
|
||||
},
|
||||
interceptors: {
|
||||
response: {
|
||||
use: vi.fn((onFulfilled?: ResponseHandler, onRejected?: ErrorHandler) => {
|
||||
capturedHandlers.onFulfilled = onFulfilled
|
||||
capturedHandlers.onRejected = onRejected
|
||||
return vi.fn()
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
create: vi.fn(() => mockClient),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Must import AFTER mock definition
|
||||
import { setAuthErrorHandler, setAuthToken } from '../client'
|
||||
import axios from 'axios'
|
||||
|
||||
// Get mock client instance for header assertions
|
||||
const getMockClient = () => {
|
||||
const mockAxios = vi.mocked(axios)
|
||||
return mockAxios.create()
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sets and clears the Authorization header', () => {
|
||||
const mockClientInstance = getMockClient()
|
||||
|
||||
setAuthToken('test-token')
|
||||
expect(mockClientInstance.defaults.headers.common.Authorization).toBe('Bearer test-token')
|
||||
|
||||
setAuthToken(null)
|
||||
expect(mockClientInstance.defaults.headers.common.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('extracts error message from response payload', async () => {
|
||||
const error: ResponseError = {
|
||||
response: { data: { error: 'Bad request' } },
|
||||
config: { url: '/test' },
|
||||
message: 'Original',
|
||||
}
|
||||
|
||||
const handler = capturedHandlers.onRejected
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
|
||||
|
||||
await expect(resultPromise).rejects.toBe(error)
|
||||
expect(error.message).toBe('Bad request')
|
||||
})
|
||||
|
||||
it('keeps original message when response payload is not an object', async () => {
|
||||
const error: ResponseError = {
|
||||
response: { data: 'plain text error' as unknown as Record<string, unknown> },
|
||||
config: { url: '/test' },
|
||||
message: 'Original',
|
||||
}
|
||||
|
||||
const handler = capturedHandlers.onRejected
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
|
||||
|
||||
await expect(resultPromise).rejects.toBe(error)
|
||||
expect(error.message).toBe('Original')
|
||||
})
|
||||
|
||||
it('uses error field over message field when both exist', async () => {
|
||||
const error: ResponseError = {
|
||||
response: { data: { error: 'Preferred error', message: 'Secondary message' } },
|
||||
config: { url: '/test' },
|
||||
message: 'Original',
|
||||
}
|
||||
|
||||
const handler = capturedHandlers.onRejected
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
|
||||
|
||||
await expect(resultPromise).rejects.toBe(error)
|
||||
expect(error.message).toBe('Preferred error')
|
||||
})
|
||||
|
||||
it('invokes auth error handler on 401 outside auth endpoints', async () => {
|
||||
const onAuthError = vi.fn()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
setAuthErrorHandler(onAuthError)
|
||||
|
||||
const error: ResponseError = {
|
||||
response: { status: 401, data: { message: 'Unauthorized' } },
|
||||
config: { url: '/proxy-hosts' },
|
||||
message: 'Original',
|
||||
}
|
||||
|
||||
const handler = capturedHandlers.onRejected
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
|
||||
|
||||
await expect(resultPromise).rejects.toBe(error)
|
||||
expect(onAuthError).toHaveBeenCalledTimes(1)
|
||||
expect(error.message).toBe('Unauthorized')
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('skips auth error handler for auth endpoints', async () => {
|
||||
const onAuthError = vi.fn()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
setAuthErrorHandler(onAuthError)
|
||||
|
||||
const error: ResponseError = {
|
||||
response: { status: 401, data: { message: 'Unauthorized' } },
|
||||
config: { url: '/auth/login' },
|
||||
message: 'Original',
|
||||
}
|
||||
|
||||
const handler = capturedHandlers.onRejected
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
// Call handler with auth endpoint error to verify it skips the auth error handler
|
||||
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
|
||||
|
||||
await expect(resultPromise).rejects.toBe(error)
|
||||
expect(onAuthError).not.toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not invoke auth error handler when status is not 401', async () => {
|
||||
const onAuthError = vi.fn()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
setAuthErrorHandler(onAuthError)
|
||||
|
||||
const error: ResponseError = {
|
||||
response: { status: 403, data: { message: 'Forbidden' } },
|
||||
config: { url: '/proxy-hosts' },
|
||||
message: 'Original',
|
||||
}
|
||||
|
||||
const handler = capturedHandlers.onRejected
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
|
||||
|
||||
await expect(resultPromise).rejects.toBe(error)
|
||||
expect(onAuthError).not.toHaveBeenCalled()
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('passes through successful responses via fulfilled interceptor', () => {
|
||||
const responsePayload = { data: { ok: true } }
|
||||
const fulfilled = capturedHandlers.onFulfilled
|
||||
|
||||
expect(fulfilled).toBeDefined()
|
||||
const result = fulfilled ? fulfilled(responsePayload) : undefined
|
||||
expect(result).toBe(responsePayload)
|
||||
})
|
||||
})
|
||||
507
frontend/src/api/__tests__/consoleEnrollment.test.ts
Normal file
507
frontend/src/api/__tests__/consoleEnrollment.test.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
119
frontend/src/api/__tests__/credentials.test.ts
Normal file
119
frontend/src/api/__tests__/credentials.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
130
frontend/src/api/__tests__/crowdsec.test.ts
Normal file
130
frontend/src/api/__tests__/crowdsec.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
138
frontend/src/api/__tests__/dnsDetection.test.ts
Normal file
138
frontend/src/api/__tests__/dnsDetection.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
431
frontend/src/api/__tests__/dnsProviders.test.ts
Normal file
431
frontend/src/api/__tests__/dnsProviders.test.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
96
frontend/src/api/__tests__/docker.test.ts
Normal file
96
frontend/src/api/__tests__/docker.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
frontend/src/api/__tests__/domains.test.ts
Normal file
44
frontend/src/api/__tests__/domains.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
95
frontend/src/api/__tests__/encryption.test.ts
Normal file
95
frontend/src/api/__tests__/encryption.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
133
frontend/src/api/__tests__/import.test.ts
Normal file
133
frontend/src/api/__tests__/import.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { uploadCaddyfile, uploadCaddyfilesMulti, getImportPreview, commitImport, cancelImport, getImportStatus } from '../import';
|
||||
import client from '../client';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('import API', () => {
|
||||
const mockedGet = vi.mocked(client.get);
|
||||
const mockedPost = vi.mocked(client.post);
|
||||
const mockedDelete = vi.mocked(client.delete);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uploadCaddyfile posts content', async () => {
|
||||
const content = 'example.com';
|
||||
const mockResponse = { preview: { hosts: [] } };
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await uploadCaddyfile(content);
|
||||
expect(client.post).toHaveBeenCalledWith('/import/upload', { content });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('uploadCaddyfilesMulti posts files', async () => {
|
||||
const files = [{ filename: 'Caddyfile', content: 'foo.com' }];
|
||||
const mockResponse = { preview: { hosts: [] } };
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await uploadCaddyfilesMulti(files);
|
||||
expect(client.post).toHaveBeenCalledWith('/import/upload-multi', { files });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('uploadCaddyfilesMulti accepts empty file arrays', async () => {
|
||||
mockedPost.mockResolvedValue({ data: { preview: { hosts: [], conflicts: [], errors: [] } } });
|
||||
|
||||
const result = await uploadCaddyfilesMulti([]);
|
||||
expect(client.post).toHaveBeenCalledWith('/import/upload-multi', { files: [] });
|
||||
expect(result).toEqual({ preview: { hosts: [], conflicts: [], errors: [] } });
|
||||
});
|
||||
|
||||
it('getImportPreview gets preview', async () => {
|
||||
const mockResponse = { preview: { hosts: [] } };
|
||||
mockedGet.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await getImportPreview();
|
||||
expect(client.get).toHaveBeenCalledWith('/import/preview');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('commitImport posts commitments', async () => {
|
||||
const sessionUUID = 'uuid-123';
|
||||
const resolutions = { 'foo.com': 'keep' };
|
||||
const names = { 'foo.com': 'My Site' };
|
||||
const mockResponse = { created: 1, updated: 0, skipped: 0, errors: [] };
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await commitImport(sessionUUID, resolutions, names);
|
||||
expect(client.post).toHaveBeenCalledWith('/import/commit', {
|
||||
session_uuid: sessionUUID,
|
||||
resolutions,
|
||||
names
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('cancelImport deletes cancel with required session_uuid query', async () => {
|
||||
const sessionUUID = 'uuid-cancel-123';
|
||||
mockedDelete.mockResolvedValue({});
|
||||
|
||||
await cancelImport(sessionUUID);
|
||||
|
||||
expect(client.delete).toHaveBeenCalledTimes(1);
|
||||
expect(client.delete).toHaveBeenCalledWith('/import/cancel', {
|
||||
params: {
|
||||
session_uuid: sessionUUID,
|
||||
},
|
||||
});
|
||||
|
||||
const [, requestConfig] = mockedDelete.mock.calls[0];
|
||||
expect(requestConfig).toEqual({
|
||||
params: {
|
||||
session_uuid: sessionUUID,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards commitImport errors', async () => {
|
||||
const error = new Error('commit failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(commitImport('uuid-123', {}, {})).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards cancelImport errors', async () => {
|
||||
const error = new Error('cancel failed');
|
||||
mockedDelete.mockRejectedValue(error);
|
||||
|
||||
await expect(cancelImport('uuid-cancel-123')).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('getImportStatus gets status', async () => {
|
||||
const mockResponse = { has_pending: true };
|
||||
mockedGet.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await getImportStatus();
|
||||
expect(client.get).toHaveBeenCalledWith('/import/status');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('getImportStatus handles error', async () => {
|
||||
mockedGet.mockRejectedValue(new Error('Failed'));
|
||||
|
||||
const result = await getImportStatus();
|
||||
expect(result).toEqual({ has_pending: false });
|
||||
});
|
||||
|
||||
it('getImportStatus returns false on non-Error rejections', async () => {
|
||||
mockedGet.mockRejectedValue('network down');
|
||||
|
||||
const result = await getImportStatus();
|
||||
expect(result).toEqual({ has_pending: false });
|
||||
});
|
||||
});
|
||||
96
frontend/src/api/__tests__/jsonImport.test.ts
Normal file
96
frontend/src/api/__tests__/jsonImport.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { uploadJSONExport, commitJSONImport, cancelJSONImport } from '../jsonImport';
|
||||
import client from '../client';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('jsonImport API', () => {
|
||||
const mockedPost = vi.mocked(client.post);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('cancelJSONImport posts cancel endpoint with required session_uuid body', async () => {
|
||||
const sessionUUID = 'json-session-123';
|
||||
mockedPost.mockResolvedValue({});
|
||||
|
||||
await cancelJSONImport(sessionUUID);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/json/cancel', {
|
||||
session_uuid: sessionUUID,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploadJSONExport posts upload endpoint with content payload', async () => {
|
||||
const content = '{"proxy_hosts":[]}';
|
||||
const mockResponse = {
|
||||
session: {
|
||||
id: 'json-session-456',
|
||||
state: 'reviewing',
|
||||
source: 'json',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
};
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await uploadJSONExport(content);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/json/upload', { content });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('commitJSONImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
|
||||
const sessionUUID = 'json-session-789';
|
||||
const resolutions = { 'json.example.com': 'replace' };
|
||||
const names = { 'json.example.com': 'JSON Example' };
|
||||
const mockResponse = {
|
||||
created: 1,
|
||||
updated: 1,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await commitJSONImport(sessionUUID, resolutions, names);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/json/commit', {
|
||||
session_uuid: sessionUUID,
|
||||
resolutions,
|
||||
names,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('forwards uploadJSONExport errors', async () => {
|
||||
const error = new Error('upload failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(uploadJSONExport('{"proxy_hosts":[]}')).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards commitJSONImport errors', async () => {
|
||||
const error = new Error('commit failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(commitJSONImport('json-session-123', {}, {})).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards cancelJSONImport errors', async () => {
|
||||
const error = new Error('cancel failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(cancelJSONImport('json-session-123')).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
217
frontend/src/api/__tests__/logs-websocket.test.ts
Normal file
217
frontend/src/api/__tests__/logs-websocket.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
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('');
|
||||
(globalThis as typeof globalThis & { WebSocket: typeof WebSocket }).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);
|
||||
});
|
||||
});
|
||||
44
frontend/src/api/__tests__/logs.http.test.ts
Normal file
44
frontend/src/api/__tests__/logs.http.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
230
frontend/src/api/__tests__/manualChallenge.test.ts
Normal file
230
frontend/src/api/__tests__/manualChallenge.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
111
frontend/src/api/__tests__/notifications.test.ts
Normal file
111
frontend/src/api/__tests__/notifications.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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.resetAllMocks()
|
||||
})
|
||||
|
||||
it('crud for providers uses correct endpoints', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: [{ id: '1', name: 'discord', type: 'discord', 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', type: 'discord' })
|
||||
expect(client.post).toHaveBeenCalledWith('/notifications/providers', { name: 'x', type: 'discord' })
|
||||
|
||||
await updateProvider('2', { name: 'updated', type: 'discord' })
|
||||
expect(client.put).toHaveBeenCalledWith('/notifications/providers/2', { name: 'updated', type: 'discord' })
|
||||
|
||||
await deleteProvider('2')
|
||||
expect(client.delete).toHaveBeenCalledWith('/notifications/providers/2')
|
||||
|
||||
await testProvider({ id: '2', name: 'test', type: 'discord' })
|
||||
expect(client.post).toHaveBeenCalledWith('/notifications/providers/test', { id: '2', name: 'test', type: 'discord' })
|
||||
|
||||
await expect(createProvider({ name: 'x', type: 'slack' })).rejects.toThrow('Unsupported notification provider type: slack')
|
||||
await expect(updateProvider('2', { name: 'updated', type: 'generic' })).rejects.toThrow('Unsupported notification provider type: generic')
|
||||
await expect(testProvider({ id: '2', name: 'test', type: 'telegram' })).rejects.toThrow('Unsupported notification provider type: telegram')
|
||||
})
|
||||
|
||||
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', type: 'discord' }, { user: 'alice' })
|
||||
expect(preview).toEqual({ preview: 'ok' })
|
||||
expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', type: 'discord', data: { user: 'alice' } })
|
||||
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ data: { preview: 'webhook-ok' } })
|
||||
const webhookPreview = await previewProvider({ name: 'provider', type: 'webhook' }, { user: 'alice' })
|
||||
expect(webhookPreview).toEqual({ preview: 'webhook-ok' })
|
||||
expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', type: 'webhook', 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: { id: 'ext2' } })
|
||||
const result = await previewExternalTemplate('ext', 'tpl', { id: 1 })
|
||||
expect(result).toEqual({ id: 'ext2' })
|
||||
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', security_waf_enabled: true, security_acl_enabled: false, security_rate_limit_enabled: 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 })
|
||||
})
|
||||
})
|
||||
96
frontend/src/api/__tests__/npmImport.test.ts
Normal file
96
frontend/src/api/__tests__/npmImport.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { uploadNPMExport, commitNPMImport, cancelNPMImport } from '../npmImport';
|
||||
import client from '../client';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('npmImport API', () => {
|
||||
const mockedPost = vi.mocked(client.post);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('cancelNPMImport posts cancel endpoint with required session_uuid body', async () => {
|
||||
const sessionUUID = 'npm-session-123';
|
||||
mockedPost.mockResolvedValue({});
|
||||
|
||||
await cancelNPMImport(sessionUUID);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/npm/cancel', {
|
||||
session_uuid: sessionUUID,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploadNPMExport posts upload endpoint with content payload', async () => {
|
||||
const content = '{"proxy_hosts":[]}';
|
||||
const mockResponse = {
|
||||
session: {
|
||||
id: 'npm-session-456',
|
||||
state: 'reviewing',
|
||||
source: 'npm',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
};
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await uploadNPMExport(content);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/npm/upload', { content });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('commitNPMImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
|
||||
const sessionUUID = 'npm-session-789';
|
||||
const resolutions = { 'npm.example.com': 'replace' };
|
||||
const names = { 'npm.example.com': 'NPM Example' };
|
||||
const mockResponse = {
|
||||
created: 1,
|
||||
updated: 1,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await commitNPMImport(sessionUUID, resolutions, names);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/npm/commit', {
|
||||
session_uuid: sessionUUID,
|
||||
resolutions,
|
||||
names,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('forwards uploadNPMExport errors', async () => {
|
||||
const error = new Error('upload failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(uploadNPMExport('{"proxy_hosts":[]}')).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards commitNPMImport errors', async () => {
|
||||
const error = new Error('commit failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(commitNPMImport('npm-session-123', {}, {})).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards cancelNPMImport errors', async () => {
|
||||
const error = new Error('cancel failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(cancelNPMImport('npm-session-123')).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
122
frontend/src/api/__tests__/plugins.test.ts
Normal file
122
frontend/src/api/__tests__/plugins.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import client from '../client';
|
||||
import {
|
||||
getPlugins,
|
||||
getPlugin,
|
||||
enablePlugin,
|
||||
disablePlugin,
|
||||
reloadPlugins,
|
||||
type PluginInfo,
|
||||
} from '../plugins';
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Plugins API', () => {
|
||||
const mockPlugins: PluginInfo[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'plugin-1',
|
||||
name: 'Test Plugin 1',
|
||||
type: 'auth',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
is_built_in: false,
|
||||
created_at: '2023-01-01',
|
||||
updated_at: '2023-01-01',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'plugin-2',
|
||||
name: 'Test Plugin 2',
|
||||
type: 'notification',
|
||||
enabled: false,
|
||||
status: 'pending',
|
||||
is_built_in: true,
|
||||
created_at: '2023-01-01',
|
||||
updated_at: '2023-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getPlugins', () => {
|
||||
it('fetches all plugins successfully', async () => {
|
||||
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPlugins });
|
||||
|
||||
const result = await getPlugins();
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/plugins');
|
||||
expect(result).toEqual(mockPlugins);
|
||||
});
|
||||
|
||||
it('propagates error when request fails', async () => {
|
||||
const error = new Error('API Error');
|
||||
vi.mocked(client.get).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getPlugins()).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlugin', () => {
|
||||
it('fetches a single plugin successfully', async () => {
|
||||
const plugin = mockPlugins[0];
|
||||
vi.mocked(client.get).mockResolvedValueOnce({ data: plugin });
|
||||
|
||||
const result = await getPlugin(1);
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/plugins/1');
|
||||
expect(result).toEqual(plugin);
|
||||
});
|
||||
|
||||
it('propagates error when plugin not found', async () => {
|
||||
const error = new Error('Not Found');
|
||||
vi.mocked(client.get).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getPlugin(999)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enablePlugin', () => {
|
||||
it('enables a plugin successfully', async () => {
|
||||
const response = { message: 'Plugin enabled' };
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
|
||||
|
||||
const result = await enablePlugin(1);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/plugins/1/enable');
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disablePlugin', () => {
|
||||
it('disables a plugin successfully', async () => {
|
||||
const response = { message: 'Plugin disabled' };
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
|
||||
|
||||
const result = await disablePlugin(1);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/plugins/1/disable');
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadPlugins', () => {
|
||||
it('reloads plugins successfully', async () => {
|
||||
const response = { message: 'Plugins reloaded', count: 5 };
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
|
||||
|
||||
const result = await reloadPlugins();
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/plugins/reload');
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
});
|
||||
});
|
||||
465
frontend/src/api/__tests__/presets.test.ts
Normal file
465
frontend/src/api/__tests__/presets.test.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
95
frontend/src/api/__tests__/proxyHosts-bulk.test.ts
Normal file
95
frontend/src/api/__tests__/proxyHosts-bulk.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
91
frontend/src/api/__tests__/proxyHosts.test.ts
Normal file
91
frontend/src/api/__tests__/proxyHosts.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
146
frontend/src/api/__tests__/remoteServers.test.ts
Normal file
146
frontend/src/api/__tests__/remoteServers.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
244
frontend/src/api/__tests__/security.test.ts
Normal file
244
frontend/src/api/__tests__/security.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
133
frontend/src/api/__tests__/securityHeaders.test.ts
Normal file
133
frontend/src/api/__tests__/securityHeaders.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { securityHeadersApi } from '../securityHeaders';
|
||||
import client from '../client';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('securityHeadersApi', () => {
|
||||
const mockedGet = vi.mocked(client.get);
|
||||
const mockedPost = vi.mocked(client.post);
|
||||
const mockedPut = vi.mocked(client.put);
|
||||
const mockedDelete = vi.mocked(client.delete);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('listProfiles returns profiles', async () => {
|
||||
const mockProfiles = [{ id: 1, name: 'Profile 1' }];
|
||||
mockedGet.mockResolvedValue({ data: { profiles: mockProfiles } });
|
||||
|
||||
const result = await securityHeadersApi.listProfiles();
|
||||
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles');
|
||||
expect(result).toEqual(mockProfiles);
|
||||
});
|
||||
|
||||
it('getProfile returns a profile', async () => {
|
||||
const mockProfile = { id: 1, name: 'Profile 1' };
|
||||
mockedGet.mockResolvedValue({ data: { profile: mockProfile } });
|
||||
|
||||
const result = await securityHeadersApi.getProfile(1);
|
||||
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles/1');
|
||||
expect(result).toEqual(mockProfile);
|
||||
});
|
||||
|
||||
it('getProfile accepts UUID string identifiers', async () => {
|
||||
const mockProfile = { id: 2, uuid: 'profile-uuid', name: 'Profile UUID' };
|
||||
mockedGet.mockResolvedValue({ data: { profile: mockProfile } });
|
||||
|
||||
const result = await securityHeadersApi.getProfile('profile-uuid');
|
||||
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles/profile-uuid');
|
||||
expect(result).toEqual(mockProfile);
|
||||
});
|
||||
|
||||
it('createProfile creates a profile', async () => {
|
||||
const newProfile = { name: 'New Profile' };
|
||||
const mockResponse = { id: 1, ...newProfile };
|
||||
mockedPost.mockResolvedValue({ data: { profile: mockResponse } });
|
||||
|
||||
const result = await securityHeadersApi.createProfile(newProfile);
|
||||
expect(client.post).toHaveBeenCalledWith('/security/headers/profiles', newProfile);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('updateProfile updates a profile', async () => {
|
||||
const updates = { name: 'Updated Profile' };
|
||||
const mockResponse = { id: 1, ...updates };
|
||||
mockedPut.mockResolvedValue({ data: { profile: mockResponse } });
|
||||
|
||||
const result = await securityHeadersApi.updateProfile(1, updates);
|
||||
expect(client.put).toHaveBeenCalledWith('/security/headers/profiles/1', updates);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('deleteProfile deletes a profile', async () => {
|
||||
mockedDelete.mockResolvedValue({});
|
||||
|
||||
await securityHeadersApi.deleteProfile(1);
|
||||
expect(client.delete).toHaveBeenCalledWith('/security/headers/profiles/1');
|
||||
});
|
||||
|
||||
it('forwards API errors from listProfiles', async () => {
|
||||
const error = new Error('backend unavailable');
|
||||
mockedGet.mockRejectedValue(error);
|
||||
|
||||
await expect(securityHeadersApi.listProfiles()).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('getPresets returns presets', async () => {
|
||||
const mockPresets = [{ name: 'Basic' }];
|
||||
mockedGet.mockResolvedValue({ data: { presets: mockPresets } });
|
||||
|
||||
const result = await securityHeadersApi.getPresets();
|
||||
expect(client.get).toHaveBeenCalledWith('/security/headers/presets');
|
||||
expect(result).toEqual(mockPresets);
|
||||
});
|
||||
|
||||
it('applyPreset applies a preset', async () => {
|
||||
const request = { preset_type: 'basic', name: 'My Preset' };
|
||||
const mockResponse = { id: 1, ...request };
|
||||
mockedPost.mockResolvedValue({ data: { profile: mockResponse } });
|
||||
|
||||
const result = await securityHeadersApi.applyPreset(request);
|
||||
expect(client.post).toHaveBeenCalledWith('/security/headers/presets/apply', request);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('calculateScore calculates score', async () => {
|
||||
const config = { hsts_enabled: true };
|
||||
const mockResponse = { score: 90 };
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await securityHeadersApi.calculateScore(config);
|
||||
expect(client.post).toHaveBeenCalledWith('/security/headers/score', config);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('validateCSP validates CSP', async () => {
|
||||
const csp = "default-src 'self'";
|
||||
const mockResponse = { valid: true, errors: [] };
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await securityHeadersApi.validateCSP(csp);
|
||||
expect(client.post).toHaveBeenCalledWith('/security/headers/csp/validate', { csp });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('buildCSP builds CSP', async () => {
|
||||
const directives = [{ directive: 'default-src', values: ["'self'"] }];
|
||||
const mockResponse = { csp: "default-src 'self'" };
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await securityHeadersApi.buildCSP(directives);
|
||||
expect(client.post).toHaveBeenCalledWith('/security/headers/csp/build', { directives });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
181
frontend/src/api/__tests__/settings.test.ts
Normal file
181
frontend/src/api/__tests__/settings.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
23
frontend/src/api/__tests__/setup.test.ts
Normal file
23
frontend/src/api/__tests__/setup.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
62
frontend/src/api/__tests__/system.test.ts
Normal file
62
frontend/src/api/__tests__/system.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
135
frontend/src/api/__tests__/uptime.test.ts
Normal file
135
frontend/src/api/__tests__/uptime.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
69
frontend/src/api/__tests__/user.test.ts
Normal file
69
frontend/src/api/__tests__/user.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import client from '../client'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../users'
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('user api', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetches profile using masked API key fields', async () => {
|
||||
vi.mocked(client.get).mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
has_api_key: true,
|
||||
api_key_masked: '********',
|
||||
},
|
||||
})
|
||||
|
||||
const profile = await getProfile()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/user/profile')
|
||||
expect(profile.has_api_key).toBe(true)
|
||||
expect(profile.api_key_masked).toBe('********')
|
||||
})
|
||||
|
||||
it('regenerates API key and returns metadata-only response', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({
|
||||
data: {
|
||||
message: 'API key regenerated successfully',
|
||||
has_api_key: true,
|
||||
api_key_masked: '********',
|
||||
api_key_updated: '2026-02-25T00:00:00Z',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await regenerateApiKey()
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/user/api-key')
|
||||
expect(result.has_api_key).toBe(true)
|
||||
expect(result.api_key_masked).toBe('********')
|
||||
expect(result.api_key_updated).toBe('2026-02-25T00:00:00Z')
|
||||
})
|
||||
|
||||
it('updates profile with optional current password', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ data: { message: 'ok' } })
|
||||
|
||||
await updateProfile({
|
||||
name: 'Updated Name',
|
||||
email: 'updated@example.com',
|
||||
current_password: 'current-password',
|
||||
})
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/user/profile', {
|
||||
name: 'Updated Name',
|
||||
email: 'updated@example.com',
|
||||
current_password: 'current-password',
|
||||
})
|
||||
})
|
||||
})
|
||||
189
frontend/src/api/__tests__/users.test.ts
Normal file
189
frontend/src/api/__tests__/users.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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_masked: '********', invite_url: '[REDACTED]' } })
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
112
frontend/src/api/__tests__/websocket.test.ts
Normal file
112
frontend/src/api/__tests__/websocket.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
126
frontend/src/api/accessLists.ts
Normal file
126
frontend/src/api/accessLists.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
267
frontend/src/api/auditLogs.test.ts
Normal file
267
frontend/src/api/auditLogs.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
144
frontend/src/api/auditLogs.ts
Normal file
144
frontend/src/api/auditLogs.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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
|
||||
}
|
||||
46
frontend/src/api/backups.ts
Normal file
46
frontend/src/api/backups.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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}`);
|
||||
};
|
||||
53
frontend/src/api/certificates.ts
Normal file
53
frontend/src/api/certificates.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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}`)
|
||||
}
|
||||
71
frontend/src/api/client.ts
Normal file
71
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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 response error handling
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Extract API error message and set on error object for consistent error handling
|
||||
if (error.response?.data && typeof error.response.data === 'object') {
|
||||
const data = error.response.data as { error?: string; message?: string };
|
||||
if (data.error) {
|
||||
error.message = data.error;
|
||||
} else if (data.message) {
|
||||
error.message = data.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 401 authentication errors - triggers auth error callback for session expiry
|
||||
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') ||
|
||||
url.includes('/auth/logout') ||
|
||||
url.includes('/auth/refresh');
|
||||
if (onAuthError && !isAuthEndpoint) {
|
||||
onAuthError();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default client;
|
||||
57
frontend/src/api/consoleEnrollment.ts
Normal file
57
frontend/src/api/consoleEnrollment.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
}
|
||||
148
frontend/src/api/credentials.ts
Normal file
148
frontend/src/api/credentials.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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`)
|
||||
}
|
||||
159
frontend/src/api/crowdsec.ts
Normal file
159
frontend/src/api/crowdsec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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)}`)
|
||||
}
|
||||
|
||||
/** CrowdSec API key status information for key rejection notifications. */
|
||||
export interface CrowdSecKeyStatus {
|
||||
key_source: 'env' | 'file' | 'auto-generated'
|
||||
env_key_rejected: boolean
|
||||
full_key?: string // Only present when env_key_rejected is true
|
||||
current_key_preview: string
|
||||
rejected_key_preview?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current CrowdSec API key status.
|
||||
* Used to display warning banner when env key was rejected.
|
||||
* @returns Promise resolving to CrowdSecKeyStatus
|
||||
* @throws {AxiosError} If status check fails
|
||||
*/
|
||||
export async function getCrowdsecKeyStatus(): Promise<CrowdSecKeyStatus> {
|
||||
const resp = await client.get<CrowdSecKeyStatus>('/admin/crowdsec/key-status')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, getCrowdsecKeyStatus }
|
||||
40
frontend/src/api/dnsDetection.ts
Normal file
40
frontend/src/api/dnsDetection.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
}
|
||||
178
frontend/src/api/dnsProviders.ts
Normal file
178
frontend/src/api/dnsProviders.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
39
frontend/src/api/docker.ts
Normal file
39
frontend/src/api/docker.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
39
frontend/src/api/domains.ts
Normal file
39
frontend/src/api/domains.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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}`)
|
||||
}
|
||||
85
frontend/src/api/encryption.ts
Normal file
85
frontend/src/api/encryption.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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
|
||||
}
|
||||
26
frontend/src/api/featureFlags.test.ts
Normal file
26
frontend/src/api/featureFlags.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
27
frontend/src/api/featureFlags.ts
Normal file
27
frontend/src/api/featureFlags.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
}
|
||||
20
frontend/src/api/health.ts
Normal file
20
frontend/src/api/health.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
};
|
||||
142
frontend/src/api/import.ts
Normal file
142
frontend/src/api/import.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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[];
|
||||
};
|
||||
/** Optional top-level warning message returned by the backend (file_server, no-sites, etc.) */
|
||||
warning?: 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a Caddyfile with its filename and content.
|
||||
*/
|
||||
export interface CaddyFile {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads multiple Caddyfiles for batch import.
|
||||
* @param files - Array of CaddyFile objects with filename and content
|
||||
* @returns Promise resolving to combined ImportPreview
|
||||
* @throws {AxiosError} If parsing fails
|
||||
*/
|
||||
export const uploadCaddyfilesMulti = async (files: CaddyFile[]): Promise<ImportPreview> => {
|
||||
const { data } = await client.post<ImportPreview>('/import/upload-multi', { files });
|
||||
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.
|
||||
* @param sessionUUID - The import session UUID
|
||||
* @throws {AxiosError} If cancellation fails
|
||||
*/
|
||||
export const cancelImport = async (sessionUUID: string): Promise<void> => {
|
||||
await client.delete('/import/cancel', {
|
||||
params: {
|
||||
session_uuid: sessionUUID,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
};
|
||||
93
frontend/src/api/jsonImport.ts
Normal file
93
frontend/src/api/jsonImport.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
* @param sessionUuid - The import session UUID
|
||||
* @throws {AxiosError} If cancellation fails
|
||||
*/
|
||||
export const cancelJSONImport = async (sessionUuid: string): Promise<void> => {
|
||||
await client.post('/import/json/cancel', {
|
||||
session_uuid: sessionUuid,
|
||||
});
|
||||
};
|
||||
339
frontend/src/api/logs.test.ts
Normal file
339
frontend/src/api/logs.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
262
frontend/src/api/logs.ts
Normal file
262
frontend/src/api/logs.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
};
|
||||
115
frontend/src/api/manualChallenge.ts
Normal file
115
frontend/src/api/manualChallenge.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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}`)
|
||||
}
|
||||
185
frontend/src/api/notifications.test.ts
Normal file
185
frontend/src/api/notifications.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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', type: 'discord' })
|
||||
expect(created.id).toBe('new')
|
||||
|
||||
const updated = await updateProvider('new', { enabled: false })
|
||||
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/new', { enabled: false, type: 'discord' })
|
||||
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,
|
||||
type: 'discord',
|
||||
})
|
||||
|
||||
mockedClient.delete.mockResolvedValue({})
|
||||
await deleteProvider('new')
|
||||
expect(mockedClient.delete).toHaveBeenCalledWith('/notifications/providers/new')
|
||||
})
|
||||
|
||||
it('supports discord, gotify, and webhook while enforcing token payload contract', async () => {
|
||||
mockedClient.post.mockResolvedValue({ data: { id: 'ok' } })
|
||||
mockedClient.put.mockResolvedValue({ data: { id: 'ok' } })
|
||||
|
||||
await createProvider({ name: 'Gotify', type: 'gotify', gotify_token: 'secret-token' })
|
||||
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers', {
|
||||
name: 'Gotify',
|
||||
type: 'gotify',
|
||||
token: 'secret-token',
|
||||
})
|
||||
|
||||
await updateProvider('ok', { type: 'webhook', url: 'https://example.com/webhook', gotify_token: 'should-not-send' })
|
||||
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/ok', {
|
||||
type: 'webhook',
|
||||
url: 'https://example.com/webhook',
|
||||
})
|
||||
|
||||
await testProvider({ id: 'ok', type: 'gotify', gotify_token: 'should-not-send' })
|
||||
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/test', {
|
||||
id: 'ok',
|
||||
type: 'gotify',
|
||||
})
|
||||
|
||||
await previewProvider({ id: 'ok', type: 'gotify', gotify_token: 'should-not-send' })
|
||||
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/preview', {
|
||||
id: 'ok',
|
||||
type: 'gotify',
|
||||
})
|
||||
|
||||
await expect(createProvider({ name: 'Bad', type: 'slack' })).rejects.toThrow('Unsupported notification provider type: slack')
|
||||
await expect(updateProvider('bad', { type: 'generic' })).rejects.toThrow('Unsupported notification provider type: generic')
|
||||
await expect(testProvider({ id: 'bad', type: 'email' })).rejects.toThrow('Unsupported notification provider type: email')
|
||||
})
|
||||
|
||||
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',
|
||||
type: 'discord',
|
||||
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', security_waf_enabled: true, security_acl_enabled: false, security_rate_limit_enabled: true } })
|
||||
mockedClient.put.mockResolvedValueOnce({ data: { enabled: false, min_log_level: 'error', security_waf_enabled: false, security_acl_enabled: true, security_rate_limit_enabled: 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)
|
||||
})
|
||||
})
|
||||
271
frontend/src/api/notifications.ts
Normal file
271
frontend/src/api/notifications.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import client from './client';
|
||||
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook'] as const;
|
||||
export type SupportedNotificationProviderType = (typeof SUPPORTED_NOTIFICATION_PROVIDER_TYPES)[number];
|
||||
const DEFAULT_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord';
|
||||
|
||||
const isSupportedNotificationProviderType = (type: string | undefined): type is SupportedNotificationProviderType =>
|
||||
typeof type === 'string' && SUPPORTED_NOTIFICATION_PROVIDER_TYPES.includes(type.toLowerCase() as SupportedNotificationProviderType);
|
||||
|
||||
const resolveProviderTypeOrThrow = (type: string | undefined): SupportedNotificationProviderType => {
|
||||
if (typeof type === 'undefined') {
|
||||
return DEFAULT_PROVIDER_TYPE;
|
||||
}
|
||||
|
||||
const normalizedType = type.toLowerCase();
|
||||
if (isSupportedNotificationProviderType(normalizedType)) {
|
||||
return normalizedType;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported notification provider type: ${type}`);
|
||||
};
|
||||
|
||||
/** Notification provider configuration. */
|
||||
export interface NotificationProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
config?: string;
|
||||
template?: string;
|
||||
gotify_token?: string;
|
||||
token?: string;
|
||||
has_token?: boolean;
|
||||
enabled: boolean;
|
||||
notify_proxy_hosts: boolean;
|
||||
notify_remote_servers: boolean;
|
||||
notify_domains: boolean;
|
||||
notify_certs: boolean;
|
||||
notify_uptime: boolean;
|
||||
notify_security_waf_blocks: boolean;
|
||||
notify_security_acl_denies: boolean;
|
||||
notify_security_rate_limit_hits: boolean;
|
||||
managed_legacy_security?: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const sanitizeProviderForWriteAction = (data: Partial<NotificationProvider>): Partial<NotificationProvider> => {
|
||||
const type = resolveProviderTypeOrThrow(data.type);
|
||||
const payload: Partial<NotificationProvider> = {
|
||||
...data,
|
||||
type,
|
||||
};
|
||||
|
||||
const normalizedToken = typeof payload.gotify_token === 'string' && payload.gotify_token.trim().length > 0
|
||||
? payload.gotify_token.trim()
|
||||
: typeof payload.token === 'string' && payload.token.trim().length > 0
|
||||
? payload.token.trim()
|
||||
: undefined;
|
||||
|
||||
delete payload.gotify_token;
|
||||
|
||||
if (type !== 'gotify') {
|
||||
delete payload.token;
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (normalizedToken) {
|
||||
payload.token = normalizedToken;
|
||||
} else {
|
||||
delete payload.token;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const sanitizeProviderForReadLikeAction = (data: Partial<NotificationProvider>): Partial<NotificationProvider> => {
|
||||
const payload = sanitizeProviderForWriteAction(data);
|
||||
delete payload.token;
|
||||
return payload;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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', sanitizeProviderForWriteAction(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}`, sanitizeProviderForWriteAction(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', sanitizeProviderForReadLikeAction(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> = sanitizeProviderForReadLikeAction(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;
|
||||
security_waf_enabled: boolean;
|
||||
security_acl_enabled: boolean;
|
||||
security_rate_limit_enabled: boolean;
|
||||
destination_ambiguous?: boolean;
|
||||
webhook_url?: string;
|
||||
discord_webhook_url?: string;
|
||||
slack_webhook_url?: string;
|
||||
gotify_url?: string;
|
||||
gotify_token?: 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;
|
||||
};
|
||||
93
frontend/src/api/npmImport.ts
Normal file
93
frontend/src/api/npmImport.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
* @param sessionUuid - The import session UUID
|
||||
* @throws {AxiosError} If cancellation fails
|
||||
*/
|
||||
export const cancelNPMImport = async (sessionUuid: string): Promise<void> => {
|
||||
await client.post('/import/npm/cancel', {
|
||||
session_uuid: sessionUuid,
|
||||
});
|
||||
};
|
||||
76
frontend/src/api/plugins.ts
Normal file
76
frontend/src/api/plugins.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
104
frontend/src/api/presets.ts
Normal file
104
frontend/src/api/presets.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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,
|
||||
}
|
||||
188
frontend/src/api/proxyHosts.ts
Normal file
188
frontend/src/api/proxyHosts.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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 | string | null;
|
||||
access_list?: {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
} | null;
|
||||
security_header_profile_id?: number | string | 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;
|
||||
};
|
||||
94
frontend/src/api/remoteServers.ts
Normal file
94
frontend/src/api/remoteServers.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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;
|
||||
};
|
||||
189
frontend/src/api/security.ts
Normal file
189
frontend/src/api/security.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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
|
||||
}
|
||||
188
frontend/src/api/securityHeaders.ts
Normal file
188
frontend/src/api/securityHeaders.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
57
frontend/src/api/settings.ts
Normal file
57
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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
|
||||
}
|
||||
32
frontend/src/api/setup.ts
Normal file
32
frontend/src/api/setup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
};
|
||||
76
frontend/src/api/smtp.ts
Normal file
76
frontend/src/api/smtp.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
72
frontend/src/api/system.ts
Normal file
72
frontend/src/api/system.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
};
|
||||
112
frontend/src/api/uptime.ts
Normal file
112
frontend/src/api/uptime.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
};
|
||||
93
frontend/src/api/users.test.ts
Normal file
93
frontend/src/api/users.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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_masked: '********', invite_url: '[REDACTED]', 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_masked).toBe('********')
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
262
frontend/src/api/users.ts
Normal file
262
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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' | 'passthrough'
|
||||
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_masked: string
|
||||
invite_url?: 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
|
||||
}
|
||||
|
||||
// --- Self-service profile endpoints (merged from api/user.ts) ---
|
||||
|
||||
/** Current user profile information. */
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
role: 'admin' | 'user' | 'passthrough'
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
/** Response from API key regeneration. */
|
||||
export interface RegenerateApiKeyResponse {
|
||||
message: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
api_key_updated: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's profile.
|
||||
* @returns Promise resolving to UserProfile
|
||||
*/
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await client.get<UserProfile>('/user/profile')
|
||||
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
|
||||
*/
|
||||
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>('/user/profile', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the current user's API key.
|
||||
* @returns Promise resolving to object containing the new API key
|
||||
*/
|
||||
export const regenerateApiKey = async (): Promise<RegenerateApiKeyResponse> => {
|
||||
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
47
frontend/src/api/websocket.ts
Normal file
47
frontend/src/api/websocket.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
};
|
||||
558
frontend/src/components/AccessListForm.tsx
Normal file
558
frontend/src/components/AccessListForm.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
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 id="access-list-enabled-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
|
||||
aria-labelledby="access-list-enabled-label"
|
||||
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 id="access-list-local-network-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
|
||||
aria-labelledby="access-list-local-network-label"
|
||||
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 htmlFor="country-select" className="block text-sm font-medium text-gray-300 mb-2">Select Countries</label>
|
||||
<select
|
||||
id="country-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>
|
||||
);
|
||||
}
|
||||
199
frontend/src/components/AccessListSelector.tsx
Normal file
199
frontend/src/components/AccessListSelector.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useAccessLists } from '../hooks/useAccessLists';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/Select';
|
||||
|
||||
interface AccessListSelectorProps {
|
||||
value: number | string | null;
|
||||
onChange: (id: number | string | null) => void;
|
||||
}
|
||||
|
||||
function resolveAccessListToken(
|
||||
value: number | string | null | undefined,
|
||||
accessLists?: Array<{ id?: number | string; uuid?: string }>
|
||||
): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return `id:${value}`;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('id:')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('uuid:')) {
|
||||
const uuid = trimmed.slice(5);
|
||||
const matchingACL = accessLists?.find((acl) => acl.uuid === uuid);
|
||||
const matchingToken = matchingACL ? getOptionToken(matchingACL) : null;
|
||||
return matchingToken ?? trimmed;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return `id:${parsed}`;
|
||||
}
|
||||
|
||||
const matchingACL = accessLists?.find((acl) => acl.uuid === trimmed);
|
||||
const matchingToken = matchingACL ? getOptionToken(matchingACL) : null;
|
||||
return matchingToken ?? `uuid:${trimmed}`;
|
||||
}
|
||||
|
||||
function getOptionToken(acl: { id?: number | string; uuid?: string }): string | null {
|
||||
if (typeof acl.id === 'number' && Number.isFinite(acl.id)) {
|
||||
return `id:${acl.id}`;
|
||||
}
|
||||
|
||||
if (typeof acl.id === 'string') {
|
||||
const trimmed = acl.id.trim();
|
||||
if (trimmed !== '' && /^\d+$/.test(trimmed)) {
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return `id:${parsed}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (acl.uuid) {
|
||||
return `uuid:${acl.uuid}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AccessListSelector({ value, onChange }: AccessListSelectorProps) {
|
||||
const { data: accessLists } = useAccessLists();
|
||||
|
||||
const selectedToken = resolveAccessListToken(value, accessLists);
|
||||
const selectedACL = accessLists?.find((acl) => getOptionToken(acl) === selectedToken);
|
||||
|
||||
// Keep select value stable for both numeric-ID and UUID-only payload shapes.
|
||||
const selectValue = selectedToken;
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
if (newValue === 'none') {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue.startsWith('id:')) {
|
||||
const numericId = Number.parseInt(newValue.slice(3), 10);
|
||||
if (!Number.isNaN(numericId)) {
|
||||
onChange(numericId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue.startsWith('uuid:')) {
|
||||
const selectedUUID = newValue.slice(5);
|
||||
const matchingACL = accessLists?.find((acl) => acl.uuid === selectedUUID);
|
||||
const matchingToken = matchingACL ? getOptionToken(matchingACL) : null;
|
||||
|
||||
if (matchingToken?.startsWith('id:')) {
|
||||
const numericId = Number.parseInt(matchingToken.slice(3), 10);
|
||||
if (!Number.isNaN(numericId)) {
|
||||
onChange(numericId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onChange(selectedUUID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(newValue)) {
|
||||
const numericId = Number.parseInt(newValue, 10);
|
||||
onChange(numericId);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
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={selectValue}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Access Control List">
|
||||
<SelectValue placeholder="Select an ACL" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No Access Control (Public)</SelectItem>
|
||||
{accessLists
|
||||
?.filter((acl) => acl.enabled)
|
||||
.map((acl) => {
|
||||
const optionToken = getOptionToken(acl);
|
||||
if (!optionToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectItem key={optionToken} value={optionToken}>
|
||||
{acl.name} ({acl.type.replace('_', ' ')})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
332
frontend/src/components/CSPBuilder.tsx
Normal file
332
frontend/src/components/CSPBuilder.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
207
frontend/src/components/CertificateList.tsx
Normal file
207
frontend/src/components/CertificateList.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
143
frontend/src/components/CertificateStatusCard.tsx
Normal file
143
frontend/src/components/CertificateStatusCard.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
609
frontend/src/components/CredentialManager.tsx
Normal file
609
frontend/src/components/CredentialManager.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
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={true} 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>
|
||||
)
|
||||
}
|
||||
147
frontend/src/components/CrowdSecBouncerKeyDisplay.tsx
Normal file
147
frontend/src/components/CrowdSecBouncerKeyDisplay.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Copy, Check, Key, AlertCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from './ui/Button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card'
|
||||
import { Badge } from './ui/Badge'
|
||||
import { Skeleton } from './ui/Skeleton'
|
||||
import { toast } from '../utils/toast'
|
||||
import client from '../api/client'
|
||||
|
||||
interface BouncerInfo {
|
||||
name: string
|
||||
key_preview: string
|
||||
key_source: 'env_var' | 'file' | 'none'
|
||||
file_path: string
|
||||
registered: boolean
|
||||
}
|
||||
|
||||
interface BouncerKeyResponse {
|
||||
key: string
|
||||
source: string
|
||||
}
|
||||
|
||||
async function fetchBouncerInfo(): Promise<BouncerInfo> {
|
||||
const response = await client.get<BouncerInfo>('/admin/crowdsec/bouncer')
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function fetchBouncerKey(): Promise<string> {
|
||||
const response = await client.get<BouncerKeyResponse>('/admin/crowdsec/bouncer/key')
|
||||
return response.data.key
|
||||
}
|
||||
|
||||
export function CrowdSecBouncerKeyDisplay() {
|
||||
const { t, ready } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
|
||||
const { data: info, isLoading, error } = useQuery({
|
||||
queryKey: ['crowdsec-bouncer-info'],
|
||||
queryFn: fetchBouncerInfo,
|
||||
refetchInterval: 30000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const handleCopyKey = async () => {
|
||||
if (isCopying) return
|
||||
setIsCopying(true)
|
||||
|
||||
try {
|
||||
const key = await fetchBouncerKey()
|
||||
await navigator.clipboard.writeText(key)
|
||||
setCopied(true)
|
||||
toast.success(t('security.crowdsec.keyCopied'))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error(t('security.crowdsec.copyFailed'))
|
||||
} finally {
|
||||
setIsCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready || isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !info) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (info.key_source === 'none') {
|
||||
return (
|
||||
<Card className="border-yellow-500/30 bg-yellow-500/5">
|
||||
<CardContent className="flex items-center gap-2 py-3">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-200">
|
||||
{t('security.crowdsec.noKeyConfigured')}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Key className="h-4 w-4" />
|
||||
{t('security.crowdsec.bouncerApiKey')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="rounded bg-gray-900 px-3 py-1.5 font-mono text-sm text-gray-200">
|
||||
{info.key_preview}
|
||||
</code>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopyKey}
|
||||
disabled={copied || isCopying}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
{t('common.success')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
{t('common.copy') || 'Copy'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={info.registered ? 'success' : 'error'}>
|
||||
{info.registered
|
||||
? t('security.crowdsec.registered')
|
||||
: t('security.crowdsec.notRegistered')}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{info.key_source === 'env_var'
|
||||
? t('security.crowdsec.sourceEnvVar')
|
||||
: t('security.crowdsec.sourceFile')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
{t('security.crowdsec.keyStoredAt')}: <code className="text-gray-300">{info.file_path}</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
158
frontend/src/components/CrowdSecKeyWarning.tsx
Normal file
158
frontend/src/components/CrowdSecKeyWarning.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Copy, Check, AlertTriangle, X, Eye, EyeOff } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert } from './ui/Alert'
|
||||
import { Button } from './ui/Button'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getCrowdsecKeyStatus, type CrowdSecKeyStatus } from '../api/crowdsec'
|
||||
|
||||
const DISMISSAL_STORAGE_KEY = 'crowdsec-key-warning-dismissed'
|
||||
|
||||
interface DismissedState {
|
||||
dismissed: boolean
|
||||
key?: string
|
||||
}
|
||||
|
||||
function getDismissedState(): DismissedState {
|
||||
try {
|
||||
const stored = localStorage.getItem(DISMISSAL_STORAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return { dismissed: false }
|
||||
}
|
||||
|
||||
function setDismissedState(fullKey: string) {
|
||||
try {
|
||||
localStorage.setItem(DISMISSAL_STORAGE_KEY, JSON.stringify({ dismissed: true, key: fullKey }))
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function CrowdSecKeyWarning() {
|
||||
const { t, ready } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
|
||||
const { data: keyStatus, isLoading } = useQuery<CrowdSecKeyStatus>({
|
||||
queryKey: ['crowdsec-key-status'],
|
||||
queryFn: getCrowdsecKeyStatus,
|
||||
refetchInterval: 60000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (keyStatus?.env_key_rejected && keyStatus.full_key) {
|
||||
const storedState = getDismissedState()
|
||||
// If dismissed but for a different key, show the warning again
|
||||
if (storedState.dismissed && storedState.key !== keyStatus.full_key) {
|
||||
setDismissed(false)
|
||||
} else if (storedState.dismissed && storedState.key === keyStatus.full_key) {
|
||||
setDismissed(true)
|
||||
}
|
||||
}
|
||||
}, [keyStatus])
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!keyStatus?.full_key) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(keyStatus.full_key)
|
||||
setCopied(true)
|
||||
toast.success(t('security.crowdsec.keyWarning.copied'))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error(t('security.crowdsec.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (keyStatus?.full_key) {
|
||||
setDismissedState(keyStatus.full_key)
|
||||
}
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
if (!ready || isLoading || !keyStatus?.env_key_rejected || !keyStatus?.full_key || dismissed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const envVarLine = `CHARON_SECURITY_CROWDSEC_API_KEY=${keyStatus.full_key}`
|
||||
const maskedKey = `CHARON_SECURITY_CROWDSEC_API_KEY=${'•'.repeat(Math.min(keyStatus.full_key.length, 40))}`
|
||||
|
||||
return (
|
||||
<Alert variant="warning" className="relative">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-warning flex-shrink-0" />
|
||||
<h4 className="font-semibold text-content-primary">
|
||||
{t('security.crowdsec.keyWarning.title')}
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="p-1 rounded-md text-content-muted hover:text-content-primary hover:bg-surface-muted transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-content-secondary">
|
||||
{t('security.crowdsec.keyWarning.description')}
|
||||
</p>
|
||||
|
||||
<div className="bg-surface-subtle border border-border rounded-md p-3">
|
||||
<p className="text-xs text-content-muted mb-2">
|
||||
{t('security.crowdsec.keyWarning.instructions')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-surface-elevated rounded px-3 py-2 font-mono text-sm text-content-primary overflow-x-auto whitespace-nowrap">
|
||||
{showKey ? envVarLine : maskedKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="flex-shrink-0"
|
||||
title={showKey ? 'Hide key' : 'Show key'}
|
||||
>
|
||||
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
disabled={copied}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
{t('security.crowdsec.keyWarning.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
{t('security.crowdsec.keyWarning.copyButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-content-muted">
|
||||
{t('security.crowdsec.keyWarning.restartNote')}
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
129
frontend/src/components/DNSDetectionResult.tsx
Normal file
129
frontend/src/components/DNSDetectionResult.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
216
frontend/src/components/DNSProviderCard.tsx
Normal file
216
frontend/src/components/DNSProviderCard.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
482
frontend/src/components/DNSProviderForm.tsx
Normal file
482
frontend/src/components/DNSProviderForm.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
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 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 [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
|
||||
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" data-testid="credentials-section">{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 (
|
||||
<div key={field.name} className="space-y-1.5">
|
||||
<Label htmlFor={`field-${field.name}`}>{field.label}</Label>
|
||||
<Input
|
||||
id={`field-${field.name}`}
|
||||
aria-label={
|
||||
field.name === 'create_script' && providerType === 'script'
|
||||
? 'Script Path'
|
||||
: undefined
|
||||
}
|
||||
type={field.type}
|
||||
value={credentials[field.name] || ''}
|
||||
onChange={(e) => handleCredentialChange(field.name, e.target.value)}
|
||||
placeholder={
|
||||
field.name === 'create_script' && providerType === 'script'
|
||||
? '/scripts/dns-challenge.sh'
|
||||
: field.placeholder || field.default
|
||||
}
|
||||
required={field.required && !provider}
|
||||
/>
|
||||
{field.hint && (
|
||||
<p id={`hint-${field.name}`} className="text-sm text-content-muted">
|
||||
{field.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</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
|
||||
id="propagation-timeout"
|
||||
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
|
||||
id="polling-interval"
|
||||
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>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/DNSProviderSelector.tsx
Normal file
105
frontend/src/components/DNSProviderSelector.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/ImportBanner.tsx
Normal file
30
frontend/src/components/ImportBanner.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
349
frontend/src/components/ImportReviewTable.tsx
Normal file
349
frontend/src/components/ImportReviewTable.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
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.'
|
||||
}
|
||||
99
frontend/src/components/ImportSitesModal.test.tsx
Normal file
99
frontend/src/components/ImportSitesModal.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import ImportSitesModal from './ImportSitesModal'
|
||||
import { vi } from 'vitest'
|
||||
import { CaddyFile } from '../api/import'
|
||||
|
||||
// Mock the upload API used by the component
|
||||
const mockUpload = vi.fn()
|
||||
vi.mock('../api/import', () => ({
|
||||
uploadCaddyfilesMulti: (files: CaddyFile[]) => mockUpload(files),
|
||||
}))
|
||||
|
||||
describe('ImportSitesModal', () => {
|
||||
beforeEach(() => {
|
||||
mockUpload.mockReset()
|
||||
})
|
||||
|
||||
test('renders modal, add and remove sites, and edits textarea', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ImportSitesModal visible={true} onClose={onClose} />)
|
||||
|
||||
// modal container is present
|
||||
expect(screen.getByTestId('multi-site-modal')).toBeInTheDocument()
|
||||
|
||||
// initially one site with filename input and content textarea
|
||||
const textareas = screen.getAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA')
|
||||
expect(textareas.length).toBe(1)
|
||||
|
||||
// add a site -> two sites
|
||||
fireEvent.click(screen.getByText('+ Add site'))
|
||||
const textareasAfterAdd = screen.getAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA')
|
||||
expect(textareasAfterAdd.length).toBe(2)
|
||||
|
||||
// remove the second site (use getAllByText since multiple Remove buttons now exist)
|
||||
const removeButtons = screen.getAllByText('Remove')
|
||||
fireEvent.click(removeButtons[removeButtons.length - 1])
|
||||
const textareasAfterRemove = screen.getAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA')
|
||||
expect(textareasAfterRemove.length).toBe(1)
|
||||
|
||||
// type into textarea
|
||||
const ta = screen.getAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA')[0]
|
||||
fireEvent.change(ta, { target: { value: 'example.com { reverse_proxy 127.0.0.1:8080 }' } })
|
||||
expect((ta as HTMLTextAreaElement).value).toContain('example.com')
|
||||
})
|
||||
|
||||
test('reads multiple files via hidden input and submits successfully', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onUploaded = vi.fn()
|
||||
mockUpload.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { container } = render(<ImportSitesModal visible={true} onClose={onClose} onUploaded={onUploaded} />)
|
||||
|
||||
// find the hidden file input
|
||||
const input: HTMLInputElement | null = container.querySelector('input[type="file"]')
|
||||
expect(input).toBeTruthy()
|
||||
|
||||
// create two files (note: jsdom's File.text() returns empty strings, so we'll set content manually)
|
||||
const f1 = new File(['site1'], 'site1.caddy', { type: 'text/plain' })
|
||||
const f2 = new File(['site2'], 'site2.caddy', { type: 'text/plain' })
|
||||
|
||||
// fire change event with files
|
||||
fireEvent.change(input!, { target: { files: [f1, f2] } })
|
||||
|
||||
// after input, two textareas should appear (one per file)
|
||||
await waitFor(() => {
|
||||
const textareas = screen.getAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA')
|
||||
expect(textareas.length).toBe(2)
|
||||
})
|
||||
|
||||
// Manually fill textareas since jsdom's File.text() doesn't work correctly
|
||||
const textareas = screen.getAllByRole('textbox').filter(el => el.tagName === 'TEXTAREA')
|
||||
fireEvent.change(textareas[0], { target: { value: 'site1' } })
|
||||
fireEvent.change(textareas[1], { target: { value: 'site2' } })
|
||||
|
||||
// submit
|
||||
fireEvent.click(screen.getByText('Parse and Review'))
|
||||
|
||||
await waitFor(() => expect(mockUpload).toHaveBeenCalled())
|
||||
// New API contract: files are passed as {filename, content} objects
|
||||
expect(mockUpload).toHaveBeenCalledWith([
|
||||
{ filename: 'site1.caddy', content: 'site1' },
|
||||
{ filename: 'site2.caddy', content: 'site2' },
|
||||
])
|
||||
expect(onUploaded).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('displays error when upload fails', async () => {
|
||||
const onClose = vi.fn()
|
||||
mockUpload.mockRejectedValueOnce(new Error('upload-failed'))
|
||||
|
||||
render(<ImportSitesModal visible={true} onClose={onClose} />)
|
||||
|
||||
// click submit with default empty site
|
||||
fireEvent.click(screen.getByText('Parse and Review'))
|
||||
|
||||
// error message appears
|
||||
await waitFor(() => expect(screen.getByText(/upload-failed|Upload failed/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
143
frontend/src/components/ImportSitesModal.tsx
Normal file
143
frontend/src/components/ImportSitesModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react'
|
||||
import { uploadCaddyfilesMulti, CaddyFile } from '../api/import'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onUploaded?: () => void
|
||||
}
|
||||
|
||||
interface SiteEntry {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function ImportSitesModal({ visible, onClose, onUploaded }: Props) {
|
||||
const [sites, setSites] = useState<SiteEntry[]>([{ filename: 'Caddyfile-1', content: '' }])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const setSiteContent = (index: number, value: string) => {
|
||||
const s = [...sites]
|
||||
s[index] = { ...s[index], content: value }
|
||||
setSites(s)
|
||||
}
|
||||
|
||||
const setSiteFilename = (index: number, value: string) => {
|
||||
const s = [...sites]
|
||||
s[index] = { ...s[index], filename: value }
|
||||
setSites(s)
|
||||
}
|
||||
|
||||
const handleFileInput = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const newSites: SiteEntry[] = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
try {
|
||||
const text = await files[i].text()
|
||||
newSites.push({ filename: files[i].name, content: text })
|
||||
} catch {
|
||||
// ignore read errors for individual files
|
||||
newSites.push({ filename: files[i].name, content: '' })
|
||||
}
|
||||
}
|
||||
if (newSites.length > 0) setSites(newSites)
|
||||
}
|
||||
|
||||
const addSite = () => setSites(prev => [...prev, { filename: `Caddyfile-${prev.length + 1}`, content: '' }])
|
||||
const removeSite = (index: number) => setSites(prev => prev.filter((_, i) => i !== index))
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const cleaned: CaddyFile[] = sites.map((s, i) => ({
|
||||
filename: s.filename || `Caddyfile-${i + 1}`,
|
||||
content: s.content || '',
|
||||
}))
|
||||
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"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="multi-site-modal-title"
|
||||
data-testid="multi-site-modal"
|
||||
>
|
||||
<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 id="multi-site-modal-title" 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>
|
||||
|
||||
{/* Hidden file input so E2E tests can programmatically upload multiple files */}
|
||||
<input
|
||||
type="file"
|
||||
accept=".caddy,.caddyfile,.txt,text/plain"
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 max-h-[60vh] overflow-auto mb-4">
|
||||
{sites.map((site, idx) => (
|
||||
<div key={idx} className="border border-gray-800 rounded-lg p-3">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={site.filename}
|
||||
onChange={e => setSiteFilename(idx, e.target.value)}
|
||||
className="text-sm text-gray-300 bg-transparent border-b border-gray-700 focus:border-blue-500 focus:outline-none"
|
||||
placeholder={`Caddyfile-${idx + 1}`}
|
||||
/>
|
||||
<div>
|
||||
{sites.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeSite(idx)}
|
||||
className="text-red-400 text-sm hover:underline mr-2"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={site.content}
|
||||
onChange={e => setSiteContent(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>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/LanguageSelector.tsx
Normal file
39
frontend/src/components/LanguageSelector.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
383
frontend/src/components/Layout.tsx
Normal file
383
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
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: '📧' },
|
||||
...(user?.role === 'admin' ? [{ name: t('navigation.users'), path: '/settings/users', 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 => {
|
||||
// Passthrough users see no navigation — they're redirected to /passthrough
|
||||
if (user?.role === 'passthrough') return false
|
||||
// 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">
|
||||
{/* Skip to main content link for accessibility */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-brand-500 focus:text-white focus:font-medium focus:rounded-md focus:m-2"
|
||||
>
|
||||
{t('accessibility.skipToContent')}
|
||||
</a>
|
||||
{/* 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 id="main-content" tabIndex={-1} 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/users" 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>
|
||||
)
|
||||
}
|
||||
517
frontend/src/components/LiveLogViewer.tsx
Normal file
517
frontend/src/components/LiveLogViewer.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
331
frontend/src/components/LoadingStates.tsx
Normal file
331
frontend/src/components/LoadingStates.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
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" data-testid="config-reload-overlay">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
117
frontend/src/components/LogFilters.tsx
Normal file
117
frontend/src/components/LogFilters.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
</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"
|
||||
data-testid="host-input"
|
||||
/>
|
||||
</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"
|
||||
data-testid="level-select"
|
||||
>
|
||||
<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"
|
||||
data-testid="status-select"
|
||||
>
|
||||
<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"
|
||||
data-testid="sort-select"
|
||||
>
|
||||
<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} data-testid="refresh-button">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={onDownload} variant="secondary" size="sm" data-testid="download-button">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
103
frontend/src/components/LogTable.tsx
Normal file
103
frontend/src/components/LogTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
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'}`}
|
||||
data-testid={`status-${log.status}`}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
156
frontend/src/components/NotificationCenter.tsx
Normal file
156
frontend/src/components/NotificationCenter.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
57
frontend/src/components/PasswordStrengthMeter.tsx
Normal file
57
frontend/src/components/PasswordStrengthMeter.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
272
frontend/src/components/PermissionsPolicyBuilder.tsx
Normal file
272
frontend/src/components/PermissionsPolicyBuilder.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
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"
|
||||
aria-label="Select Feature"
|
||||
>
|
||||
{FEATURES.map((feature) => (
|
||||
<option key={feature} value={feature}>
|
||||
{feature}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
<NativeSelect
|
||||
value={newAllowlist}
|
||||
onChange={(e) => setNewAllowlist(e.target.value)}
|
||||
className="w-40"
|
||||
aria-label="Select Allowlist Origin"
|
||||
>
|
||||
{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} aria-label="Add Feature">
|
||||
<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)}
|
||||
aria-label={`Remove ${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>
|
||||
);
|
||||
}
|
||||
1583
frontend/src/components/ProxyHostForm.tsx
Normal file
1583
frontend/src/components/ProxyHostForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user