chore: clean .gitignore cache
@@ -1,16 +0,0 @@
|
||||
# Frontend (Vite + React)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
@@ -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']],
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
6885
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 105 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||