chore: clean .gitignore cache

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

View File

@@ -1,16 +0,0 @@
# Frontend (Vite + React)
## Development
```bash
cd frontend
npm install
npm run dev
```
## Production build
```bash
cd frontend
npm run build
```

View File

@@ -1,10 +0,0 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests',
timeout: 30_000,
use: {
baseURL: process.env.CHARON_BASE_URL || 'http://localhost:8080',
},
reporter: [['list']],
})

View File

@@ -1,297 +0,0 @@
/**
* Security Dashboard Mobile Responsive E2E Tests
* Test IDs: MR-01 through MR-10
*
* Tests mobile viewport (375x667), tablet viewport (768x1024),
* touch targets, scrolling, and layout responsiveness.
*/
import { test, expect } from '@bgotink/playwright-coverage'
const base = process.env.CHARON_BASE_URL || 'http://localhost:8080'
test.describe('Security Dashboard Mobile (375x667)', () => {
test.use({ viewport: { width: 375, height: 667 } })
test('MR-01: cards stack vertically on mobile', async ({ page }) => {
await page.goto(`${base}/security`)
// Wait for page to load
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// On mobile, grid should be single column
const grid = page.locator('.grid.grid-cols-1')
await expect(grid).toBeVisible()
// Get the computed grid-template-columns
const cardsContainer = page.locator('.grid').first()
const gridStyle = await cardsContainer.evaluate((el) => {
const style = window.getComputedStyle(el)
return style.gridTemplateColumns
})
// Single column should have just one value (not multiple columns like "repeat(4, ...)")
const columns = gridStyle.split(' ').filter((s) => s.trim().length > 0)
expect(columns.length).toBeLessThanOrEqual(2) // Single column or flexible
})
test('MR-04: toggle switches have accessible touch targets', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Check CrowdSec toggle
const crowdsecToggle = page.getByTestId('toggle-crowdsec')
const crowdsecBox = await crowdsecToggle.boundingBox()
// Touch target should be at least 24px (component) + padding
// Most switches have a reasonable touch target
expect(crowdsecBox).not.toBeNull()
if (crowdsecBox) {
expect(crowdsecBox.height).toBeGreaterThanOrEqual(20)
expect(crowdsecBox.width).toBeGreaterThanOrEqual(35)
}
// Check WAF toggle
const wafToggle = page.getByTestId('toggle-waf')
const wafBox = await wafToggle.boundingBox()
expect(wafBox).not.toBeNull()
if (wafBox) {
expect(wafBox.height).toBeGreaterThanOrEqual(20)
}
})
test('MR-05: config buttons are tappable on mobile', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Find config/configure buttons
const configButtons = page.locator('button:has-text("Config"), button:has-text("Configure")')
const buttonCount = await configButtons.count()
expect(buttonCount).toBeGreaterThan(0)
// Check first config button has reasonable size
const firstButton = configButtons.first()
const box = await firstButton.boundingBox()
expect(box).not.toBeNull()
if (box) {
expect(box.height).toBeGreaterThanOrEqual(28) // Minimum tap height
}
})
test('MR-06: page content is scrollable on mobile', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Check if page is scrollable (content height > viewport)
const bodyHeight = await page.evaluate(() => document.body.scrollHeight)
const viewportHeight = 667
// If content is taller than viewport, page should scroll
if (bodyHeight > viewportHeight) {
// Attempt to scroll down
await page.evaluate(() => window.scrollBy(0, 200))
const scrollY = await page.evaluate(() => window.scrollY)
expect(scrollY).toBeGreaterThan(0)
}
})
test('MR-10: navigation is accessible on mobile', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// On mobile, there should be some form of navigation
// Check if sidebar or mobile menu toggle exists
const sidebar = page.locator('nav, aside, [role="navigation"]')
const sidebarCount = await sidebar.count()
// Navigation should exist in some form
expect(sidebarCount).toBeGreaterThanOrEqual(0) // May be hidden on mobile
})
test('MR-06b: overlay renders correctly on mobile', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Skip if Cerberus is disabled (toggles would be disabled)
const cerberusDisabled = await page.locator('text=Cerberus Disabled').isVisible()
if (cerberusDisabled) {
test.skip()
return
}
// Trigger loading state by clicking a toggle
const wafToggle = page.getByTestId('toggle-waf')
const isDisabled = await wafToggle.isDisabled()
if (!isDisabled) {
await wafToggle.click()
// Check for overlay (may appear briefly)
// Use a short timeout since it might disappear quickly
try {
const overlay = page.locator('.fixed.inset-0')
await overlay.waitFor({ state: 'visible', timeout: 2000 })
// If overlay appeared, verify it fits screen
const box = await overlay.boundingBox()
if (box) {
expect(box.width).toBeLessThanOrEqual(375 + 10) // Allow small margin
}
} catch {
// Overlay might have disappeared before we could check
// This is acceptable for a fast operation
}
}
})
})
test.describe('Security Dashboard Tablet (768x1024)', () => {
test.use({ viewport: { width: 768, height: 1024 } })
test('MR-02: cards show 2 columns on tablet', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// On tablet (md breakpoint), should have md:grid-cols-2
const grid = page.locator('.grid').first()
await expect(grid).toBeVisible()
// Get computed style
const gridStyle = await grid.evaluate((el) => {
const style = window.getComputedStyle(el)
return style.gridTemplateColumns
})
// Should have 2 columns at md breakpoint
const columns = gridStyle.split(' ').filter((s) => s.trim().length > 0 && s !== 'none')
expect(columns.length).toBeGreaterThanOrEqual(2)
})
test('MR-08: cards have proper spacing on tablet', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Check gap between cards
const grid = page.locator('.grid.gap-6').first()
const hasGap = await grid.isVisible()
expect(hasGap).toBe(true)
})
})
test.describe('Security Dashboard Desktop (1920x1080)', () => {
test.use({ viewport: { width: 1920, height: 1080 } })
test('MR-03: cards show 4 columns on desktop', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// On desktop (lg breakpoint), should have lg:grid-cols-4
const grid = page.locator('.grid').first()
await expect(grid).toBeVisible()
// Get computed style
const gridStyle = await grid.evaluate((el) => {
const style = window.getComputedStyle(el)
return style.gridTemplateColumns
})
// Should have 4 columns at lg breakpoint
const columns = gridStyle.split(' ').filter((s) => s.trim().length > 0 && s !== 'none')
expect(columns.length).toBeGreaterThanOrEqual(4)
})
})
test.describe('Security Dashboard Layout Tests', () => {
test('cards maintain correct order across viewports', async ({ page }) => {
// Test on mobile
await page.setViewportSize({ width: 375, height: 667 })
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Get card headings
const getCardOrder = async () => {
const headings = await page.locator('h3').allTextContents()
return headings.filter((h) => ['CrowdSec', 'Access Control', 'Coraza', 'Rate Limiting'].includes(h))
}
const mobileOrder = await getCardOrder()
// Test on tablet
await page.setViewportSize({ width: 768, height: 1024 })
await page.waitForTimeout(100) // Allow reflow
const tabletOrder = await getCardOrder()
// Test on desktop
await page.setViewportSize({ width: 1920, height: 1080 })
await page.waitForTimeout(100) // Allow reflow
const desktopOrder = await getCardOrder()
// Order should be consistent
expect(mobileOrder).toEqual(tabletOrder)
expect(tabletOrder).toEqual(desktopOrder)
expect(desktopOrder).toEqual(['CrowdSec', 'Access Control', 'Coraza', 'Rate Limiting'])
})
test('MR-09: all security cards are visible on scroll', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Scroll to each card type
const cardTypes = ['CrowdSec', 'Access Control', 'Coraza', 'Rate Limiting']
for (const cardType of cardTypes) {
const card = page.locator(`h3:has-text("${cardType}")`)
await card.scrollIntoViewIfNeeded()
await expect(card).toBeVisible()
}
})
})
test.describe('Security Dashboard Interaction Tests', () => {
test.use({ viewport: { width: 375, height: 667 } })
test('MR-07: config buttons navigate correctly on mobile', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Skip if Cerberus disabled
const cerberusDisabled = await page.locator('text=Cerberus Disabled').isVisible()
if (cerberusDisabled) {
test.skip()
return
}
// Find and click WAF Configure button
const configureButton = page.locator('button:has-text("Configure")').first()
if (await configureButton.isVisible()) {
await configureButton.click()
// Should navigate to a config page
await page.waitForTimeout(500)
const url = page.url()
// URL should include security/waf or security/rate-limiting etc
expect(url).toMatch(/security\/(waf|rate-limiting|access-lists|crowdsec)/i)
}
})
test('documentation button works on mobile', async ({ page }) => {
await page.goto(`${base}/security`)
await page.waitForSelector('[data-testid="toggle-crowdsec"]', { timeout: 10000 })
// Find documentation button
const docButton = page.locator('button:has-text("Documentation"), a:has-text("Documentation")').first()
if (await docButton.isVisible()) {
// Check it has correct external link behavior
const href = await docButton.getAttribute('href')
// Should open external docs
if (href) {
expect(href).toContain('wikid82.github.io')
}
}
})
})

View File

@@ -1,34 +0,0 @@
import { test, expect } from '@bgotink/playwright-coverage'
const base = process.env.CHARON_BASE_URL || 'http://localhost:8080'
// Hit an API route inside /api/v1 to ensure Cerberus middleware executes.
const targetPath = '/api/v1/system/my-ip'
test.describe('WAF blocking and monitoring', () => {
test('blocks malicious query when mode=block', async ({ request }) => {
// Use literal '<script>' to trigger naive WAF check
const res = await request.get(`${base}${targetPath}?<script>=x`)
expect([400, 401]).toContain(res.status())
// When WAF runs before auth, expect 400; if auth runs first, we still validate that the server rejects
if (res.status() === 400) {
const body = await res.json()
expect(body?.error).toMatch(/WAF: suspicious payload/i)
}
})
test('does not block when mode=monitor (returns 401 due to auth)', async ({ request }) => {
const res = await request.get(`${base}${targetPath}?safe=yes`)
// Unauthenticated → expect 401, not 400; proves WAF did not block
expect([401, 403]).toContain(res.status())
})
test('metrics endpoint exposes Prometheus counters', async ({ request }) => {
const res = await request.get(`${base}/metrics`)
expect(res.status()).toBe(200)
const text = await res.text()
expect(text).toContain('charon_waf_requests_total')
expect(text).toContain('charon_waf_blocked_total')
expect(text).toContain('charon_waf_monitored_total')
})
})

View File

@@ -1,21 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactRefresh from 'eslint-plugin-react-refresh';
import reactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config(
{ ignores: ['dist/**', 'node_modules/**', 'coverage/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
plugins: { 'react-refresh': reactRefresh, 'react-hooks': reactHooks },
rules: {
'react-refresh/only-export-components': 'warn',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn'
}
}
);

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Charon</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
{
"name": "charon-frontend",
"private": true,
"version": "0.3.0",
"type": "module",
"tools": [],
"constraints": [
"NPM SCRIPTS ONLY: Do not try to construct complex `vitest` or `playwright` commands. Always look at `package.json` first and use `npm run <script-name>`."
],
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.build.json && vite build",
"pretype-check": "npm ci --silent",
"type-check": "tsc --noEmit",
"lint": "eslint . --report-unused-disable-directives",
"preview": "vite preview",
"test": "vitest run",
"test:ci": "vitest run",
"test:ui": "vitest --ui",
"check-coverage": "bash ../scripts/frontend-test-coverage.sh",
"pretest:coverage": "npm ci --silent && node -e \"require('fs').mkdirSync('coverage/.tmp', { recursive: true })\"",
"test:coverage": "vitest run --coverage --coverage.provider=istanbul --coverage.reporter=json-summary --coverage.reporter=lcov --coverage.reporter=text",
"e2e:install": "npx playwright install --with-deps",
"e2e:test": "playwright test",
"e2e:up:block": "docker compose -f ../.docker/compose/docker-compose.local.yml down && CHARON_SECURITY_WAF_MODE=block docker compose -f ../.docker/compose/docker-compose.local.yml up -d",
"e2e:up:monitor": "docker compose -f ../.docker/compose/docker-compose.local.yml down && CHARON_SECURITY_WAF_MODE=monitor docker compose -f ../.docker/compose/docker-compose.local.yml up -d",
"e2e:down": "docker compose -f ../.docker/compose/docker-compose.local.yml down"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.563.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.71.1",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.5.3",
"react-router-dom": "^7.13.0",
"tailwind-merge": "^3.4.0",
"tldts": "^7.0.19"
},
"devDependencies": {
"@playwright/test": "^1.58.0",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.10",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.25",
"jsdom": "^27.4.0",
"knip": "^5.82.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -1,76 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Site Not Configured | Charon</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f3f4f6;
color: #1f2937;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
text-align: center;
}
.container {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
max-width: 500px;
width: 90%;
}
h1 {
color: #4f46e5;
margin-bottom: 1rem;
}
p {
margin-bottom: 1.5rem;
line-height: 1.5;
color: #4b5563;
}
.logo {
font-size: 3rem;
margin-bottom: 1rem;
}
.btn {
display: inline-block;
background-color: #4f46e5;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 500;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #4338ca;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🛡️</div>
<h1>Site Not Configured</h1>
<p>
The domain you are trying to access is pointing to this server, but no proxy host has been configured for it yet.
</p>
<p>
If you are the administrator, please log in to the Charon dashboard to configure this host.
</p>
<a href="http://localhost:8080" id="admin-link" class="btn">Go to Dashboard</a>
</div>
<script>
// Dynamically update the admin link to point to port 8080 on the current hostname
const link = document.getElementById('admin-link');
const currentHost = window.location.hostname;
link.href = `http://${currentHost}:8080`;
</script>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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