chore: clean .gitignore cache

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,136 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Activity, CheckCircle2, XCircle, AlertCircle, ArrowRight } from 'lucide-react'
import { getMonitors } from '../api/uptime'
import { Card, CardHeader, CardContent, Badge, Skeleton } from './ui'
export default function UptimeWidget() {
const { data: monitors, isLoading } = useQuery({
queryKey: ['monitors'],
queryFn: getMonitors,
refetchInterval: 30000,
})
const upCount = monitors?.filter(m => m.status === 'up').length || 0
const downCount = monitors?.filter(m => m.status === 'down').length || 0
const totalCount = monitors?.length || 0
const allUp = totalCount > 0 && downCount === 0
const hasDown = downCount > 0
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-24" />
</div>
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-6 w-48" />
<div className="flex gap-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex gap-1">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-3 flex-1 rounded-sm" />
))}
</div>
</CardContent>
</Card>
)
}
return (
<Link to="/uptime" 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">
<Activity className="h-5 w-5" />
</div>
<span className="text-sm font-medium text-content-secondary">Uptime Status</span>
</div>
{hasDown && (
<Badge variant="error" size="sm" className="animate-pulse">
Issues
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{totalCount === 0 ? (
<p className="text-content-muted text-sm">No monitors configured</p>
) : (
<>
{/* Status indicator */}
<div className="flex items-center gap-2">
{allUp ? (
<>
<CheckCircle2 className="h-6 w-6 text-success" />
<span className="text-lg font-bold text-success">All Systems Operational</span>
</>
) : hasDown ? (
<>
<XCircle className="h-6 w-6 text-error" />
<span className="text-lg font-bold text-error">
{downCount} {downCount === 1 ? 'Site' : 'Sites'} Down
</span>
</>
) : (
<>
<AlertCircle className="h-6 w-6 text-warning" />
<span className="text-lg font-bold text-warning">Unknown Status</span>
</>
)}
</div>
{/* Quick stats */}
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-success"></span>
<span className="text-content-secondary">{upCount} up</span>
</div>
{downCount > 0 && (
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-error"></span>
<span className="text-content-secondary">{downCount} down</span>
</div>
)}
<div className="text-content-muted">
{totalCount} total
</div>
</div>
{/* Mini status bars */}
{monitors && monitors.length > 0 && (
<div className="flex gap-1">
{monitors.slice(0, 20).map((monitor) => (
<div
key={monitor.id}
className={`flex-1 h-2.5 rounded-sm transition-colors duration-fast ${
monitor.status === 'up' ? 'bg-success' : 'bg-error'
}`}
title={`${monitor.name}: ${monitor.status.toUpperCase()}`}
/>
))}
{monitors.length > 20 && (
<div className="text-xs text-content-muted ml-1">+{monitors.length - 20}</div>
)}
</div>
)}
</>
)}
<div className="flex items-center gap-1 text-xs text-content-muted group-hover:text-brand-400 transition-colors duration-fast">
<span>View detailed status</span>
<ArrowRight className="h-3 w-3" />
</div>
</CardContent>
</Card>
</Link>
)
}

View File

@@ -1,175 +0,0 @@
import { useState } from 'react';
import { Wifi, WifiOff, Activity, Clock, Filter, Globe } from 'lucide-react';
import { useWebSocketConnections, useWebSocketStats } from '../hooks/useWebSocketStatus';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Badge,
Skeleton,
Alert,
} from './ui';
import { formatDistanceToNow } from 'date-fns';
interface WebSocketStatusCardProps {
className?: string;
showDetails?: boolean;
}
/**
* Component to display WebSocket connection status and statistics
*/
export function WebSocketStatusCard({ className = '', showDetails = false }: WebSocketStatusCardProps) {
const [expanded, setExpanded] = useState(showDetails);
const { data: connections, isLoading: connectionsLoading } = useWebSocketConnections();
const { data: stats, isLoading: statsLoading } = useWebSocketStats();
const isLoading = connectionsLoading || statsLoading;
if (isLoading) {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</CardContent>
</Card>
);
}
if (!stats) {
return (
<Alert variant="warning" className={className}>
Unable to load WebSocket status
</Alert>
);
}
const hasActiveConnections = stats.total_active > 0;
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${hasActiveConnections ? 'bg-success/10' : 'bg-surface-muted'}`}>
{hasActiveConnections ? (
<Wifi className="w-5 h-5 text-success" />
) : (
<WifiOff className="w-5 h-5 text-content-muted" />
)}
</div>
<div>
<CardTitle className="text-lg">WebSocket Connections</CardTitle>
<CardDescription>
Real-time connection monitoring
</CardDescription>
</div>
</div>
<Badge variant={hasActiveConnections ? 'success' : 'default'}>
{stats.total_active} Active
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Statistics Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-content-muted">
<Activity className="w-4 h-4" />
<span>General Logs</span>
</div>
<p className="text-2xl font-semibold">{stats.logs_connections}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-content-muted">
<Activity className="w-4 h-4" />
<span>Security Logs</span>
</div>
<p className="text-2xl font-semibold">{stats.cerberus_connections}</p>
</div>
</div>
{/* Oldest Connection */}
{stats.oldest_connection && (
<div className="pt-3 border-t border-border">
<div className="flex items-center gap-2 text-sm text-content-muted mb-1">
<Clock className="w-4 h-4" />
<span>Oldest Connection</span>
</div>
<p className="text-sm">
{formatDistanceToNow(new Date(stats.oldest_connection), { addSuffix: true })}
</p>
</div>
)}
{/* Connection Details */}
{expanded && connections?.connections && connections.connections.length > 0 && (
<div className="pt-3 border-t border-border space-y-3">
<p className="text-sm font-medium">Active Connections</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
{connections.connections.map((conn) => (
<div
key={conn.id}
className="p-3 rounded-lg bg-surface-muted space-y-2 text-xs"
>
<div className="flex items-center justify-between">
<Badge variant={conn.type === 'logs' ? 'default' : 'success'} size="sm">
{conn.type === 'logs' ? 'General' : 'Security'}
</Badge>
<span className="text-content-muted font-mono">
{conn.id.substring(0, 8)}...
</span>
</div>
{conn.remote_addr && (
<div className="flex items-center gap-2 text-content-muted">
<Globe className="w-3 h-3" />
<span>{conn.remote_addr}</span>
</div>
)}
{conn.filters && (
<div className="flex items-center gap-2 text-content-muted">
<Filter className="w-3 h-3" />
<span className="truncate">{conn.filters}</span>
</div>
)}
<div className="flex items-center gap-2 text-content-muted">
<Clock className="w-3 h-3" />
<span>
Connected {formatDistanceToNow(new Date(conn.connected_at), { addSuffix: true })}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Toggle Details Button */}
{connections?.connections && connections.connections.length > 0 && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full pt-3 text-sm text-primary hover:text-primary/80 transition-colors"
>
{expanded ? 'Hide Details' : 'Show Details'}
</button>
)}
{/* No Connections Message */}
{!hasActiveConnections && (
<div className="pt-3 text-center text-sm text-content-muted">
No active WebSocket connections
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,124 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AccessListSelector from '../AccessListSelector';
import * as useAccessListsHook from '../../hooks/useAccessLists';
import type { AccessList } from '../../api/accessLists';
// Mock the hooks
vi.mock('../../hooks/useAccessLists');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('AccessListSelector', () => {
it('should render with no access lists', () => {
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: [],
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
render(
<Wrapper>
<AccessListSelector value={null} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByText('No Access Control (Public)')).toBeInTheDocument();
});
it('should render with access lists and show only enabled ones', () => {
const mockLists: AccessList[] = [
{
id: 1,
uuid: 'uuid-1',
name: 'Test ACL 1',
description: 'Description 1',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
{
id: 2,
uuid: 'uuid-2',
name: 'Test ACL 2',
description: 'Description 2',
type: 'blacklist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: false,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists,
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
render(
<Wrapper>
<AccessListSelector value={null} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByText('Test ACL 1 (whitelist)')).toBeInTheDocument();
expect(screen.queryByText('Test ACL 2 (blacklist)')).not.toBeInTheDocument();
});
it('should show selected ACL details', () => {
const mockLists: AccessList[] = [
{
id: 1,
uuid: 'uuid-1',
name: 'Selected ACL',
description: 'This is selected',
type: 'geo_whitelist',
ip_rules: '[]',
country_codes: 'US,CA',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists,
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
render(
<Wrapper>
<AccessListSelector value={1} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByText('Selected ACL')).toBeInTheDocument();
expect(screen.getByText('This is selected')).toBeInTheDocument();
expect(screen.getByText(/Countries: US,CA/)).toBeInTheDocument();
});
});

View File

@@ -1,235 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CSPBuilder } from '../CSPBuilder';
describe('CSPBuilder', () => {
const mockOnChange = vi.fn();
const mockOnValidate = vi.fn();
const defaultProps = {
value: '',
onChange: mockOnChange,
onValidate: mockOnValidate,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render with empty directives', () => {
render(<CSPBuilder {...defaultProps} />);
expect(screen.getByText('Content Security Policy Builder')).toBeInTheDocument();
expect(screen.getByText('No CSP directives configured. Add directives above to build your policy.')).toBeInTheDocument();
});
it('should add a directive', async () => {
render(<CSPBuilder {...defaultProps} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
const addButton = screen.getByRole('button', { name: '' }); // Plus button
fireEvent.change(valueInput, { target: { value: "'self'" } });
fireEvent.click(addButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[0][0];
const parsed = JSON.parse(callArg);
expect(parsed).toEqual([
{ directive: 'default-src', values: ["'self'"] },
]);
});
it('should remove a directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
await waitFor(() => {
const directiveElements = screen.getAllByText('default-src');
expect(directiveElements.length).toBeGreaterThan(0);
});
// Find the X button in the directive row (not in the select)
const allButtons = screen.getAllByRole('button');
const removeButton = allButtons.find(btn => {
const svg = btn.querySelector('svg');
return svg && btn.closest('.bg-gray-50, .dark\\:bg-gray-800');
});
if (removeButton) {
fireEvent.click(removeButton);
}
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
});
it('should apply preset', async () => {
render(<CSPBuilder {...defaultProps} />);
const presetButton = screen.getByRole('button', { name: 'Strict Default' });
fireEvent.click(presetButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[0][0];
const parsed = JSON.parse(callArg);
expect(parsed.length).toBeGreaterThan(0);
expect(parsed[0].directive).toBe('default-src');
});
it('should toggle preview display', () => {
render(<CSPBuilder {...defaultProps} />);
const previewButton = screen.getByRole('button', { name: /Show Preview/ });
expect(screen.queryByText('Generated CSP Header:')).not.toBeInTheDocument();
fireEvent.click(previewButton);
expect(screen.getByRole('button', { name: /Hide Preview/ })).toBeInTheDocument();
});
it('should validate CSP and show warnings', async () => {
render(<CSPBuilder {...defaultProps} />);
// Add an unsafe directive to trigger validation
const directiveSelect = screen.getAllByRole('combobox')[0];
const valueInput = screen.getByPlaceholderText(/Enter value/);
const addButton = screen.getAllByRole('button').find(btn => btn.querySelector('.lucide-plus'));
fireEvent.change(directiveSelect, { target: { value: 'script-src' } });
fireEvent.change(valueInput, { target: { value: "'unsafe-inline'" } });
if (addButton) {
fireEvent.click(addButton);
}
await waitFor(() => {
expect(mockOnValidate).toHaveBeenCalled();
const validateCall = mockOnValidate.mock.calls.find(call => call[1]?.length > 0);
expect(validateCall).toBeDefined();
});
const validateCall = mockOnValidate.mock.calls.find(call => call[1]?.length > 0);
expect(validateCall?.[0]).toBe(false);
expect(validateCall?.[1]).toContain('Using unsafe-inline or unsafe-eval weakens CSP protection');
});
it('should not add duplicate values to same directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
const allButtons = screen.getAllByRole('button');
const addButton = allButtons.find(btn => btn.querySelector('.lucide-plus'));
// Try to add the same value again
fireEvent.change(valueInput, { target: { value: "'self'" } });
if (addButton) {
fireEvent.click(addButton);
}
await waitFor(() => {
// Should not call onChange since it's a duplicate
const calls = mockOnChange.mock.calls.filter(call => {
const parsed = JSON.parse(call[0]);
return parsed[0].values.filter((v: string) => v === "'self'").length > 1;
});
expect(calls.length).toBe(0);
});
});
it('should parse initial value correctly', () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'", 'https:'] },
{ directive: 'script-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
// Use getAllByText since these appear in both the select and the directive list
const defaultSrcElements = screen.getAllByText('default-src');
expect(defaultSrcElements.length).toBeGreaterThan(0);
const scriptSrcElements = screen.getAllByText('script-src');
expect(scriptSrcElements.length).toBeGreaterThan(0);
const selfElements = screen.getAllByText("'self'");
expect(selfElements.length).toBeGreaterThan(0);
});
it('should change directive selector', () => {
render(<CSPBuilder {...defaultProps} />);
// Get the first combobox (the directive selector)
const allSelects = screen.getAllByRole('combobox');
const directiveSelect = allSelects[0];
fireEvent.change(directiveSelect, { target: { value: 'script-src' } });
expect(directiveSelect).toHaveValue('script-src');
});
it('should handle Enter key to add directive', async () => {
render(<CSPBuilder {...defaultProps} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
fireEvent.change(valueInput, { target: { value: "'self'" } });
fireEvent.keyDown(valueInput, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
});
it('should not add empty values', () => {
render(<CSPBuilder {...defaultProps} />);
const addButton = screen.getByRole('button', { name: '' });
fireEvent.click(addButton);
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should remove individual values from directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'", 'https:', 'data:'] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
const selfBadge = screen.getByText("'self'");
fireEvent.click(selfBadge);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0];
const parsed = JSON.parse(callArg);
expect(parsed[0].values).not.toContain("'self'");
expect(parsed[0].values).toContain('https:');
});
it('should show success alert when valid', async () => {
const validValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={validValue} />);
await waitFor(() => {
expect(screen.getByText('CSP configuration looks good!')).toBeInTheDocument();
});
});
});

View File

@@ -1,113 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClientProvider } from '@tanstack/react-query'
import CertificateList from '../CertificateList'
import { createTestQueryClient } from '../../test/createTestQueryClient'
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(() => ({
certificates: [
{ id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: new Date().toISOString(), status: 'expired', provider: 'custom' },
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: new Date().toISOString(), status: 'untrusted', provider: 'letsencrypt-staging' },
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: new Date().toISOString(), status: 'valid', provider: 'custom' },
],
isLoading: false,
error: null,
}))
}))
vi.mock('../../api/certificates', () => ({
deleteCertificate: vi.fn(async () => undefined),
}))
vi.mock('../../api/backups', () => ({
createBackup: vi.fn(async () => ({ filename: 'backup-cert' })),
}))
vi.mock('../../hooks/useProxyHosts', () => ({
useProxyHosts: vi.fn(() => ({
hosts: [
{ uuid: 'h1', name: 'Host1', certificate_id: 3 },
],
loading: false,
isFetching: false,
error: null,
createHost: vi.fn(),
updateHost: vi.fn(),
deleteHost: vi.fn(),
bulkUpdateACL: vi.fn(),
isBulkUpdating: false,
})),
}))
vi.mock('../../utils/toast', () => ({
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}))
function renderWithClient(ui: React.ReactNode) {
const qc = createTestQueryClient()
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
}
describe('CertificateList', () => {
it('deletes custom certificate when confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
const { deleteCertificate } = await import('../../api/certificates')
const { createBackup } = await import('../../api/backups')
const { toast } = await import('../../utils/toast')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert')) as HTMLElement
expect(customRow).toBeTruthy()
const customBtn = customRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
expect(customBtn).toBeTruthy()
await user.click(customBtn)
await waitFor(() => expect(createBackup).toHaveBeenCalled())
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Certificate deleted'))
confirmSpy.mockRestore()
})
it('deletes staging certificate when confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
const { deleteCertificate } = await import('../../api/certificates')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const stagingButtons = await screen.findAllByTitle('Delete Staging Certificate')
expect(stagingButtons.length).toBeGreaterThan(0)
await user.click(stagingButtons[0])
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(2))
confirmSpy.mockRestore()
})
it('blocks deletion when certificate is in use by a proxy host', async () => {
const { toast } = await import('../../utils/toast')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
// Find button corresponding to ActiveCert (id 3)
const activeButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
expect(activeButton).toBeTruthy()
if (activeButton) await user.click(activeButton)
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('in use')))
})
it('blocks deletion when certificate status is active (valid/expiring)', async () => {
const { toast } = await import('../../utils/toast')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
// ActiveCert (valid) should block even if not linked ensure hosts mock links it so previous test covers linkage.
// Here, simulate clicking a valid cert button if present
const validButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
expect(validButton).toBeTruthy()
if (validButton) await user.click(validButton)
await waitFor(() => expect(toast.error).toHaveBeenCalled())
})
})

View File

@@ -1,321 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import CertificateStatusCard from '../CertificateStatusCard'
import type { Certificate } from '../../api/certificates'
import type { ProxyHost } from '../../api/proxyHosts'
const mockCert: Certificate = {
id: 1,
name: 'Test Cert',
domain: 'example.com',
issuer: "Let's Encrypt",
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
status: 'valid',
provider: 'letsencrypt',
}
const mockHost: ProxyHost = {
uuid: 'test-uuid',
name: 'Test Host',
domain_names: 'example.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: false,
enabled: true,
certificate_id: null,
access_list_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: false,
websocket_support: false,
application: 'none',
locations: [],
}
// Helper to create a certificate with a specific domain
function mockCertWithDomain(domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate {
return {
id: Math.floor(Math.random() * 10000),
name: domain,
domain: domain,
issuer: "Let's Encrypt",
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
status,
provider: 'letsencrypt',
}
}
function renderWithRouter(ui: React.ReactNode) {
return render(<BrowserRouter>{ui}</BrowserRouter>)
}
describe('CertificateStatusCard', () => {
it('shows total certificate count', () => {
const certs: Certificate[] = [mockCert, { ...mockCert, id: 2 }, { ...mockCert, id: 3 }]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('SSL Certificates')).toBeInTheDocument()
})
it('shows valid certificate count', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'valid' },
{ ...mockCert, id: 2, status: 'valid' },
{ ...mockCert, id: 3, status: 'expired' },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.getByText('2 valid')).toBeInTheDocument()
})
it('shows expiring count when certificates are expiring', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'expiring' },
{ ...mockCert, id: 2, status: 'valid' },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.getByText('1 expiring')).toBeInTheDocument()
})
it('hides expiring count when no certificates are expiring', () => {
const certs: Certificate[] = [{ ...mockCert, status: 'valid' }]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.queryByText(/expiring/)).not.toBeInTheDocument()
})
it('shows staging count for untrusted certificates', () => {
const certs: Certificate[] = [
{ ...mockCert, status: 'untrusted' },
{ ...mockCert, id: 2, status: 'untrusted' },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.getByText('2 staging')).toBeInTheDocument()
})
it('hides staging count when no untrusted certificates', () => {
const certs: Certificate[] = [{ ...mockCert, status: 'valid' }]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.queryByText(/staging/)).not.toBeInTheDocument()
})
it('shows spinning loader icon when pending', () => {
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'other.com', ssl_forced: true, certificate_id: null, enabled: true },
]
const { container } = renderWithRouter(
<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />
)
const spinner = container.querySelector('.animate-spin')
expect(spinner).toBeInTheDocument()
})
it('links to certificates page', () => {
renderWithRouter(<CertificateStatusCard certificates={[mockCert]} hosts={[]} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/certificates')
})
it('handles empty certificates array', () => {
renderWithRouter(<CertificateStatusCard certificates={[]} hosts={[]} />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('No certificates')).toBeInTheDocument()
})
})
describe('CertificateStatusCard - Domain Matching', () => {
it('does not show pending when host domain matches certificate domain', () => {
const certs: Certificate[] = [mockCertWithDomain('example.com')]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
// Should NOT show "awaiting certificate" since domain matches
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('shows pending when host domain has no matching certificate', () => {
const certs: Certificate[] = [mockCertWithDomain('other.com')]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument()
})
it('shows plural for multiple pending hosts', () => {
const certs: Certificate[] = [mockCertWithDomain('has-cert.com')]
const hosts: ProxyHost[] = [
{ ...mockHost, uuid: 'h1', domain_names: 'no-cert-1.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h2', domain_names: 'no-cert-2.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h3', domain_names: 'no-cert-3.com', ssl_forced: true, certificate_id: null, enabled: true },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.getByText('3 hosts awaiting certificate')).toBeInTheDocument()
})
it('handles case-insensitive domain matching', () => {
const certs: Certificate[] = [mockCertWithDomain('EXAMPLE.COM')]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('handles case-insensitive matching with host uppercase', () => {
const certs: Certificate[] = [mockCertWithDomain('example.com')]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'EXAMPLE.COM', ssl_forced: true, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('handles multi-domain hosts with partial certificate coverage', () => {
// Host has two domains, but only one has a certificate - should be "covered"
const certs: Certificate[] = [mockCertWithDomain('example.com')]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com, www.example.com', ssl_forced: true, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
// Host should be considered "covered" if any domain has a cert
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('handles comma-separated certificate domains', () => {
const certs: Certificate[] = [{
...mockCertWithDomain('example.com'),
domain: 'example.com, www.example.com'
}]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('ignores disabled hosts even without certificate', () => {
const certs: Certificate[] = []
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: false }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('ignores hosts without SSL forced', () => {
const certs: Certificate[] = []
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com', ssl_forced: false, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('calculates progress percentage with domain matching', () => {
const certs: Certificate[] = [
mockCertWithDomain('a.example.com'),
mockCertWithDomain('b.example.com'),
]
const hosts: ProxyHost[] = [
{ ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h3', domain_names: 'c.example.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h4', domain_names: 'd.example.com', ssl_forced: true, certificate_id: null, enabled: true },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
// 2 out of 4 hosts have matching certs = 50%
expect(screen.getByText('50% provisioned')).toBeInTheDocument()
expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument()
})
it('shows all pending when no certificates exist', () => {
const certs: Certificate[] = []
const hosts: ProxyHost[] = [
{ ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument()
expect(screen.getByText('0% provisioned')).toBeInTheDocument()
})
it('shows 100% provisioned when all SSL hosts have matching certificates', () => {
const certs: Certificate[] = [
mockCertWithDomain('a.example.com'),
mockCertWithDomain('b.example.com'),
]
const hosts: ProxyHost[] = [
{ ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
// Should NOT show awaiting indicator when all hosts are covered
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
expect(screen.queryByText(/provisioned/)).not.toBeInTheDocument()
})
it('handles whitespace in domain names', () => {
const certs: Certificate[] = [mockCertWithDomain('example.com')]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: ' example.com ', ssl_forced: true, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('handles whitespace in certificate domains', () => {
const certs: Certificate[] = [{
...mockCertWithDomain('example.com'),
domain: ' example.com '
}]
const hosts: ProxyHost[] = [
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('correctly counts mix of covered and uncovered hosts', () => {
const certs: Certificate[] = [mockCertWithDomain('covered.com')]
const hosts: ProxyHost[] = [
{ ...mockHost, uuid: 'h1', domain_names: 'covered.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h2', domain_names: 'uncovered.com', ssl_forced: true, certificate_id: null, enabled: true },
{ ...mockHost, uuid: 'h3', domain_names: 'disabled.com', ssl_forced: true, certificate_id: null, enabled: false },
{ ...mockHost, uuid: 'h4', domain_names: 'no-ssl.com', ssl_forced: false, certificate_id: null, enabled: true },
]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
// Only h1 and h2 are SSL hosts that are enabled
// h1 is covered, h2 is not
expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument()
expect(screen.getByText('50% provisioned')).toBeInTheDocument()
})
})

View File

@@ -1,559 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import CredentialManager from '../CredentialManager'
import {
useCredentials,
useCreateCredential,
useUpdateCredential,
useDeleteCredential,
useTestCredential,
} from '../../hooks/useCredentials'
import type { DNSProvider, DNSProviderTypeInfo } from '../../api/dnsProviders'
import type { DNSProviderCredential } from '../../api/credentials'
vi.mock('../../hooks/useCredentials')
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
const mockProvider: DNSProvider = {
id: 1,
uuid: 'uuid-1',
name: 'Cloudflare Production',
provider_type: 'cloudflare',
enabled: true,
is_default: false,
has_credentials: true,
propagation_timeout: 120,
polling_interval: 5,
success_count: 10,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
const mockProviderTypeInfo: 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',
}
const mockCredentials: DNSProviderCredential[] = [
{
id: 1,
uuid: 'cred-uuid-1',
dns_provider_id: 1,
label: 'Main Zone',
zone_filter: 'example.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 5,
key_version: 1,
success_count: 15,
failure_count: 0,
last_used_at: '2025-01-03T10:00:00Z',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
uuid: 'cred-uuid-2',
dns_provider_id: 1,
label: 'Customer A',
zone_filter: '*.customer-a.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 5,
key_version: 1,
success_count: 3,
failure_count: 0,
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-02T00:00:00Z',
},
{
id: 3,
uuid: 'cred-uuid-3',
dns_provider_id: 1,
label: 'Staging',
zone_filter: '*.staging.example.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 5,
key_version: 1,
success_count: 2,
failure_count: 1,
last_error: 'DNS propagation timeout',
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-03T00:00:00Z',
},
]
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
describe('CredentialManager', () => {
const mockOnOpenChange = vi.fn()
const mockRefetch = vi.fn()
const mockMutateAsync = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCredentials).mockReturnValue({
data: mockCredentials,
isLoading: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useCredentials>)
vi.mocked(useCreateCredential).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useCreateCredential>)
vi.mocked(useUpdateCredential).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useUpdateCredential>)
vi.mocked(useDeleteCredential).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useDeleteCredential>)
vi.mocked(useTestCredential).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useTestCredential>)
})
describe('Rendering', () => {
it('renders modal with provider name in title', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText(/Cloudflare Production/)).toBeInTheDocument()
})
it('shows add credential button', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Check for button with specific text or by querying all buttons
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('renders credentials table with data', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('Main Zone')).toBeInTheDocument()
expect(screen.getByText('Customer A')).toBeInTheDocument()
expect(screen.getByText('Staging')).toBeInTheDocument()
})
it('displays zone filters correctly', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('example.com')).toBeInTheDocument()
expect(screen.getByText('*.customer-a.com')).toBeInTheDocument()
expect(screen.getByText('*.staging.example.com')).toBeInTheDocument()
})
it('shows status with success/failure counts', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('15/0')).toBeInTheDocument()
expect(screen.getByText('3/0')).toBeInTheDocument()
expect(screen.getByText('2/1')).toBeInTheDocument()
})
it('displays last error when present', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('DNS propagation timeout')).toBeInTheDocument()
})
})
describe('Empty State', () => {
it('shows empty state when no credentials', () => {
vi.mocked(useCredentials).mockReturnValue({
data: [],
isLoading: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useCredentials>)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Empty state should render (no table)
expect(screen.queryByRole('table')).not.toBeInTheDocument()
// But buttons should still exist (add button)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('empty state has add credential action', async () => {
const user = userEvent.setup()
vi.mocked(useCredentials).mockReturnValue({
data: [],
isLoading: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useCredentials>)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Empty state should have buttons
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
// Click first button (likely the add button)
await user.click(buttons[0])
// Form dialog should open
await waitFor(() => {
const dialogs = screen.getAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
})
})
})
describe('Loading State', () => {
it('shows loading indicator', () => {
vi.mocked(useCredentials).mockReturnValue({
data: undefined,
isLoading: true,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useCredentials>)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
})
describe('Table Actions', () => {
it('shows test, edit, and delete buttons for each credential', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Each row should have 3 action buttons (test, edit, delete)
const rows = screen.getAllByRole('row').slice(1) // Skip header
expect(rows).toHaveLength(3)
// Verify action buttons exist
expect(rows[0].querySelectorAll('button')).toHaveLength(3)
})
it('opens edit form when edit button clicked', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Find edit button in first row
const firstRow = screen.getAllByRole('row')[1]
const editButton = firstRow.querySelectorAll('button')[1]
// Verify edit button exists
expect(editButton).toBeInTheDocument()
await user.click(editButton)
// Form dialog should open (state change)
await waitFor(() => {
// Check that a form input appears
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBeGreaterThan(0)
})
})
})
describe('Delete Confirmation', () => {
it('opens delete confirmation flow', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Click delete button in first row
const firstRow = screen.getAllByRole('row')[1]
const deleteButton = firstRow.querySelectorAll('button')[2]
// Verify button exists and is clickable
expect(deleteButton).toBeInTheDocument()
await user.click(deleteButton)
// Confirmation flow initiated (state change verified)
expect(deleteButton).toBeInTheDocument()
})
})
describe('Test Credential', () => {
it('calls test mutation when test button clicked', async () => {
const user = userEvent.setup()
mockMutateAsync.mockResolvedValue({
success: true,
message: 'Test passed',
})
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Click test button in first row
const firstRow = screen.getAllByRole('row')[1]
const testButton = firstRow.querySelectorAll('button')[0]
await user.click(testButton)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
providerId: 1,
credentialId: expect.any(Number),
})
})
})
})
describe('Close Modal', () => {
it('calls onOpenChange when close button clicked', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Get the close button at the bottom of the modal
const closeButtons = screen.getAllByRole('button', { name: /close/i })
const closeButton = closeButtons[closeButtons.length - 1]
await user.click(closeButton)
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
describe('Accessibility', () => {
it('has proper dialog role', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('has accessible table structure', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByRole('table')).toBeInTheDocument()
expect(screen.getAllByRole('columnheader')).toHaveLength(4)
})
})
describe('Error Handling', () => {
it('shows error when credentials fail to load', async () => {
vi.mocked(useCredentials).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch'),
refetch: mockRefetch,
} as unknown as ReturnType<typeof useCredentials>)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Error state should render (no table, no loading text)
expect(screen.queryByRole('table')).not.toBeInTheDocument()
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
it('handles test mutation error gracefully', async () => {
const user = userEvent.setup()
mockMutateAsync.mockRejectedValue({
response: { data: { error: 'Invalid credentials' } },
})
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Click test button
const firstRow = screen.getAllByRole('row')[1]
const testButton = firstRow.querySelectorAll('button')[0]
await user.click(testButton)
// Should have called the mutation
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
})
describe('Edge Cases', () => {
it('handles wildcard zone filters', async () => {
const wildcard = mockCredentials.filter((c) => c.zone_filter.includes('*'))
expect(wildcard.length).toBeGreaterThan(0)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
wildcard.forEach((cred) => {
expect(screen.getByText(cred.zone_filter)).toBeInTheDocument()
})
})
it('handles credentials without last_used_at', () => {
const credWithoutLastUsed = mockCredentials.find((c) => !c.last_used_at)
expect(credWithoutLastUsed).toBeDefined()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Should render without error
expect(screen.getByText(credWithoutLastUsed!.label)).toBeInTheDocument()
})
})
})

View File

@@ -1,221 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DNSDetectionResult } from '../DNSDetectionResult'
import type { DetectionResult } from '../../api/dnsDetection'
import type { DNSProvider } from '../../api/dnsProviders'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'dns_detection.detecting': 'Detecting DNS provider...',
'dns_detection.detected': `${params?.provider} detected`,
'dns_detection.confidence_high': 'High confidence',
'dns_detection.confidence_medium': 'Medium confidence',
'dns_detection.confidence_low': 'Low confidence',
'dns_detection.confidence_none': 'No match',
'dns_detection.not_detected': 'Could not detect DNS provider',
'dns_detection.use_suggested': `Use ${params?.provider}`,
'dns_detection.select_manually': 'Select manually',
'dns_detection.nameservers': 'Nameservers',
'dns_detection.error': `Detection failed: ${params?.error}`,
}
return translations[key] || key
},
}),
}))
describe('DNSDetectionResult', () => {
const mockSuggestedProvider: DNSProvider = {
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',
}
it('should show loading state', () => {
render(
<DNSDetectionResult
result={{} as DetectionResult}
isLoading={true}
/>
)
expect(screen.getByText('Detecting DNS provider...')).toBeInTheDocument()
})
it('should show error message', () => {
const result: DetectionResult = {
domain: 'example.com',
detected: false,
nameservers: [],
confidence: 'none',
error: 'Network error',
}
render(<DNSDetectionResult result={result} />)
expect(screen.getByText(/Detection failed: Network error/)).toBeInTheDocument()
})
it('should show not detected message with nameservers', () => {
const result: DetectionResult = {
domain: 'example.com',
detected: false,
nameservers: ['ns1.unknown.com', 'ns2.unknown.com'],
confidence: 'none',
}
render(<DNSDetectionResult result={result} />)
expect(screen.getByText('Could not detect DNS provider')).toBeInTheDocument()
expect(screen.getByText(/nameservers/i)).toBeInTheDocument()
expect(screen.getByText('ns1.unknown.com')).toBeInTheDocument()
expect(screen.getByText('ns2.unknown.com')).toBeInTheDocument()
})
it('should show successful detection with high confidence', () => {
const result: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'cloudflare',
nameservers: ['ns1.cloudflare.com', 'ns2.cloudflare.com'],
confidence: 'high',
suggested_provider: mockSuggestedProvider,
}
render(<DNSDetectionResult result={result} />)
expect(screen.getByText('cloudflare detected')).toBeInTheDocument()
expect(screen.getByText('High confidence')).toBeInTheDocument()
expect(screen.getByText('Use Production Cloudflare')).toBeInTheDocument()
expect(screen.getByText('Select manually')).toBeInTheDocument()
})
it('should call onUseSuggested when "Use" button is clicked', async () => {
const user = userEvent.setup()
const onUseSuggested = vi.fn()
const result: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'cloudflare',
nameservers: ['ns1.cloudflare.com'],
confidence: 'high',
suggested_provider: mockSuggestedProvider,
}
render(
<DNSDetectionResult
result={result}
onUseSuggested={onUseSuggested}
/>
)
await user.click(screen.getByText('Use Production Cloudflare'))
expect(onUseSuggested).toHaveBeenCalledWith(mockSuggestedProvider)
})
it('should call onSelectManually when "Select manually" button is clicked', async () => {
const user = userEvent.setup()
const onSelectManually = vi.fn()
const result: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'cloudflare',
nameservers: ['ns1.cloudflare.com'],
confidence: 'high',
suggested_provider: mockSuggestedProvider,
}
render(
<DNSDetectionResult
result={result}
onSelectManually={onSelectManually}
/>
)
await user.click(screen.getByText('Select manually'))
expect(onSelectManually).toHaveBeenCalled()
})
it('should show medium confidence badge', () => {
const result: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'route53',
nameservers: ['ns-123.awsdns-12.com'],
confidence: 'medium',
}
render(<DNSDetectionResult result={result} />)
expect(screen.getByText('Medium confidence')).toBeInTheDocument()
})
it('should show low confidence badge', () => {
const result: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'digitalocean',
nameservers: ['ns1.digitalocean.com'],
confidence: 'low',
}
render(<DNSDetectionResult result={result} />)
expect(screen.getByText('Low confidence')).toBeInTheDocument()
})
it('should show expandable nameservers list', async () => {
const user = userEvent.setup()
const result: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'cloudflare',
nameservers: ['ns1.cloudflare.com', 'ns2.cloudflare.com', 'ns3.cloudflare.com'],
confidence: 'high',
suggested_provider: mockSuggestedProvider,
}
render(<DNSDetectionResult result={result} />)
// Nameservers are in a details element
const summary = screen.getByText(/Nameservers \(3\)/)
await user.click(summary)
expect(screen.getByText('ns1.cloudflare.com')).toBeInTheDocument()
expect(screen.getByText('ns2.cloudflare.com')).toBeInTheDocument()
expect(screen.getByText('ns3.cloudflare.com')).toBeInTheDocument()
})
it('should not show action buttons when no suggested provider', () => {
const result: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'cloudflare',
nameservers: ['ns1.cloudflare.com'],
confidence: 'high',
}
render(<DNSDetectionResult result={result} />)
expect(screen.queryByText(/Use/)).not.toBeInTheDocument()
expect(screen.queryByText('Select manually')).not.toBeInTheDocument()
})
})

View File

@@ -1,501 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import DNSProviderSelector from '../DNSProviderSelector'
import { useDNSProviders } from '../../hooks/useDNSProviders'
import type { DNSProvider } from '../../api/dnsProviders'
vi.mock('../../hooks/useDNSProviders')
// Capture the onValueChange callback from Select component
let capturedOnValueChange: ((value: string) => void) | undefined
let capturedSelectDisabled: boolean | undefined
let capturedSelectValue: string | undefined
// Mock the Select component to capture onValueChange and enable testing
vi.mock('../ui', async () => {
const actual = await vi.importActual('../ui')
return {
...actual,
Select: ({ value, onValueChange, disabled, children }: {
value: string
onValueChange: (value: string) => void
disabled?: boolean
children: React.ReactNode
}) => {
capturedOnValueChange = onValueChange
capturedSelectDisabled = disabled
capturedSelectValue = value
return (
<div data-testid="select-mock" data-value={value} data-disabled={disabled}>
{children}
</div>
)
},
SelectTrigger: ({ error, children }: { error?: boolean; children: React.ReactNode }) => (
<button
role="combobox"
data-error={error}
disabled={capturedSelectDisabled}
aria-disabled={capturedSelectDisabled}
>
{children}
</button>
),
SelectValue: ({ placeholder }: { placeholder?: string }) => {
// Display actual selected value based on capturedSelectValue
return <span data-placeholder={placeholder}>{capturedSelectValue || placeholder}</span>
},
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div role="listbox">{children}</div>
),
SelectItem: ({ value, disabled, children }: { value: string; disabled?: boolean; children: React.ReactNode }) => (
<div role="option" data-value={value} data-disabled={disabled}>
{children}
</div>
),
}
})
const mockProviders: DNSProvider[] = [
{
id: 1,
uuid: 'uuid-1',
name: 'Cloudflare Prod',
provider_type: 'cloudflare',
enabled: true,
is_default: true,
has_credentials: true,
propagation_timeout: 120,
polling_interval: 2,
success_count: 10,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
uuid: 'uuid-2',
name: 'Route53 Staging',
provider_type: 'route53',
enabled: true,
is_default: false,
has_credentials: true,
propagation_timeout: 60,
polling_interval: 2,
success_count: 5,
failure_count: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
uuid: 'uuid-3',
name: 'Disabled Provider',
provider_type: 'digitalocean',
enabled: false,
is_default: false,
has_credentials: true,
propagation_timeout: 90,
polling_interval: 2,
success_count: 0,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 4,
uuid: 'uuid-4',
name: 'No Credentials',
provider_type: 'googleclouddns',
enabled: true,
is_default: false,
has_credentials: false,
propagation_timeout: 120,
polling_interval: 2,
success_count: 0,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
]
const renderWithClient = (ui: React.ReactElement) => {
return render(<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>)
}
describe('DNSProviderSelector', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
capturedOnValueChange = undefined
capturedSelectDisabled = undefined
capturedSelectValue = undefined
vi.mocked(useDNSProviders).mockReturnValue({
data: mockProviders,
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useDNSProviders>)
})
describe('Rendering', () => {
it('renders with label when provided', () => {
renderWithClient(
<DNSProviderSelector value={undefined} onChange={mockOnChange} label="DNS Provider" />
)
expect(screen.getByText('DNS Provider')).toBeInTheDocument()
})
it('renders without label when not provided', () => {
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
expect(screen.queryByRole('label')).not.toBeInTheDocument()
})
it('shows required asterisk when required=true', () => {
renderWithClient(
<DNSProviderSelector
value={undefined}
onChange={mockOnChange}
label="DNS Provider"
required
/>
)
const label = screen.getByText('DNS Provider')
expect(label.parentElement?.textContent).toContain('*')
})
it('shows helper text when provided', () => {
renderWithClient(
<DNSProviderSelector
value={undefined}
onChange={mockOnChange}
helperText="Select a DNS provider for wildcard certificates"
/>
)
expect(
screen.getByText('Select a DNS provider for wildcard certificates')
).toBeInTheDocument()
})
it('shows error message when provided and replaces helper text', () => {
renderWithClient(
<DNSProviderSelector
value={undefined}
onChange={mockOnChange}
helperText="This should not appear"
error="DNS provider is required"
/>
)
expect(screen.getByText('DNS provider is required')).toBeInTheDocument()
expect(screen.queryByText('This should not appear')).not.toBeInTheDocument()
})
})
describe('Provider Filtering', () => {
it('only shows enabled providers', () => {
renderWithClient(
<DNSProviderSelector value={undefined} onChange={mockOnChange} />
)
// Component filters providers internally, verify filtering logic
// by checking that only enabled providers with credentials are available
const providers = mockProviders.filter((p) => p.enabled && p.has_credentials)
expect(providers).toHaveLength(2)
expect(providers[0].name).toBe('Cloudflare Prod')
expect(providers[1].name).toBe('Route53 Staging')
})
it('only shows providers with credentials', () => {
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
// Verify filtering logic: providers must have both enabled=true and has_credentials=true
const availableProviders = mockProviders.filter((p) => p.enabled && p.has_credentials)
expect(availableProviders.every((p) => p.has_credentials)).toBe(true)
})
it('filters out disabled providers', () => {
const disabledProvider: DNSProvider = {
...mockProviders[0],
id: 5,
enabled: false,
name: 'Another Disabled',
}
vi.mocked(useDNSProviders).mockReturnValue({
data: [...mockProviders, disabledProvider],
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useDNSProviders>)
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
// Verify the disabled provider is filtered out
const allProviders = [...mockProviders, disabledProvider]
const availableProviders = allProviders.filter((p) => p.enabled && p.has_credentials)
expect(availableProviders.find((p) => p.name === 'Another Disabled')).toBeUndefined()
})
it('filters out providers without credentials', () => {
const noCredProvider: DNSProvider = {
...mockProviders[0],
id: 6,
has_credentials: false,
name: 'Missing Creds',
}
vi.mocked(useDNSProviders).mockReturnValue({
data: [...mockProviders, noCredProvider],
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useDNSProviders>)
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
// Verify the provider without credentials is filtered out
const allProviders = [...mockProviders, noCredProvider]
const availableProviders = allProviders.filter((p) => p.enabled && p.has_credentials)
expect(availableProviders.find((p) => p.name === 'Missing Creds')).toBeUndefined()
})
})
describe('Loading States', () => {
it('shows loading state while fetching', () => {
vi.mocked(useDNSProviders).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as unknown as ReturnType<typeof useDNSProviders>)
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
// When loading, data is undefined and isLoading is true
expect(screen.getByRole('combobox')).toBeDisabled()
})
it('disables select during loading', () => {
vi.mocked(useDNSProviders).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as unknown as ReturnType<typeof useDNSProviders>)
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
expect(screen.getByRole('combobox')).toBeDisabled()
})
})
describe('Empty States', () => {
it('handles empty provider list', () => {
vi.mocked(useDNSProviders).mockReturnValue({
data: [],
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useDNSProviders>)
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
// Verify selector renders even with empty list
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('handles all providers filtered out scenario', () => {
const allDisabled = mockProviders.map((p) => ({ ...p, enabled: false }))
vi.mocked(useDNSProviders).mockReturnValue({
data: allDisabled,
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useDNSProviders>)
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
// Verify selector renders with no available providers
const availableProviders = allDisabled.filter((p) => p.enabled && p.has_credentials)
expect(availableProviders).toHaveLength(0)
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
})
describe('Selection Behavior', () => {
it('displays selected provider by ID', () => {
renderWithClient(<DNSProviderSelector value={1} onChange={mockOnChange} />)
// Verify the Select received the correct value
expect(capturedSelectValue).toBe('1')
})
it('shows none placeholder when value is undefined and not required', () => {
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
// When value is undefined, the component uses 'none' as the Select value
expect(capturedSelectValue).toBe('none')
})
it('handles required prop correctly', () => {
renderWithClient(
<DNSProviderSelector value={undefined} onChange={mockOnChange} required />
)
// When required, component should not include "none" in value
const combobox = screen.getByRole('combobox')
expect(combobox).toBeInTheDocument()
})
it('stores provider ID in component state', () => {
const { rerender } = renderWithClient(
<DNSProviderSelector value={1} onChange={mockOnChange} />
)
expect(capturedSelectValue).toBe('1')
// Change to different provider
rerender(
<QueryClientProvider client={new QueryClient()}>
<DNSProviderSelector value={2} onChange={mockOnChange} />
</QueryClientProvider>
)
expect(capturedSelectValue).toBe('2')
})
it('handles undefined selection', () => {
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
// When undefined, the value should be 'none'
expect(capturedSelectValue).toBe('none')
})
})
describe('Provider Display', () => {
it('renders provider names correctly', () => {
renderWithClient(<DNSProviderSelector value={1} onChange={mockOnChange} />)
// Verify selected provider value is passed to Select
expect(capturedSelectValue).toBe('1')
// Provider names are rendered in SelectItems
expect(screen.getByText('Cloudflare Prod')).toBeInTheDocument()
})
it('identifies default provider', () => {
const defaultProvider = mockProviders.find((p) => p.is_default)
expect(defaultProvider?.is_default).toBe(true)
expect(defaultProvider?.name).toBe('Cloudflare Prod')
})
it('includes provider type information', () => {
// Verify mock data includes provider types
expect(mockProviders[0].provider_type).toBe('cloudflare')
expect(mockProviders[1].provider_type).toBe('route53')
})
it('uses translation keys for provider types', () => {
renderWithClient(<DNSProviderSelector value={1} onChange={mockOnChange} />)
// The component uses t(`dnsProviders.types.${provider.provider_type}`)
// Our mock translation returns the key if not found
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
})
describe('Disabled State', () => {
it('disables select when disabled=true', () => {
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} disabled />)
expect(screen.getByRole('combobox')).toBeDisabled()
})
it('disables select during loading', () => {
vi.mocked(useDNSProviders).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as unknown as ReturnType<typeof useDNSProviders>)
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
expect(screen.getByRole('combobox')).toBeDisabled()
})
})
describe('Accessibility', () => {
it('error has role="alert"', () => {
renderWithClient(
<DNSProviderSelector
value={undefined}
onChange={mockOnChange}
error="Required field"
/>
)
const errorElement = screen.getByText('Required field')
expect(errorElement).toHaveAttribute('role', 'alert')
})
it('label properly associates with select', () => {
renderWithClient(
<DNSProviderSelector
value={undefined}
onChange={mockOnChange}
label="Choose Provider"
/>
)
const label = screen.getByText('Choose Provider')
const select = screen.getByRole('combobox')
// They should be associated (exact implementation may vary)
expect(label).toBeInTheDocument()
expect(select).toBeInTheDocument()
})
})
describe('Value Change Handling', () => {
it('calls onChange with undefined when "none" is selected', () => {
renderWithClient(
<DNSProviderSelector value={1} onChange={mockOnChange} />
)
// Invoke the captured onValueChange with 'none'
expect(capturedOnValueChange).toBeDefined()
capturedOnValueChange!('none')
expect(mockOnChange).toHaveBeenCalledWith(undefined)
})
it('calls onChange with provider ID when a provider is selected', () => {
renderWithClient(
<DNSProviderSelector value={undefined} onChange={mockOnChange} />
)
// Invoke the captured onValueChange with provider id '1'
expect(capturedOnValueChange).toBeDefined()
capturedOnValueChange!('1')
expect(mockOnChange).toHaveBeenCalledWith(1)
})
it('calls onChange with different provider ID when switching providers', () => {
renderWithClient(
<DNSProviderSelector value={1} onChange={mockOnChange} />
)
// Invoke the captured onValueChange with provider id '2'
expect(capturedOnValueChange).toBeDefined()
capturedOnValueChange!('2')
expect(mockOnChange).toHaveBeenCalledWith(2)
})
})
})

View File

@@ -1,262 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ImportReviewTable from '../ImportReviewTable'
import { mockImportPreview } from '../../test/mockData'
describe('ImportReviewTable', () => {
const mockOnCommit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('displays hosts to import', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
conflictDetails={{}}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Review Imported Hosts')).toBeInTheDocument()
expect(screen.getByText('test.example.com')).toBeInTheDocument()
})
it('displays conflicts with resolution dropdowns', () => {
const conflicts = ['test.example.com']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={{}}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('test.example.com')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('displays errors', () => {
const errors = ['Invalid Caddyfile syntax', 'Missing required field']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
conflictDetails={{}}
errors={errors}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Issues found during parsing')).toBeInTheDocument()
expect(screen.getByText('Invalid Caddyfile syntax')).toBeInTheDocument()
expect(screen.getByText('Missing required field')).toBeInTheDocument()
})
it('calls onCommit with resolutions and names', async () => {
const conflicts = ['test.example.com']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={{}}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const dropdown = screen.getByRole('combobox') as HTMLSelectElement
await userEvent.selectOptions(dropdown, 'overwrite')
const commitButton = screen.getByText('Commit Import')
await userEvent.click(commitButton)
await waitFor(() => {
expect(mockOnCommit).toHaveBeenCalledWith(
{ 'test.example.com': 'overwrite' },
{ 'test.example.com': 'test.example.com' }
)
})
})
it('calls onCancel when cancel button is clicked', async () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
conflictDetails={{}}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
await userEvent.click(screen.getByText('Back'))
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('shows conflict indicator on conflicting hosts', () => {
const conflicts = ['test.example.com']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={{}}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByRole('combobox')).toBeInTheDocument()
expect(screen.queryByText('No conflict')).not.toBeInTheDocument()
})
it('expands and collapses conflict details', async () => {
const conflicts = ['test.example.com']
const conflictDetails = {
'test.example.com': {
existing: {
forward_scheme: 'http',
forward_host: '192.168.1.1',
forward_port: 8080,
ssl_forced: true,
websocket: true,
enabled: true,
},
imported: {
forward_scheme: 'http',
forward_host: '192.168.1.2',
forward_port: 9090,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
// Initially collapsed
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
// Find and click expand button (it's the ▶ button)
const expandButton = screen.getByText('▶')
await userEvent.click(expandButton)
// Now should show details
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
expect(screen.getByText('Imported Configuration')).toBeInTheDocument()
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
expect(screen.getByText('http://192.168.1.2:9090')).toBeInTheDocument()
// Click collapse button
const collapseButton = screen.getByText('▼')
await userEvent.click(collapseButton)
// Details should be hidden again
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
})
it('shows recommendation based on configuration differences', async () => {
const conflicts = ['test.example.com']
const conflictDetails = {
'test.example.com': {
existing: {
forward_scheme: 'http',
forward_host: '192.168.1.1',
forward_port: 8080,
ssl_forced: true,
websocket: false,
enabled: true,
},
imported: {
forward_scheme: 'http',
forward_host: '192.168.1.1',
forward_port: 8080,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
// Expand to see recommendation
const expandButton = screen.getByText('▶')
await userEvent.click(expandButton)
// Should show recommendation about config changes (SSL differs)
expect(screen.getByText(/different SSL or WebSocket settings/i)).toBeInTheDocument()
})
it('highlights configuration differences', async () => {
const conflicts = ['test.example.com']
const conflictDetails = {
'test.example.com': {
existing: {
forward_scheme: 'http',
forward_host: '192.168.1.1',
forward_port: 8080,
ssl_forced: true,
websocket: true,
enabled: true,
},
imported: {
forward_scheme: 'https',
forward_host: '192.168.1.2',
forward_port: 9090,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const expandButton = screen.getByText('▶')
await userEvent.click(expandButton)
// Check for differences being displayed
expect(screen.getByText('https://192.168.1.2:9090')).toBeInTheDocument()
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
})
})

View File

@@ -1,60 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { LanguageSelector } from '../LanguageSelector'
import { LanguageProvider } from '../../context/LanguageContext'
// Mock i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: vi.fn(),
language: 'en',
},
}),
}))
describe('LanguageSelector', () => {
const renderWithProvider = () => {
return render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>
)
}
it('renders language selector with all options', () => {
renderWithProvider()
const select = screen.getByRole('combobox')
expect(select).toBeInTheDocument()
// Check that all language options are available
const options = screen.getAllByRole('option')
expect(options).toHaveLength(5)
expect(options[0]).toHaveTextContent('English')
expect(options[1]).toHaveTextContent('Español')
expect(options[2]).toHaveTextContent('Français')
expect(options[3]).toHaveTextContent('Deutsch')
expect(options[4]).toHaveTextContent('中文')
})
it('displays globe icon', () => {
const { container } = renderWithProvider()
const svgElement = container.querySelector('svg')
expect(svgElement).toBeInTheDocument()
})
it('changes language when option is selected', () => {
renderWithProvider()
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('en')
fireEvent.change(select, { target: { value: 'es' } })
expect(select.value).toBe('es')
fireEvent.change(select, { target: { value: 'fr' } })
expect(select.value).toBe('fr')
})
})

View File

@@ -1,320 +0,0 @@
import { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Layout from '../Layout'
import { ThemeProvider } from '../../context/ThemeContext'
import * as featureFlagsApi from '../../api/featureFlags'
const mockLogout = vi.fn()
// Mock AuthContext
vi.mock('../../hooks/useAuth', () => ({
useAuth: () => ({
logout: mockLogout,
}),
}))
// Mock API
vi.mock('../../api/health', () => ({
checkHealth: vi.fn().mockResolvedValue({
version: '0.1.0',
git_commit: 'abcdef1',
}),
}))
vi.mock('../../api/featureFlags', () => ({
getFeatureFlags: vi.fn().mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': true,
}),
}))
const renderWithProviders = (children: ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeProvider>
{children}
</ThemeProvider>
</BrowserRouter>
</QueryClientProvider>
)
}
describe('Layout', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
localStorage.setItem('sidebarCollapsed', 'false')
// Default: all features enabled
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': true,
})
})
it('renders the application logo', () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
const logos = screen.getAllByAltText('Charon')
expect(logos.length).toBeGreaterThan(0)
expect(logos[0]).toBeInTheDocument()
})
it('renders all navigation items', async () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
expect(screen.getByText('Certificates')).toBeInTheDocument()
// Expand Tasks and Import to see nested items
await userEvent.click(screen.getByText('Tasks'))
expect(screen.getByText('Import')).toBeInTheDocument()
await userEvent.click(screen.getByText('Import'))
expect(screen.getByText('Caddyfile')).toBeInTheDocument()
expect(screen.getByText('CrowdSec')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
})
it('renders children content', () => {
renderWithProviders(
<Layout>
<div data-testid="test-content">Test Content</div>
</Layout>
)
expect(screen.getByTestId('test-content')).toBeInTheDocument()
})
it('displays version information', async () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
expect(await screen.findByText('Version 0.1.0')).toBeInTheDocument()
})
it('calls logout when logout button is clicked', async () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
await userEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalled()
})
it('toggles sidebar on mobile', async () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
// The mobile sidebar toggle is found by test-id
const toggleButton = screen.getByTestId('mobile-menu-toggle')
// Click to open the sidebar
await userEvent.click(toggleButton)
// The overlay should be present when mobile sidebar is open
// The overlay has class 'fixed inset-0 bg-gray-900/50 z-20 lg:hidden'
// Click the toggle again to close
await userEvent.click(toggleButton)
// Toggle button should still be in the document
expect(toggleButton).toBeInTheDocument()
})
it('persists collapse state to localStorage', async () => {
localStorage.clear()
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
const collapseBtn = await screen.findByTitle('Collapse sidebar')
await userEvent.click(collapseBtn)
expect(JSON.parse(localStorage.getItem('sidebarCollapsed') || 'false')).toBe(true)
})
it('restores collapsed state from localStorage on load', async () => {
localStorage.setItem('sidebarCollapsed', 'true')
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
expect(await screen.findByTitle('Expand sidebar')).toBeInTheDocument()
})
describe('Feature Flags - Conditional Sidebar Items', () => {
it('displays Security nav item when Cerberus is enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': true,
})
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument()
})
})
it('hides Security nav item when Cerberus is disabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': true,
})
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
await waitFor(() => {
expect(screen.queryByText('Security')).not.toBeInTheDocument()
})
})
it('displays Uptime nav item when Uptime is enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': true,
})
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
await waitFor(() => {
expect(screen.getByText('Uptime')).toBeInTheDocument()
})
})
it('hides Uptime nav item when Uptime is disabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': false,
})
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
await waitFor(() => {
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
})
})
it('shows Security and Uptime when both features are enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': true,
})
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
})
})
it('hides both Security and Uptime when both features are disabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': false,
})
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
await waitFor(() => {
expect(screen.queryByText('Security')).not.toBeInTheDocument()
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
})
})
it('defaults to showing Security and Uptime when feature flags are loading', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({} as any)
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
// When flags are undefined, items should be visible by default (conservative approach)
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
})
})
it('shows other nav items regardless of feature flags', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': false,
})
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
await waitFor(() => {
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
expect(screen.getByText('Certificates')).toBeInTheDocument()
})
})
})
})

View File

@@ -1,661 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LiveLogViewer } from '../LiveLogViewer';
import * as logsApi from '../../api/logs';
// Mock the connectLiveLogs and connectSecurityLogs functions
vi.mock('../../api/logs', async () => {
const actual = await vi.importActual('../../api/logs');
return {
...actual,
connectLiveLogs: vi.fn(),
connectSecurityLogs: vi.fn(),
};
});
describe('LiveLogViewer', () => {
let mockCloseConnection: ReturnType<typeof vi.fn>;
let mockOnMessage: ((log: logsApi.LiveLogEntry) => void) | null;
let mockOnSecurityMessage: ((log: logsApi.SecurityLogEntry) => void) | null;
let mockOnClose: (() => void) | null;
beforeEach(() => {
mockCloseConnection = vi.fn();
mockOnMessage = null;
mockOnSecurityMessage = null;
mockOnClose = null;
vi.mocked(logsApi.connectLiveLogs).mockImplementation((_filters, onMessage, onOpen, _onError, onClose) => {
mockOnMessage = onMessage;
mockOnClose = onClose ?? null;
// Simulate connection success
if (onOpen) {
setTimeout(() => onOpen(), 0);
}
return mockCloseConnection as () => void;
});
vi.mocked(logsApi.connectSecurityLogs).mockImplementation((_filters, onMessage, onOpen, _onError, onClose) => {
mockOnSecurityMessage = onMessage;
mockOnClose = onClose ?? null;
// Simulate connection success
if (onOpen) {
setTimeout(() => onOpen(), 0);
}
return mockCloseConnection as () => void;
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders the component with initial state', async () => {
render(<LiveLogViewer />);
// Default mode is now 'security'
expect(screen.getByText('Security Access Logs')).toBeTruthy();
// Initially disconnected until WebSocket opens
expect(screen.getByText('Disconnected')).toBeTruthy();
// Wait for onOpen callback to be called
await waitFor(() => {
expect(screen.getByText('Connected')).toBeTruthy();
});
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
});
it('displays incoming log messages', async () => {
// Explicitly use application mode for this test
render(<LiveLogViewer mode="application" />);
// Simulate receiving a log
const logEntry: logsApi.LiveLogEntry = {
level: 'info',
timestamp: '2025-12-09T10:30:00Z',
message: 'Test log message',
source: 'test',
};
if (mockOnMessage) {
mockOnMessage(logEntry);
}
await waitFor(() => {
expect(screen.getByText('Test log message')).toBeTruthy();
expect(screen.getByText('INFO')).toBeTruthy();
expect(screen.getByText('[test]')).toBeTruthy();
});
});
it('filters logs by text', async () => {
const user = userEvent.setup();
// Explicitly use application mode for this test
render(<LiveLogViewer mode="application" />);
// Add multiple logs
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'First message' });
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Second message' });
}
await waitFor(() => {
expect(screen.getByText('First message')).toBeTruthy();
expect(screen.getByText('Second message')).toBeTruthy();
});
// Apply text filter
const filterInput = screen.getByPlaceholderText('Filter by text...');
await user.type(filterInput, 'First');
await waitFor(() => {
expect(screen.getByText('First message')).toBeTruthy();
expect(screen.queryByText('Second message')).toBeFalsy();
});
});
it('filters logs by level', async () => {
const user = userEvent.setup();
// Explicitly use application mode for this test
render(<LiveLogViewer mode="application" />);
// Add multiple logs
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Info message' });
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Error message' });
}
await waitFor(() => {
expect(screen.getByText('Info message')).toBeTruthy();
expect(screen.getByText('Error message')).toBeTruthy();
});
// Apply level filter
const levelSelect = screen.getAllByRole('combobox')[0];
await user.selectOptions(levelSelect, 'error');
await waitFor(() => {
expect(screen.queryByText('Info message')).toBeFalsy();
expect(screen.getByText('Error message')).toBeTruthy();
});
});
it('pauses and resumes log streaming', async () => {
const user = userEvent.setup();
// Explicitly use application mode for this test
render(<LiveLogViewer mode="application" />);
// Add initial log
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Before pause' });
}
await waitFor(() => {
expect(screen.getByText('Before pause')).toBeTruthy();
});
// Click pause button
const pauseButton = screen.getByTitle('Pause');
await user.click(pauseButton);
// Verify paused state
await waitFor(() => {
expect(screen.getByText('⏸ Paused')).toBeTruthy();
});
// Try to add log while paused (should not appear)
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'During pause' });
}
// Log should not appear
expect(screen.queryByText('During pause')).toBeFalsy();
// Resume
const resumeButton = screen.getByTitle('Resume');
await user.click(resumeButton);
// Add log after resume
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'After resume' });
}
await waitFor(() => {
expect(screen.getByText('After resume')).toBeTruthy();
});
});
it('clears all logs', async () => {
const user = userEvent.setup();
// Explicitly use application mode for this test
render(<LiveLogViewer mode="application" />);
// Add logs
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
}
await waitFor(() => {
expect(screen.getByText('Log 1')).toBeTruthy();
expect(screen.getByText('Log 2')).toBeTruthy();
});
// Click clear button
const clearButton = screen.getByTitle('Clear logs');
await user.click(clearButton);
await waitFor(() => {
expect(screen.queryByText('Log 1')).toBeFalsy();
expect(screen.queryByText('Log 2')).toBeFalsy();
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
});
});
it('limits the number of stored logs', async () => {
// Explicitly use application mode for this test
render(<LiveLogViewer maxLogs={2} mode="application" />);
// Add 3 logs (exceeding maxLogs)
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'Log 3' });
}
await waitFor(() => {
// First log should be removed, only last 2 should remain
expect(screen.queryByText('Log 1')).toBeFalsy();
expect(screen.getByText('Log 2')).toBeTruthy();
expect(screen.getByText('Log 3')).toBeTruthy();
});
});
it('displays log data when available', async () => {
// Explicitly use application mode for this test
render(<LiveLogViewer mode="application" />);
const logWithData: logsApi.LiveLogEntry = {
level: 'error',
timestamp: '2025-12-09T10:30:00Z',
message: 'Error occurred',
data: { error_code: 500, details: 'Internal server error' },
};
if (mockOnMessage) {
mockOnMessage(logWithData);
}
await waitFor(() => {
expect(screen.getByText('Error occurred')).toBeTruthy();
// Check that data is rendered as JSON
expect(screen.getByText(/"error_code"/)).toBeTruthy();
});
});
it('closes WebSocket connection on unmount', () => {
const { unmount } = render(<LiveLogViewer />);
// Default mode is security
expect(logsApi.connectSecurityLogs).toHaveBeenCalled();
unmount();
expect(mockCloseConnection).toHaveBeenCalled();
});
it('applies custom className', () => {
const { container } = render(<LiveLogViewer className="custom-class" />);
const element = container.querySelector('.custom-class');
expect(element).toBeTruthy();
});
it('shows correct connection status', async () => {
let mockOnOpen: (() => void) | undefined;
let mockOnError: ((error: Event) => void) | undefined;
// Use security logs mock since default mode is security
vi.mocked(logsApi.connectSecurityLogs).mockImplementation((_filters, _onMessage, onOpen, onError) => {
mockOnOpen = onOpen;
mockOnError = onError;
return mockCloseConnection as () => void;
});
render(<LiveLogViewer />);
// Initially disconnected until onOpen is called
expect(screen.getByText('Disconnected')).toBeTruthy();
// Simulate connection opened
if (mockOnOpen) {
mockOnOpen();
}
await waitFor(() => {
expect(screen.getByText('Connected')).toBeTruthy();
});
// Simulate connection error
if (mockOnError) {
mockOnError(new Event('error'));
}
await waitFor(() => {
expect(screen.getByText('Disconnected')).toBeTruthy();
// Should show error message
expect(screen.getByText('Failed to connect to log stream. Check your authentication or try refreshing.')).toBeTruthy();
});
});
it('shows no-match message when filters exclude all logs', async () => {
const user = userEvent.setup();
// Explicitly use application mode for this test
render(<LiveLogViewer mode="application" />);
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Visible' });
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Hidden' });
}
await waitFor(() => expect(screen.getByText('Visible')).toBeTruthy());
await user.type(screen.getByPlaceholderText('Filter by text...'), 'nomatch');
await waitFor(() => {
expect(screen.getByText('No logs match the current filters.')).toBeTruthy();
});
});
it('marks connection as disconnected when WebSocket closes', async () => {
render(<LiveLogViewer />);
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
act(() => {
mockOnClose?.();
});
await waitFor(() => expect(screen.getByText('Disconnected')).toBeTruthy());
});
// ============================================================
// Security Mode Tests
// ============================================================
describe('Security Mode', () => {
it('renders in security mode when mode="security"', async () => {
render(<LiveLogViewer mode="security" />);
expect(screen.getByText('Security Access Logs')).toBeTruthy();
expect(logsApi.connectSecurityLogs).toHaveBeenCalled();
});
it('displays security log entries with source badges', async () => {
render(<LiveLogViewer mode="security" />);
// Wait for connection to establish
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
const securityLog: logsApi.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,
};
if (mockOnSecurityMessage) {
mockOnSecurityMessage(securityLog);
}
await waitFor(() => {
expect(screen.getByText('NORMAL')).toBeTruthy();
expect(screen.getByText('192.168.1.100')).toBeTruthy();
expect(screen.getByText(/GET \/api\/test → 200/)).toBeTruthy();
});
});
it('displays blocked requests with special styling', async () => {
render(<LiveLogViewer mode="security" />);
// Wait for connection to establish
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
const blockedLog: logsApi.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',
};
// Send message inside act to properly handle state updates
await act(async () => {
if (mockOnSecurityMessage) {
mockOnSecurityMessage(blockedLog);
}
});
// Use findBy queries (built-in waiting) instead of single waitFor with multiple assertions
// This avoids race conditions where one failing assertion causes the entire block to retry
await screen.findByText('10.0.0.1');
await screen.findByText(/🚫 BLOCKED: SQL injection detected/);
await screen.findByText(/\[SQL injection detected\]/);
// For getAllByText, keep in waitFor but separate from other assertions
await waitFor(() => {
// Use getAllByText since 'WAF' appears both in dropdown option and source badge
const wafElements = screen.getAllByText('WAF');
expect(wafElements.length).toBeGreaterThanOrEqual(2); // Option + badge
});
}, 15000); // 15 second timeout as safeguard
it('shows source filter dropdown in security mode', async () => {
render(<LiveLogViewer mode="security" />);
// Should have source filter options
expect(screen.getByText('All Sources')).toBeTruthy();
expect(screen.getByRole('option', { name: 'WAF' })).toBeTruthy();
expect(screen.getByRole('option', { name: 'CrowdSec' })).toBeTruthy();
expect(screen.getByRole('option', { name: 'Rate Limit' })).toBeTruthy();
expect(screen.getByRole('option', { name: 'ACL' })).toBeTruthy();
});
it('filters by source in security mode', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="security" />);
// Wait for connection
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
// Add logs from different sources
if (mockOnSecurityMessage) {
mockOnSecurityMessage({
timestamp: '2025-12-12T10:30:00Z',
level: 'info',
logger: 'http.log.access',
client_ip: '192.168.1.1',
method: 'GET',
uri: '/normal-request',
status: 200,
duration: 0.01,
size: 100,
user_agent: 'Test/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
});
mockOnSecurityMessage({
timestamp: '2025-12-12T10:30:01Z',
level: 'warn',
logger: 'http.handlers.waf',
client_ip: '10.0.0.1',
method: 'POST',
uri: '/waf-blocked',
status: 403,
duration: 0.001,
size: 0,
user_agent: 'Attack/1.0',
host: 'example.com',
source: 'waf',
blocked: true,
block_reason: 'WAF block',
});
}
// Wait for logs to appear - normal shows URI, blocked shows block message
await waitFor(() => {
expect(screen.getByText(/GET \/normal-request/)).toBeTruthy();
expect(screen.getByText(/BLOCKED: WAF block/)).toBeTruthy();
});
// Filter by WAF using the source dropdown (second combobox after level)
const sourceSelects = screen.getAllByRole('combobox');
const sourceFilterSelect = sourceSelects[1]; // Second combobox is source filter
await user.selectOptions(sourceFilterSelect, 'waf');
await waitFor(() => {
expect(screen.queryByText(/GET \/normal-request/)).toBeFalsy();
expect(screen.getByText(/BLOCKED: WAF block/)).toBeTruthy();
});
});
it('shows blocked only checkbox in security mode', async () => {
render(<LiveLogViewer mode="security" />);
expect(screen.getByText('Blocked only')).toBeTruthy();
expect(screen.getByRole('checkbox')).toBeTruthy();
});
it('toggles blocked only filter', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="security" />);
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
// Verify checkbox is checked
expect(checkbox).toBeChecked();
});
it('displays duration for security logs', async () => {
render(<LiveLogViewer mode="security" />);
// Wait for connection to establish
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
const securityLog: logsApi.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.123,
size: 1024,
user_agent: 'TestAgent/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
};
if (mockOnSecurityMessage) {
mockOnSecurityMessage(securityLog);
}
await waitFor(() => {
expect(screen.getByText('123.0ms')).toBeTruthy();
});
});
it('displays status code with appropriate color for security logs', async () => {
render(<LiveLogViewer mode="security" />);
// Wait for connection to establish
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
if (mockOnSecurityMessage) {
mockOnSecurityMessage({
timestamp: '2025-12-12T10:30:00Z',
level: 'info',
logger: 'http.log.access',
client_ip: '192.168.1.100',
method: 'GET',
uri: '/ok',
status: 200,
duration: 0.01,
size: 100,
user_agent: 'Test/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
});
}
await waitFor(() => {
expect(screen.getByText('[200]')).toBeTruthy();
});
});
});
// ============================================================
// Mode Toggle Tests
// ============================================================
describe('Mode Toggle', () => {
it('switches from application to security mode', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="application" />);
expect(screen.getByText('Live Security Logs')).toBeTruthy();
expect(logsApi.connectLiveLogs).toHaveBeenCalled();
// Click security mode button
const securityButton = screen.getByTitle('Security access logs');
await user.click(securityButton);
await waitFor(() => {
expect(screen.getByText('Security Access Logs')).toBeTruthy();
expect(logsApi.connectSecurityLogs).toHaveBeenCalled();
});
});
it('switches from security to application mode', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="security" />);
expect(screen.getByText('Security Access Logs')).toBeTruthy();
// Click application mode button
const appButton = screen.getByTitle('Application logs');
await user.click(appButton);
await waitFor(() => {
expect(screen.getByText('Live Security Logs')).toBeTruthy();
});
});
it('clears logs when switching modes', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="application" />);
// Add a log in application mode
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-12T10:30:00Z', message: 'App log' });
}
await waitFor(() => {
expect(screen.getByText('App log')).toBeTruthy();
});
// Switch to security mode
const securityButton = screen.getByTitle('Security access logs');
await user.click(securityButton);
await waitFor(() => {
expect(screen.queryByText('App log')).toBeFalsy();
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
});
});
it('resets filters when switching modes', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="application" />);
// Set a filter
const filterInput = screen.getByPlaceholderText('Filter by text...');
await user.type(filterInput, 'test');
// Switch to security mode
const securityButton = screen.getByTitle('Security access logs');
await user.click(securityButton);
await waitFor(() => {
// Filter should be cleared
expect(screen.getByPlaceholderText('Filter by text...')).toHaveValue('');
});
});
});
});

View File

@@ -1,112 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { CharonLoader, CharonCoinLoader, CerberusLoader, ConfigReloadOverlay } from '../LoadingStates'
describe('CharonLoader', () => {
it('renders boat animation with accessibility label', () => {
render(<CharonLoader />)
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
})
it('renders with different sizes', () => {
const { rerender } = render(<CharonLoader size="sm" />)
expect(screen.getByRole('status')).toBeInTheDocument()
rerender(<CharonLoader size="lg" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('CharonCoinLoader', () => {
it('renders coin animation with accessibility label', () => {
render(<CharonCoinLoader />)
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
})
it('renders with different sizes', () => {
const { rerender } = render(<CharonCoinLoader size="sm" />)
expect(screen.getByRole('status')).toBeInTheDocument()
rerender(<CharonCoinLoader size="lg" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('CerberusLoader', () => {
it('renders guardian animation with accessibility label', () => {
render(<CerberusLoader />)
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
})
it('renders with different sizes', () => {
const { rerender } = render(<CerberusLoader size="sm" />)
expect(screen.getByRole('status')).toBeInTheDocument()
rerender(<CerberusLoader size="lg" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('ConfigReloadOverlay', () => {
it('renders with Charon theme (default)', () => {
render(<ConfigReloadOverlay />)
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
})
it('renders with Coin theme', () => {
render(
<ConfigReloadOverlay
message="Paying the ferryman..."
submessage="Your obol grants passage"
type="coin"
/>
)
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
})
it('renders with Cerberus theme', () => {
render(
<ConfigReloadOverlay
message="Cerberus awakens..."
submessage="Guardian of the gates stands watch"
type="cerberus"
/>
)
expect(screen.getByText('Cerberus awakens...')).toBeInTheDocument()
expect(screen.getByText('Guardian of the gates stands watch')).toBeInTheDocument()
})
it('renders with custom messages', () => {
render(
<ConfigReloadOverlay
message="Custom message"
submessage="Custom submessage"
type="charon"
/>
)
expect(screen.getByText('Custom message')).toBeInTheDocument()
expect(screen.getByText('Custom submessage')).toBeInTheDocument()
})
it('applies correct theme colors', () => {
const { container, rerender } = render(<ConfigReloadOverlay type="charon" />)
let overlay = container.querySelector('.bg-blue-950\\/90')
expect(overlay).toBeInTheDocument()
rerender(<ConfigReloadOverlay type="coin" />)
overlay = container.querySelector('.bg-amber-950\\/90')
expect(overlay).toBeInTheDocument()
rerender(<ConfigReloadOverlay type="cerberus" />)
overlay = container.querySelector('.bg-red-950\\/90')
expect(overlay).toBeInTheDocument()
})
it('renders as full-screen overlay with high z-index', () => {
const { container } = render(<ConfigReloadOverlay />)
const overlay = container.querySelector('.fixed.inset-0.z-50')
expect(overlay).toBeInTheDocument()
})
})

View File

@@ -1,321 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import {
CharonLoader,
CharonCoinLoader,
CerberusLoader,
ConfigReloadOverlay,
} from '../LoadingStates'
describe('LoadingStates - Security Audit', () => {
describe('CharonLoader', () => {
it('renders without crashing', () => {
const { container } = render(<CharonLoader />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('handles all size variants', () => {
const { rerender } = render(<CharonLoader size="sm" />)
expect(screen.getByRole('status')).toBeInTheDocument()
rerender(<CharonLoader size="md" />)
expect(screen.getByRole('status')).toBeInTheDocument()
rerender(<CharonLoader size="lg" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('has accessible role and label', () => {
render(<CharonLoader />)
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-label', 'Loading')
})
it('applies correct size classes', () => {
const { container, rerender } = render(<CharonLoader size="sm" />)
expect(container.firstChild).toHaveClass('w-12', 'h-12')
rerender(<CharonLoader size="md" />)
expect(container.firstChild).toHaveClass('w-20', 'h-20')
rerender(<CharonLoader size="lg" />)
expect(container.firstChild).toHaveClass('w-28', 'h-28')
})
})
describe('CharonCoinLoader', () => {
it('renders without crashing', () => {
const { container } = render(<CharonCoinLoader />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('has accessible role and label for authentication', () => {
render(<CharonCoinLoader />)
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-label', 'Authenticating')
})
it('renders gradient definition', () => {
const { container } = render(<CharonCoinLoader />)
const gradient = container.querySelector('#goldGradient')
expect(gradient).toBeInTheDocument()
})
it('applies correct size classes', () => {
const { container, rerender } = render(<CharonCoinLoader size="sm" />)
expect(container.firstChild).toHaveClass('w-12', 'h-12')
rerender(<CharonCoinLoader size="md" />)
expect(container.firstChild).toHaveClass('w-20', 'h-20')
rerender(<CharonCoinLoader size="lg" />)
expect(container.firstChild).toHaveClass('w-28', 'h-28')
})
})
describe('CerberusLoader', () => {
it('renders without crashing', () => {
const { container } = render(<CerberusLoader />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('has accessible role and label for security', () => {
render(<CerberusLoader />)
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-label', 'Security Loading')
})
it('renders three heads (three circles for heads)', () => {
const { container } = render(<CerberusLoader />)
const circles = container.querySelectorAll('circle')
// At least 3 head circles should exist (plus paws and eyes)
expect(circles.length).toBeGreaterThanOrEqual(3)
})
it('applies correct size classes', () => {
const { container, rerender } = render(<CerberusLoader size="sm" />)
expect(container.firstChild).toHaveClass('w-12', 'h-12')
rerender(<CerberusLoader size="md" />)
expect(container.firstChild).toHaveClass('w-20', 'h-20')
rerender(<CerberusLoader size="lg" />)
expect(container.firstChild).toHaveClass('w-28', 'h-28')
})
})
describe('ConfigReloadOverlay - XSS Protection', () => {
it('renders with default props', () => {
render(<ConfigReloadOverlay />)
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
})
it('ATTACK: prevents XSS in message prop', () => {
const xssPayload = '<script>alert("XSS")</script>'
render(<ConfigReloadOverlay message={xssPayload} />)
// React should escape this automatically
expect(screen.getByText(xssPayload)).toBeInTheDocument()
expect(document.querySelector('script')).not.toBeInTheDocument()
})
it('ATTACK: prevents XSS in submessage prop', () => {
const xssPayload = '<img src=x onerror="alert(1)">'
render(<ConfigReloadOverlay submessage={xssPayload} />)
expect(screen.getByText(xssPayload)).toBeInTheDocument()
expect(document.querySelector('img[onerror]')).not.toBeInTheDocument()
})
it('ATTACK: handles extremely long messages', () => {
const longMessage = 'A'.repeat(10000)
const { container } = render(<ConfigReloadOverlay message={longMessage} />)
// Should render without crashing
expect(container).toBeInTheDocument()
expect(screen.getByText(longMessage)).toBeInTheDocument()
})
it('ATTACK: handles special characters', () => {
const specialChars = '!@#$%^&*()_+-=[]{}|;:",.<>?/~`'
render(
<ConfigReloadOverlay
message={specialChars}
submessage={specialChars}
/>
)
expect(screen.getAllByText(specialChars)).toHaveLength(2)
})
it('ATTACK: handles unicode and emoji', () => {
const unicode = '🔥💀🐕‍🦺 λ µ π Σ 中文 العربية עברית'
render(<ConfigReloadOverlay message={unicode} />)
expect(screen.getByText(unicode)).toBeInTheDocument()
})
it('renders correct theme - charon (blue)', () => {
const { container } = render(<ConfigReloadOverlay type="charon" />)
const overlay = container.querySelector('.bg-blue-950\\/90')
expect(overlay).toBeInTheDocument()
})
it('renders correct theme - coin (gold)', () => {
const { container } = render(<ConfigReloadOverlay type="coin" />)
const overlay = container.querySelector('.bg-amber-950\\/90')
expect(overlay).toBeInTheDocument()
})
it('renders correct theme - cerberus (red)', () => {
const { container } = render(<ConfigReloadOverlay type="cerberus" />)
const overlay = container.querySelector('.bg-red-950\\/90')
expect(overlay).toBeInTheDocument()
})
it('applies correct z-index (z-50)', () => {
const { container } = render(<ConfigReloadOverlay />)
const overlay = container.querySelector('.z-50')
expect(overlay).toBeInTheDocument()
})
it('applies backdrop blur', () => {
const { container } = render(<ConfigReloadOverlay />)
const backdrop = container.querySelector('.backdrop-blur-sm')
expect(backdrop).toBeInTheDocument()
})
it('ATTACK: type prop injection attempt', () => {
// @ts-expect-error - Testing invalid type
const { container } = render(<ConfigReloadOverlay type="<script>alert(1)</script>" />)
// Should default to charon theme
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
})
})
describe('Overlay Integration Tests', () => {
it('CharonLoader renders inside overlay', () => {
render(<ConfigReloadOverlay type="charon" />)
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
})
it('CharonCoinLoader renders inside overlay', () => {
render(<ConfigReloadOverlay type="coin" />)
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
})
it('CerberusLoader renders inside overlay', () => {
render(<ConfigReloadOverlay type="cerberus" />)
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
})
})
describe('CSS Animation Requirements', () => {
it('CharonLoader uses animate-bob-boat class', () => {
const { container } = render(<CharonLoader />)
const animated = container.querySelector('.animate-bob-boat')
expect(animated).toBeInTheDocument()
})
it('CharonCoinLoader uses animate-spin-y class', () => {
const { container } = render(<CharonCoinLoader />)
const animated = container.querySelector('.animate-spin-y')
expect(animated).toBeInTheDocument()
})
it('CerberusLoader uses animate-rotate-head class', () => {
const { container } = render(<CerberusLoader />)
const animated = container.querySelector('.animate-rotate-head')
expect(animated).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('handles undefined size prop gracefully', () => {
const { container } = render(<CharonLoader size={undefined} />)
expect(container.firstChild).toHaveClass('w-20', 'h-20') // defaults to md
})
it('handles null message', () => {
// @ts-expect-error - Testing null
render(<ConfigReloadOverlay message={null} />)
// Null message renders as empty paragraph - component gracefully handles null
const textContainer = screen.getByText(/Charon is crossing the Styx/i).closest('div')
expect(textContainer).toBeInTheDocument()
})
it('handles empty string message', () => {
render(<ConfigReloadOverlay message="" submessage="" />)
// Should render but be empty
expect(screen.queryByText('Ferrying configuration...')).not.toBeInTheDocument()
})
it('handles undefined type prop', () => {
const { container } = render(<ConfigReloadOverlay type={undefined} />)
// Should default to charon
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
})
})
describe('Accessibility Requirements', () => {
it('overlay is keyboard accessible', () => {
const { container } = render(<ConfigReloadOverlay />)
const overlay = container.firstChild
expect(overlay).toBeInTheDocument()
})
it('all loaders have status role', () => {
render(
<>
<CharonLoader />
<CharonCoinLoader />
<CerberusLoader />
</>
)
const statuses = screen.getAllByRole('status')
expect(statuses).toHaveLength(3)
})
it('all loaders have aria-label', () => {
const { container: c1 } = render(<CharonLoader />)
const { container: c2 } = render(<CharonCoinLoader />)
const { container: c3 } = render(<CerberusLoader />)
expect(c1.firstChild).toHaveAttribute('aria-label')
expect(c2.firstChild).toHaveAttribute('aria-label')
expect(c3.firstChild).toHaveAttribute('aria-label')
})
})
describe('Performance Tests', () => {
it('renders CharonLoader quickly', () => {
const start = performance.now()
render(<CharonLoader />)
const end = performance.now()
expect(end - start).toBeLessThan(100) // Should render in <100ms
})
it('renders CharonCoinLoader quickly', () => {
const start = performance.now()
render(<CharonCoinLoader />)
const end = performance.now()
expect(end - start).toBeLessThan(100)
})
it('renders CerberusLoader quickly', () => {
const start = performance.now()
render(<CerberusLoader />)
const end = performance.now()
expect(end - start).toBeLessThan(100)
})
it('renders ConfigReloadOverlay quickly', () => {
const start = performance.now()
render(<ConfigReloadOverlay />)
const end = performance.now()
expect(end - start).toBeLessThan(100)
})
})
})

View File

@@ -1,712 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ManualDNSChallenge from '../dns-providers/ManualDNSChallenge'
import { useChallengePoll, useManualChallengeMutations } from '../../hooks/useManualChallenge'
import type { ManualChallenge } from '../../api/manualChallenge'
import { toast } from '../../utils/toast'
// Mock dependencies
vi.mock('../../hooks/useManualChallenge')
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
// Mock clipboard API using vi.stubGlobal
const mockWriteText = vi.fn()
vi.stubGlobal('navigator', {
...navigator,
clipboard: {
writeText: mockWriteText,
},
})
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'dnsProvider.manual.title': 'Manual DNS Challenge',
'dnsProvider.manual.instructions': `To obtain a certificate for ${options?.domain || 'example.com'}, create the following TXT record at your DNS provider:`,
'dnsProvider.manual.createRecord': 'Create this TXT record at your DNS provider',
'dnsProvider.manual.recordName': 'Record Name',
'dnsProvider.manual.recordValue': 'Record Value',
'dnsProvider.manual.ttl': 'TTL',
'dnsProvider.manual.seconds': 'seconds',
'dnsProvider.manual.minutes': 'minutes',
'dnsProvider.manual.timeRemaining': 'Time remaining',
'dnsProvider.manual.progressPercent': `${options?.percent || 0}% time remaining`,
'dnsProvider.manual.challengeProgress': 'Challenge timeout progress',
'dnsProvider.manual.copy': 'Copy',
'dnsProvider.manual.copied': 'Copied!',
'dnsProvider.manual.copyFailed': 'Failed to copy to clipboard',
'dnsProvider.manual.copyRecordName': 'Copy record name to clipboard',
'dnsProvider.manual.copyRecordValue': 'Copy record value to clipboard',
'dnsProvider.manual.checkDnsNow': 'Check DNS Now',
'dnsProvider.manual.checkDnsDescription': 'Immediately check if the DNS record has propagated',
'dnsProvider.manual.verifyButton': "I've Created the Record - Verify",
'dnsProvider.manual.verifyDescription': 'Verify that the DNS record exists',
'dnsProvider.manual.cancelChallenge': 'Cancel Challenge',
'dnsProvider.manual.lastCheck': 'Last checked',
'dnsProvider.manual.lastCheckSecondsAgo': `${options?.seconds || 0} seconds ago`,
'dnsProvider.manual.lastCheckMinutesAgo': `${options?.minutes || 0} minutes ago`,
'dnsProvider.manual.notPropagated': 'DNS record not yet propagated',
'dnsProvider.manual.dnsNotFound': 'DNS record not found',
'dnsProvider.manual.verifySuccess': 'DNS challenge verified successfully!',
'dnsProvider.manual.verifyFailed': 'DNS verification failed',
'dnsProvider.manual.challengeExpired': 'Challenge expired',
'dnsProvider.manual.challengeCancelled': 'Challenge cancelled',
'dnsProvider.manual.cancelFailed': 'Failed to cancel challenge',
'dnsProvider.manual.statusChanged': `Challenge status changed to ${options?.status || ''}`,
'dnsProvider.manual.status.created': 'Created',
'dnsProvider.manual.status.pending': 'Pending',
'dnsProvider.manual.status.verifying': 'Verifying...',
'dnsProvider.manual.status.verified': 'Verified',
'dnsProvider.manual.status.expired': 'Expired',
'dnsProvider.manual.status.failed': 'Failed',
'dnsProvider.manual.statusMessage.pending': 'Waiting for DNS propagation...',
'dnsProvider.manual.statusMessage.verified': 'DNS challenge verified successfully!',
'dnsProvider.manual.statusMessage.expired': 'Challenge has expired.',
'dnsProvider.manual.statusMessage.failed': 'DNS verification failed.',
}
return translations[key] || key
},
}),
}))
const mockChallenge: ManualChallenge = {
id: 'test-challenge-uuid',
status: 'pending',
fqdn: '_acme-challenge.example.com',
value: 'gZrH7wL9t3kM2nP4qX5yR8sT0uV1wZ2aB3cD4eF5gH6iJ7',
ttl: 300,
created_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(), // 2 minutes ago
expires_at: new Date(Date.now() + 8 * 60 * 1000).toISOString(), // 8 minutes from now
last_check_at: new Date(Date.now() - 10 * 1000).toISOString(), // 10 seconds ago
dns_propagated: false,
}
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderComponent = (
challenge: ManualChallenge = mockChallenge,
onComplete = vi.fn(),
onCancel = vi.fn()
) => {
const queryClient = createQueryClient()
return {
...render(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={challenge}
onComplete={onComplete}
onCancel={onCancel}
/>
</QueryClientProvider>
),
onComplete,
onCancel,
}
}
describe('ManualDNSChallenge', () => {
let mockVerifyMutation: ReturnType<typeof vi.fn>
let mockDeleteMutation: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
mockWriteText.mockResolvedValue(undefined)
mockVerifyMutation = vi.fn()
mockDeleteMutation = vi.fn()
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'pending',
dns_propagated: false,
time_remaining_seconds: 480,
last_check_at: new Date(Date.now() - 10 * 1000).toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useChallengePoll>)
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: false,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: false,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as unknown as ReturnType<typeof useManualChallengeMutations>)
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('renders the challenge title', () => {
renderComponent()
expect(screen.getByText('Manual DNS Challenge')).toBeInTheDocument()
})
it('displays the FQDN record name', () => {
renderComponent()
expect(screen.getByText('_acme-challenge.example.com')).toBeInTheDocument()
})
it('displays the challenge value', () => {
renderComponent()
expect(
screen.getByText('gZrH7wL9t3kM2nP4qX5yR8sT0uV1wZ2aB3cD4eF5gH6iJ7')
).toBeInTheDocument()
})
it('displays TTL information', () => {
renderComponent()
expect(screen.getByText(/300/)).toBeInTheDocument()
expect(screen.getByText(/5 minutes/)).toBeInTheDocument()
})
it('renders copy buttons with aria labels', () => {
renderComponent()
expect(
screen.getByRole('button', { name: /copy record name/i })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /copy record value/i })
).toBeInTheDocument()
})
it('renders verify and check DNS buttons', () => {
renderComponent()
expect(screen.getByText('Check DNS Now')).toBeInTheDocument()
expect(screen.getByText("I've Created the Record - Verify")).toBeInTheDocument()
})
it('renders cancel button when not in terminal state', () => {
renderComponent()
expect(screen.getByText('Cancel Challenge')).toBeInTheDocument()
})
})
describe('Progress and Countdown', () => {
it('displays time remaining', () => {
renderComponent()
expect(screen.getByText(/Time remaining/i)).toBeInTheDocument()
})
it('displays progress bar', () => {
renderComponent()
expect(
screen.getByRole('progressbar', { name: /challenge.*progress/i })
).toBeInTheDocument()
})
it('updates countdown every second', async () => {
renderComponent()
// Get initial time display
const timeElement = screen.getByText(/Time remaining/i)
expect(timeElement).toBeInTheDocument()
// Advance timer by 1 second
await act(async () => {
vi.advanceTimersByTime(1000)
})
// Time should have updated (countdown decreased)
expect(timeElement).toBeInTheDocument()
})
})
describe('Copy Functionality', () => {
// Note: These tests verify the clipboard copy functionality.
// Due to jsdom limitations with navigator.clipboard mocking, we test
// the UI state changes instead of verifying the actual clipboard API calls.
// The component correctly shows "Copied!" state after clicking, which
// indicates the async copy handler completed successfully.
beforeEach(() => {
vi.useRealTimers()
})
afterEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
it('shows copied state after clicking copy record name button', async () => {
const user = userEvent.setup()
renderComponent()
const copyNameButton = screen.getByRole('button', { name: /copy record name/i })
await user.click(copyNameButton)
// The button should show the "Copied!" state after successful copy
await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
it('shows copied state after clicking copy record value button', async () => {
const user = userEvent.setup()
renderComponent()
const copyValueButton = screen.getByRole('button', { name: /copy record value/i })
await user.click(copyValueButton)
// The button should show the "Copied!" state after successful copy
await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
it('copy buttons are accessible and clickable', () => {
renderComponent()
const copyNameButton = screen.getByRole('button', { name: /copy record name/i })
const copyValueButton = screen.getByRole('button', { name: /copy record value/i })
expect(copyNameButton).toBeEnabled()
expect(copyValueButton).toBeEnabled()
expect(copyNameButton).toHaveAttribute('aria-label')
expect(copyValueButton).toHaveAttribute('aria-label')
})
})
describe('Verification', () => {
it('calls verify mutation when verify button is clicked', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: true, dns_found: true, message: 'OK' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(mockVerifyMutation).toHaveBeenCalledWith({
providerId: 1,
challengeId: 'test-challenge-uuid',
})
})
it('calls verify mutation when Check DNS Now is clicked', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: false, dns_found: false, message: '' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const checkButton = screen.getByText('Check DNS Now')
await user.click(checkButton)
expect(mockVerifyMutation).toHaveBeenCalled()
})
it('shows success toast on successful verification', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: true, dns_found: true, message: 'OK' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.success).toHaveBeenCalledWith('DNS challenge verified successfully!')
})
it('shows warning toast when DNS not found', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: false, dns_found: false, message: '' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.warning).toHaveBeenCalledWith('DNS record not found')
})
it('shows error toast on verification failure', async () => {
mockVerifyMutation.mockRejectedValueOnce({
response: { data: { message: 'Server error' } },
})
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.error).toHaveBeenCalledWith('Server error')
})
})
describe('Cancellation', () => {
it('calls delete mutation and onCancel when cancelled', async () => {
mockDeleteMutation.mockResolvedValueOnce(undefined)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const { onCancel } = renderComponent()
const cancelButton = screen.getByText('Cancel Challenge')
await user.click(cancelButton)
expect(mockDeleteMutation).toHaveBeenCalledWith({
providerId: 1,
challengeId: 'test-challenge-uuid',
})
expect(onCancel).toHaveBeenCalled()
expect(toast.info).toHaveBeenCalledWith('Challenge cancelled')
})
it('shows error toast when cancellation fails', async () => {
mockDeleteMutation.mockRejectedValueOnce({
response: { data: { message: 'Cannot cancel' } },
})
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const cancelButton = screen.getByText('Cancel Challenge')
await user.click(cancelButton)
expect(toast.error).toHaveBeenCalledWith('Cannot cancel')
})
})
describe('Terminal States', () => {
it('hides cancel button when challenge is verified', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useChallengePoll>)
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
dns_propagated: true,
}
renderComponent(verifiedChallenge)
expect(screen.queryByText('Cancel Challenge')).not.toBeInTheDocument()
})
it('hides progress bar when challenge is expired', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'expired',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useChallengePoll>)
const expiredChallenge: ManualChallenge = {
...mockChallenge,
status: 'expired',
}
renderComponent(expiredChallenge)
expect(
screen.queryByRole('progressbar', { name: /challenge.*progress/i })
).not.toBeInTheDocument()
})
it('disables verify buttons when challenge is failed', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
error_message: 'ACME validation failed',
},
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useChallengePoll>)
const failedChallenge: ManualChallenge = {
...mockChallenge,
status: 'failed',
}
renderComponent(failedChallenge)
expect(screen.getByText('Check DNS Now').closest('button')).toBeDisabled()
expect(
screen.getByText("I've Created the Record - Verify").closest('button')
).toBeDisabled()
})
it('calls onComplete with true when status changes to verified', async () => {
const { onComplete, rerender } = renderComponent()
// Update poll data to verified
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useChallengePoll>)
// Re-render to trigger effect
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
dns_propagated: true,
}
const queryClient = createQueryClient()
rerender(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={verifiedChallenge}
onComplete={onComplete}
onCancel={vi.fn()}
/>
</QueryClientProvider>
)
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(true)
})
})
it('calls onComplete with false when status changes to expired', async () => {
const { onComplete, rerender } = renderComponent()
// Update poll data to expired
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'expired',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useChallengePoll>)
const expiredChallenge: ManualChallenge = {
...mockChallenge,
status: 'expired',
}
const queryClient = createQueryClient()
rerender(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={expiredChallenge}
onComplete={onComplete}
onCancel={vi.fn()}
/>
</QueryClientProvider>
)
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(false)
})
})
})
describe('Accessibility', () => {
it('has proper ARIA labels for copy buttons', () => {
renderComponent()
expect(
screen.getByRole('button', { name: /copy record name to clipboard/i })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /copy record value to clipboard/i })
).toBeInTheDocument()
})
it('has screen reader announcer for status changes', () => {
renderComponent()
const announcer = document.querySelector('[role="status"][aria-live="polite"]')
expect(announcer).toBeInTheDocument()
})
it('has proper labels for form fields', () => {
renderComponent()
expect(screen.getByText('Record Name')).toBeInTheDocument()
expect(screen.getByText('Record Value')).toBeInTheDocument()
})
it('progress bar has accessible label', () => {
renderComponent()
const progressBar = screen.getByRole('progressbar')
expect(progressBar).toHaveAttribute('aria-label')
})
it('buttons have aria-describedby for additional context', () => {
renderComponent()
const checkDnsButton = screen.getByText('Check DNS Now').closest('button')
expect(checkDnsButton).toHaveAttribute('aria-describedby')
})
it('uses semantic heading structure', () => {
renderComponent()
// Card title should exist
expect(screen.getByText('Manual DNS Challenge')).toBeInTheDocument()
// Section heading for DNS record
expect(screen.getByText('Create this TXT record at your DNS provider')).toBeInTheDocument()
})
})
describe('Polling Behavior', () => {
it('enables polling when challenge is pending', () => {
renderComponent()
expect(useChallengePoll).toHaveBeenCalledWith(1, 'test-challenge-uuid', true, 10000)
})
it('disables polling when challenge is in terminal state', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useChallengePoll>)
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
}
renderComponent(verifiedChallenge)
// The component should pass enabled=false for terminal states
expect(useChallengePoll).toHaveBeenCalledWith(1, 'test-challenge-uuid', false, 10000)
})
})
describe('Loading States', () => {
it('shows loading state on verify button while verifying', () => {
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: true,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: false,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as unknown as ReturnType<typeof useManualChallengeMutations>)
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify").closest('button')
expect(verifyButton).toBeDisabled()
})
it('shows loading state on cancel button while cancelling', () => {
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: false,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: true,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as unknown as ReturnType<typeof useManualChallengeMutations>)
renderComponent()
const cancelButton = screen.getByText('Cancel Challenge').closest('button')
expect(cancelButton).toBeDisabled()
})
})
describe('Error Messages', () => {
it('displays error message from poll response', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
error_message: 'ACME server rejected the challenge',
},
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useChallengePoll>)
const failedChallenge: ManualChallenge = {
...mockChallenge,
status: 'failed',
error_message: 'ACME server rejected the challenge',
}
renderComponent(failedChallenge)
expect(screen.getByText('ACME server rejected the challenge')).toBeInTheDocument()
})
})
describe('Last Check Display', () => {
it('shows last check time when available', () => {
renderComponent()
expect(screen.getByText(/Last checked/i)).toBeInTheDocument()
})
it('shows propagation status when not propagated', () => {
renderComponent()
expect(screen.getByText(/not yet propagated/i)).toBeInTheDocument()
})
})
})

View File

@@ -1,172 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import NotificationCenter from '../NotificationCenter'
import * as api from '../../api/system'
// Mock the API
vi.mock('../../api/system', () => ({
getNotifications: vi.fn(),
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
checkUpdates: vi.fn(),
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
const mockNotifications: api.Notification[] = [
{
id: '1',
type: 'info',
title: 'Info Notification',
message: 'This is an info message',
read: false,
created_at: '2025-01-01T10:00:00Z',
},
{
id: '2',
type: 'success',
title: 'Success Notification',
message: 'This is a success message',
read: false,
created_at: '2025-01-01T11:00:00Z',
},
{
id: '3',
type: 'warning',
title: 'Warning Notification',
message: 'This is a warning message',
read: false,
created_at: '2025-01-01T12:00:00Z',
},
{
id: '4',
type: 'error',
title: 'Error Notification',
message: 'This is an error message',
read: false,
created_at: '2025-01-01T13:00:00Z',
},
]
describe('NotificationCenter', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.checkUpdates).mockResolvedValue({
available: false,
latest_version: '0.0.0',
changelog_url: '',
})
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders bell icon and unread count', async () => {
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
render(<NotificationCenter />, { wrapper: createWrapper() })
expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('4')).toBeInTheDocument()
})
})
it('opens notification panel on click', async () => {
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
render(<NotificationCenter />, { wrapper: createWrapper() })
const bellButton = screen.getByRole('button', { name: /notifications/i })
await userEvent.click(bellButton)
await waitFor(() => {
expect(screen.getByText('Notifications')).toBeInTheDocument()
expect(screen.getByText('Info Notification')).toBeInTheDocument()
expect(screen.getByText('Success Notification')).toBeInTheDocument()
expect(screen.getByText('Warning Notification')).toBeInTheDocument()
expect(screen.getByText('Error Notification')).toBeInTheDocument()
})
})
it('displays empty state when no notifications', async () => {
vi.mocked(api.getNotifications).mockResolvedValue([])
render(<NotificationCenter />, { wrapper: createWrapper() })
const bellButton = screen.getByRole('button', { name: /notifications/i })
await userEvent.click(bellButton)
await waitFor(() => {
expect(screen.getByText('No new notifications')).toBeInTheDocument()
})
})
it('marks single notification as read', async () => {
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
vi.mocked(api.markNotificationRead).mockResolvedValue()
render(<NotificationCenter />, { wrapper: createWrapper() })
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
await waitFor(() => {
expect(screen.getByText('Info Notification')).toBeInTheDocument()
})
const closeButtons = screen.getAllByRole('button', { name: /close/i })
await userEvent.click(closeButtons[0])
await waitFor(() => {
expect(api.markNotificationRead).toHaveBeenCalledWith('1', expect.anything())
})
})
it('marks all notifications as read', async () => {
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
vi.mocked(api.markAllNotificationsRead).mockResolvedValue()
render(<NotificationCenter />, { wrapper: createWrapper() })
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
await waitFor(() => {
expect(screen.getByText('Mark all read')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('Mark all read'))
await waitFor(() => {
expect(api.markAllNotificationsRead).toHaveBeenCalled()
})
})
it('closes panel when clicking outside', async () => {
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
render(<NotificationCenter />, { wrapper: createWrapper() })
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
await waitFor(() => {
expect(screen.getByText('Notifications')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('notification-backdrop'))
await waitFor(() => {
expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,45 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PasswordStrengthMeter } from '../PasswordStrengthMeter'
describe('PasswordStrengthMeter', () => {
it('renders nothing when password is empty', () => {
const { container } = render(<PasswordStrengthMeter password="" />)
expect(container).toBeEmptyDOMElement()
})
it('renders strength label when password is provided', () => {
render(<PasswordStrengthMeter password="password123" />)
// Depending on the implementation, it might show "Weak", "Fair", etc.
// "password123" is likely weak or fair.
// Let's just check if any text is rendered.
expect(screen.getByText(/Weak|Fair|Good|Strong/)).toBeInTheDocument()
})
it('renders progress bars', () => {
render(<PasswordStrengthMeter password="password123" />)
// It usually renders 4 bars
// In the implementation I read, it renders one bar with width.
// <div className="h-1.5 w-full ..."><div className="h-full ..." style={{ width: ... }} /></div>
// So we can check for the progress bar container or the inner bar.
// Let's check for the label text which we already did.
// Let's check if the feedback is shown if present.
// For "password123", it might have feedback.
// But let's just stick to checking the label for now as "renders progress bars" was a bit vague in my previous attempt.
// I'll replace this test with something more specific or just remove it if covered by others.
// Actually, let's check that the bar exists.
// It doesn't have a role, so we can't use getByRole('progressbar').
// We can check if the container has the class 'bg-gray-200' or 'dark:bg-gray-700'.
// But testing implementation details (classes) is brittle.
// Let's just check that the component renders without crashing and shows the label.
expect(screen.getByText(/Weak|Fair|Good|Strong/)).toBeInTheDocument()
})
it('updates label based on password strength', () => {
const { rerender } = render(<PasswordStrengthMeter password="123" />)
expect(screen.getByText('Weak')).toBeInTheDocument()
rerender(<PasswordStrengthMeter password="CorrectHorseBatteryStaple1!" />)
expect(screen.getByText('Strong')).toBeInTheDocument()
})
})

View File

@@ -1,407 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProxyHostForm from '../ProxyHostForm'
import type { ProxyHost } from '../../api/proxyHosts'
import { mockRemoteServers } from '../../test/mockData'
// Mock the hooks
vi.mock('../../hooks/useRemoteServers', () => ({
useRemoteServers: vi.fn(() => ({
servers: mockRemoteServers,
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useDocker', () => ({
useDocker: vi.fn(() => ({
containers: [],
isLoading: false,
error: null,
refetch: vi.fn(),
})),
}))
vi.mock('../../hooks/useDomains', () => ({
useDomains: vi.fn(() => ({
domains: [{ uuid: 'domain-1', name: 'example.com' }],
createDomain: vi.fn().mockResolvedValue({ uuid: 'domain-1', name: 'example.com' }),
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(() => ({
certificates: [],
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useSecurity', () => ({
useAuthPolicies: vi.fn(() => ({
policies: [],
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({
profiles: [],
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useDNSProviders', () => ({
useDNSProviders: vi.fn(() => ({
data: [
{
id: 1,
uuid: 'dns-uuid-1',
name: 'Cloudflare',
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',
},
],
isLoading: false,
isError: false,
})),
}))
vi.mock('../../api/proxyHosts', () => ({
testProxyHostConnection: vi.fn(),
}))
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
describe('ProxyHostForm - DNS Provider Integration', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ internal_ip: '192.168.1.50' }),
})
})
describe('Wildcard Domain Detection', () => {
it('detects *.example.com as wildcard', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com')
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
})
})
it('does not detect sub.example.com as wildcard', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, 'sub.example.com')
expect(screen.queryByText('Wildcard Certificate Required')).not.toBeInTheDocument()
})
it('detects multiple wildcards in comma-separated list', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, 'app.test.com, *.wildcard.com, api.test.com')
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
})
})
it('detects wildcard at start of comma-separated list', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com, app.example.com')
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
})
})
})
describe('DNS Provider Requirement for Wildcards', () => {
it('shows DNS provider selector when wildcard domain entered', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com')
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
// Verify the selector combobox is rendered (even without opening it)
const selectors = screen.getAllByRole('combobox')
expect(selectors.length).toBeGreaterThan(3) // More than the base form selectors
})
})
it('shows info alert explaining DNS-01 requirement', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com')
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
expect(
screen.getByText(/Wildcard certificates.*require DNS-01 challenge/i)
).toBeInTheDocument()
})
})
it('shows validation error on submit if wildcard without provider', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
// Fill required fields
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Test Service')
await userEvent.type(
screen.getByPlaceholderText('example.com, www.example.com'),
'*.example.com'
)
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
// Submit without selecting DNS provider
await userEvent.click(screen.getByText('Save'))
// Should not call onSubmit
await waitFor(() => {
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})
it('does not show DNS provider selector without wildcard', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, 'app.example.com')
// DNS Provider section should not appear
expect(screen.queryByText('Wildcard Certificate Required')).not.toBeInTheDocument()
})
})
describe('DNS Provider Selection', () => {
it('DNS provider selector is present for wildcard domains', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
// Enter wildcard domain to show DNS selector
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com')
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
})
// DNS provider selector should be rendered (it's a combobox without explicit name)
const comboboxes = screen.getAllByRole('combobox')
// There should be extra combobox(es) now for DNS provider
expect(comboboxes.length).toBeGreaterThan(5) // Base form has ~5 comboboxes
})
it('clears DNS provider when switching to non-wildcard', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
// Enter wildcard
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com')
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
})
// Change to non-wildcard domain
await userEvent.clear(domainInput)
await userEvent.type(domainInput, 'app.example.com')
// DNS provider selector should disappear
await waitFor(() => {
expect(screen.queryByText('Wildcard Certificate Required')).not.toBeInTheDocument()
})
})
it('preserves form state during wildcard domain edits', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
// Fill name field
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Test Service')
// Enter wildcard
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com')
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
})
// Edit other fields
await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5')
// Name should still be present
expect(screen.getByPlaceholderText('My Service')).toHaveValue('Test Service')
})
})
describe('Form Submission with DNS Provider', () => {
it('includes dns_provider_id null for non-wildcard domains', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
// Fill required fields without wildcard
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Regular Service')
await userEvent.type(
screen.getByPlaceholderText('example.com, www.example.com'),
'app.example.com'
)
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
// Submit form
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
dns_provider_id: null,
})
)
})
})
it('prevents submission when wildcard present without DNS provider', async () => {
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
// Fill required fields with wildcard
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Test Service')
await userEvent.type(
screen.getByPlaceholderText('example.com, www.example.com'),
'*.example.com'
)
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
// Submit without selecting DNS provider
await userEvent.click(screen.getByText('Save'))
// Should not call onSubmit due to validation
await waitFor(() => {
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})
it('loads existing host with DNS provider correctly', async () => {
const existingHost: ProxyHost = {
uuid: 'test-uuid',
name: 'Existing Wildcard',
domain_names: '*.example.com',
forward_scheme: 'http',
forward_host: '192.168.1.100',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: true,
websocket_support: false,
application: 'none',
locations: [],
enabled: true,
dns_provider_id: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
renderWithClient(
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// DNS provider section should be visible due to wildcard
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
})
// The form should have wildcard domain loaded
expect(screen.getByPlaceholderText('example.com, www.example.com')).toHaveValue(
'*.example.com'
)
})
it('submits with dns_provider_id when editing existing wildcard host', async () => {
const existingHost: ProxyHost = {
uuid: 'test-uuid',
name: 'Existing Wildcard',
domain_names: '*.example.com',
forward_scheme: 'http',
forward_host: '192.168.1.100',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: true,
websocket_support: false,
application: 'none',
locations: [],
enabled: true,
dns_provider_id: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
renderWithClient(
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
})
// Submit without changes
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
dns_provider_id: 1,
})
)
})
})
})
})

View File

@@ -1,91 +0,0 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProxyHostForm from '../ProxyHostForm'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
vi.mock('../../api/uptime', () => ({
syncMonitors: vi.fn(() => Promise.resolve({})),
}))
// Minimal hook mocks used by the component
vi.mock('../../hooks/useRemoteServers', () => ({
useRemoteServers: vi.fn(() => ({
servers: [],
isLoading: false,
error: null,
createRemoteServer: vi.fn(),
updateRemoteServer: vi.fn(),
deleteRemoteServer: vi.fn(),
})),
}))
vi.mock('../../hooks/useDocker', () => ({
useDocker: vi.fn(() => ({ containers: [], isLoading: false, error: null, refetch: vi.fn() })),
}))
vi.mock('../../hooks/useDomains', () => ({
useDomains: vi.fn(() => ({ domains: [], createDomain: vi.fn().mockResolvedValue({}), isLoading: false, error: null })),
}))
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })),
}))
// stub global fetch for health endpoint
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) })))
describe('ProxyHostForm Add Uptime flow', () => {
it('submits host and requests uptime sync when Add Uptime is checked', async () => {
const onSubmit = vi.fn(() => Promise.resolve())
const onCancel = vi.fn()
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ProxyHostForm onSubmit={onSubmit} onCancel={onCancel} />
</QueryClientProvider>
)
// Fill required fields
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Service')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'example.com')
await userEvent.type(screen.getByLabelText(/^Host$/), '127.0.0.1')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
// Check Add Uptime
const addUptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
await userEvent.click(addUptimeCheckbox)
// Adjust uptime options — locate the container for the uptime inputs
const uptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
const uptimeContainer = uptimeCheckbox.closest('label')?.parentElement
if (!uptimeContainer) throw new Error('Uptime container not found')
const { within } = await import('@testing-library/react')
const spinbuttons = within(uptimeContainer).getAllByRole('spinbutton')
// first spinbutton is interval, second is max retries
fireEvent.change(spinbuttons[0], { target: { value: '30' } })
fireEvent.change(spinbuttons[1], { target: { value: '2' } })
// Submit
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement
if (!submitBtn) throw new Error('Submit button not found')
await userEvent.click(submitBtn)
// wait for onSubmit to have been called
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
// Ensure uptime API was called with provided options
const uptime = await import('../../api/uptime')
await waitFor(() => expect(uptime.syncMonitors).toHaveBeenCalledWith({ interval: 30, max_retries: 2 }))
// Ensure onSubmit payload does not include temporary uptime keys
const onSubmitMock = onSubmit as unknown as import('vitest').Mock
const submittedPayload = onSubmitMock.mock.calls[0][0]
expect(submittedPayload).not.toHaveProperty('addUptime')
expect(submittedPayload).not.toHaveProperty('uptimeInterval')
expect(submittedPayload).not.toHaveProperty('uptimeMaxRetries')
})
})

View File

@@ -1,660 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProxyHostForm from '../ProxyHostForm'
import type { ProxyHost } from '../../api/proxyHosts'
import { mockRemoteServers } from '../../test/mockData'
// Mock the hooks
vi.mock('../../hooks/useRemoteServers', () => ({
useRemoteServers: vi.fn(() => ({
servers: mockRemoteServers,
isLoading: false,
error: null,
createRemoteServer: vi.fn(),
updateRemoteServer: vi.fn(),
deleteRemoteServer: vi.fn(),
})),
}))
vi.mock('../../hooks/useDocker', () => ({
useDocker: vi.fn(() => ({
containers: [
{
id: 'container-123',
names: ['my-app'],
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' }]
}
],
isLoading: false,
error: null,
refetch: vi.fn(),
})),
}))
vi.mock('../../hooks/useDomains', () => ({
useDomains: vi.fn(() => ({
domains: [
{ uuid: 'domain-1', name: 'existing.com' }
],
createDomain: vi.fn().mockResolvedValue({ uuid: 'domain-1', name: 'existing.com' }),
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(() => ({
certificates: [
{ id: 1, name: 'Cert 1', domain: 'example.com', provider: 'custom', issuer: 'Custom', expires_at: '2026-01-01' }
],
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useSecurity', () => ({
useAuthPolicies: vi.fn(() => ({
policies: [
{ id: 1, name: 'Admin Only', description: 'Requires admin role' }
],
isLoading: false,
error: null,
})),
}))
vi.mock('../../api/proxyHosts', () => ({
testProxyHostConnection: vi.fn(),
}))
// Mock global fetch for health API
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderWithClient = (ui: React.ReactElement) => {
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
)
}
const renderWithClientAct = async (ui: React.ReactElement) => {
await act(async () => {
renderWithClient(ui)
})
}
import { testProxyHostConnection } from '../../api/proxyHosts'
describe('ProxyHostForm', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
beforeEach(() => {
// Default fetch mock for health endpoint
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ internal_ip: '192.168.1.50' }),
})
})
afterEach(() => {
vi.clearAllMocks()
})
it('handles scheme selection', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
})
// Find scheme select - it defaults to HTTP
// We can find it by label "Scheme"
const schemeSelect = screen.getByLabelText('Scheme') as HTMLSelectElement
await userEvent.selectOptions(schemeSelect, 'https')
expect(schemeSelect).toHaveValue('https')
})
it('prompts to save new base domain', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
// Enter a subdomain of a new base domain
await userEvent.type(domainInput, 'sub.newdomain.com')
await userEvent.tab()
await waitFor(() => {
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
expect(screen.getByText('newdomain.com')).toBeInTheDocument()
})
// Click "Yes, save it"
await userEvent.click(screen.getByText('Yes, save it'))
await waitFor(() => {
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
})
})
it('respects "Dont ask me again" for new domains', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
// Trigger prompt
await userEvent.type(domainInput, 'sub.another.com')
await userEvent.tab()
await waitFor(() => {
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
})
// Check "Don't ask me again"
await userEvent.click(screen.getByLabelText("Don't ask me again"))
// Click "No, thanks"
await userEvent.click(screen.getByText('No, thanks'))
await waitFor(() => {
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
})
// Try another new domain - should not prompt
await userEvent.type(domainInput, 'sub.yetanother.com')
await userEvent.tab()
// Should not see prompt
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
})
it('tests connection successfully', async () => {
vi.mocked(testProxyHostConnection).mockResolvedValue(undefined)
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill required fields for test connection
await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '80')
const testBtn = screen.getByTitle('Test connection to the forward host')
await userEvent.click(testBtn)
await waitFor(() => {
expect(testProxyHostConnection).toHaveBeenCalledWith('10.0.0.5', 80)
})
})
it('handles connection test failure', async () => {
vi.mocked(testProxyHostConnection).mockRejectedValue(new Error('Connection failed'))
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '80')
const testBtn = screen.getByTitle('Test connection to the forward host')
await userEvent.click(testBtn)
await waitFor(() => {
expect(testProxyHostConnection).toHaveBeenCalled()
})
// Should show error state (red button) - we can check class or icon
// The button changes class to bg-red-600
await waitFor(() => {
expect(testBtn).toHaveClass('bg-red-600')
})
})
it('handles base domain selection', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByLabelText('Base Domain (Auto-fill)')).toBeInTheDocument()
})
await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com')
// Should not update domain names yet as no container selected
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('')
// Select container then base domain
await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local')
await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'container-123')
await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com')
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
})
// Application Preset Tests
describe('Application Presets', () => {
it('renders application preset dropdown with all options', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const presetSelect = screen.getByLabelText(/Application Preset/i)
expect(presetSelect).toBeInTheDocument()
// Check that all presets are available
expect(screen.getByText('None - Standard reverse proxy')).toBeInTheDocument()
expect(screen.getByText('Plex - Media server with remote access')).toBeInTheDocument()
expect(screen.getByText('Jellyfin - Open source media server')).toBeInTheDocument()
expect(screen.getByText('Emby - Media server')).toBeInTheDocument()
expect(screen.getByText('Home Assistant - Home automation')).toBeInTheDocument()
expect(screen.getByText('Nextcloud - File sync and share')).toBeInTheDocument()
expect(screen.getByText('Vaultwarden - Password manager')).toBeInTheDocument()
})
it('defaults to none preset', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const presetSelect = screen.getByLabelText(/Application Preset/i)
expect(presetSelect).toHaveValue('none')
})
it('enables websockets when selecting plex preset', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// First uncheck websockets
const websocketCheckbox = screen.getByLabelText(/Websockets Support/i)
if (websocketCheckbox.getAttribute('checked') !== null) {
await userEvent.click(websocketCheckbox)
}
// Select Plex preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
})
// Websockets should be enabled
expect(screen.getByLabelText(/Websockets Support/i)).toBeChecked()
})
it('shows plex config helper with external URL when preset is selected', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill in domain names
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com')
// Select Plex preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
// Should show the helper with external URL
await waitFor(() => {
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
expect(screen.getByText('https://plex.mydomain.com:443')).toBeInTheDocument()
})
})
it('shows jellyfin config helper with internal IP', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill in domain names
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'jellyfin.mydomain.com')
// Select Jellyfin preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'jellyfin')
})
// Wait for health API fetch and show helper
await waitFor(() => {
expect(screen.getByText('Jellyfin Proxy Setup')).toBeInTheDocument()
expect(screen.getByText('192.168.1.50')).toBeInTheDocument()
})
})
it('shows home assistant config helper with yaml snippet', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill in domain names
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'ha.mydomain.com')
// Select Home Assistant preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'homeassistant')
})
// Wait for health API fetch and show helper
await waitFor(() => {
expect(screen.getByText('Home Assistant Proxy Setup')).toBeInTheDocument()
expect(screen.getByText(/use_x_forwarded_for/)).toBeInTheDocument()
expect(screen.getByText(/192\.168\.1\.50/)).toBeInTheDocument()
})
})
it('shows nextcloud config helper with php snippet', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill in domain names
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'nextcloud.mydomain.com')
// Select Nextcloud preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'nextcloud')
})
// Wait for health API fetch and show helper
await waitFor(() => {
expect(screen.getByText('Nextcloud Proxy Setup')).toBeInTheDocument()
expect(screen.getByText(/trusted_proxies/)).toBeInTheDocument()
expect(screen.getByText(/overwriteprotocol/)).toBeInTheDocument()
})
})
it('shows vaultwarden helper text', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill in domain names
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'vault.mydomain.com')
// Select Vaultwarden preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'vaultwarden')
// Wait for helper text
await waitFor(() => {
expect(screen.getByText('Vaultwarden Setup')).toBeInTheDocument()
expect(screen.getByText(/WebSocket support is enabled automatically/)).toBeInTheDocument()
expect(screen.getByText('vault.mydomain.com')).toBeInTheDocument()
})
})
it('auto-detects plex preset from container image', async () => {
// Mock useDocker to return a Plex container
const { useDocker } = await import('../../hooks/useDocker')
vi.mocked(useDocker).mockReturnValue({
containers: [
{
id: 'plex-container',
names: ['plex'],
image: 'linuxserver/plex:latest',
state: 'running',
status: 'Up 1 hour',
network: 'bridge',
ip: '172.17.0.3',
ports: [{ private_port: 32400, public_port: 32400, type: 'tcp' }]
}
],
isLoading: false,
error: null,
refetch: vi.fn(),
})
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Select local source
await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local')
// Select the plex container
await waitFor(() => {
expect(screen.getByText('plex (linuxserver/plex:latest)')).toBeInTheDocument()
})
await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'plex-container')
// The preset should be auto-detected as plex
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
})
it('auto-populates advanced_config when selecting plex preset and field empty', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Ensure advanced config is empty
const textarea = screen.getByLabelText(/Advanced Caddy Config/i)
expect(textarea).toHaveValue('')
// Select Plex preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
})
// Value should now be populated with a snippet containing X-Real-IP which is used by the preset
await waitFor(() => {
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Real-IP')
})
})
it('prompts to confirm overwrite when selecting preset and advanced_config is non-empty', async () => {
const existingHost = {
uuid: 'test-uuid',
name: 'ConfTest',
domain_names: 'test.example.com',
forward_scheme: 'http',
forward_host: '192.168.1.2',
forward_port: 8080,
advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}',
advanced_config_backup: '',
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: true,
websocket_support: true,
application: 'none' as const,
locations: [],
enabled: true,
created_at: '2025-01-01',
updated_at: '2025-01-01',
}
renderWithClient(
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Select Plex preset (should prompt since advanced_config is non-empty)
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
await waitFor(() => {
expect(screen.getByText('Confirm Preset Overwrite')).toBeInTheDocument()
})
// Click Overwrite
await userEvent.click(screen.getByText('Overwrite'))
// After overwrite, the textarea should contain the preset 'X-Real-IP' snippet
await waitFor(() => {
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Real-IP')
})
})
it('restores previous advanced_config from backup when clicking restore', async () => {
const existingHost = {
uuid: 'test-uuid',
name: 'RestoreTest',
domain_names: 'test.example.com',
forward_scheme: 'http',
forward_host: '192.168.1.2',
forward_port: 8080,
advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}',
advanced_config_backup: '{"handler":"headers","request":{"set":{"X-Prev":"backup"}}}',
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: true,
websocket_support: true,
application: 'none' as const,
locations: [],
enabled: true,
created_at: '2025-01-01',
updated_at: '2025-01-01',
}
renderWithClient(
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// The restore button should be visible
const restoreBtn = await screen.findByText('Restore previous config')
expect(restoreBtn).toBeInTheDocument()
// Click restore and expect the textarea to have backup value
await userEvent.click(restoreBtn)
await waitFor(() => {
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Prev')
})
})
it('includes application field in form submission', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill required fields
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Plex Server')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.test.com')
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '32400')
// Select Plex preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
// Submit form
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
application: 'plex',
websocket_support: true,
})
)
})
})
it('loads existing host application preset', async () => {
const existingHost = {
uuid: 'test-uuid',
name: 'Existing Plex',
domain_names: 'plex.example.com',
forward_scheme: 'http',
forward_host: '192.168.1.100',
forward_port: 32400,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: true,
websocket_support: true,
application: 'plex' as const,
locations: [],
enabled: true,
created_at: '2025-01-01',
updated_at: '2025-01-01',
}
renderWithClient(
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// The preset should be pre-selected
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
// The config helper should be visible
await waitFor(() => {
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
})
})
it('does not show config helper when preset is none', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill in domain names
await act(async () => {
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'test.mydomain.com')
})
// Preset defaults to none, so no helper should be shown
expect(screen.queryByText('Plex Remote Access Setup')).not.toBeInTheDocument()
expect(screen.queryByText('Jellyfin Proxy Setup')).not.toBeInTheDocument()
expect(screen.queryByText('Home Assistant Proxy Setup')).not.toBeInTheDocument()
})
it('copies external URL to clipboard for plex', async () => {
// Mock clipboard API
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, {
clipboard: { writeText: mockWriteText },
})
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fill in domain names
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com')
// Select Plex preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
// Wait for helper to appear
await waitFor(() => {
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
})
// Click the copy button
const copyButtons = screen.getAllByText('Copy')
await userEvent.click(copyButtons[0])
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('https://plex.mydomain.com:443')
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
})
})

View File

@@ -1,200 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import RemoteServerForm from '../RemoteServerForm'
import * as remoteServersApi from '../../api/remoteServers'
// Mock the API
vi.mock('../../api/remoteServers', () => ({
testRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080' })),
testCustomRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080', reachable: true })),
}))
describe('RemoteServerForm', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('renders create form', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Add Remote Server')).toBeInTheDocument()
expect(screen.getByPlaceholderText('My Production Server')).toHaveValue('')
})
it('renders edit form with pre-filled data', () => {
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
username: 'admin',
enabled: true,
reachable: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
render(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Edit Remote Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('Test Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('localhost')).toBeInTheDocument()
expect(screen.getByDisplayValue('5000')).toBeInTheDocument()
})
it('shows test connection button in create and edit mode', () => {
const { rerender } = render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Test Connection')).toBeInTheDocument()
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
enabled: true,
reachable: false,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
rerender(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Test Connection')).toBeInTheDocument()
})
it('calls onCancel when cancel button is clicked', async () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('submits form with correct data', async () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const nameInput = screen.getByPlaceholderText('My Production Server')
const hostInput = screen.getByPlaceholderText('192.168.1.100')
const portInput = screen.getByDisplayValue('22')
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'New Server')
await userEvent.clear(hostInput)
await userEvent.type(hostInput, '10.0.0.5')
await userEvent.clear(portInput)
await userEvent.type(portInput, '9090')
await userEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Server',
host: '10.0.0.5',
port: 9090,
})
)
})
})
it('handles provider selection', async () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const providerSelect = screen.getByDisplayValue('Generic')
await userEvent.selectOptions(providerSelect, 'docker')
expect(providerSelect).toHaveValue('docker')
})
it('handles submission error', async () => {
const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed')))
render(
<RemoteServerForm onSubmit={mockErrorSubmit} onCancel={mockOnCancel} />
)
// Fill required fields
await userEvent.clear(screen.getByPlaceholderText('My Production Server'))
await userEvent.type(screen.getByPlaceholderText('My Production Server'), 'Test Server')
await userEvent.clear(screen.getByPlaceholderText('192.168.1.100'))
await userEvent.type(screen.getByPlaceholderText('192.168.1.100'), '10.0.0.1')
await userEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(screen.getByText('Submission failed')).toBeInTheDocument()
})
})
it('handles test connection success', async () => {
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
enabled: true,
reachable: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
render(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const testButton = screen.getByText('Test Connection')
await userEvent.click(testButton)
await waitFor(() => {
// Check for success state (green background)
expect(testButton).toHaveClass('bg-green-600')
})
})
it('handles test connection failure', async () => {
// Override mock for this test
vi.mocked(remoteServersApi.testCustomRemoteServerConnection).mockRejectedValueOnce(new Error('Connection failed'))
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
enabled: true,
reachable: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
render(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.click(screen.getByText('Test Connection'))
await waitFor(() => {
expect(screen.getByText('Connection failed')).toBeInTheDocument()
})
})
})

View File

@@ -1,280 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi } from 'vitest';
import { SecurityHeaderProfileForm } from '../SecurityHeaderProfileForm';
import { securityHeadersApi, type SecurityHeaderProfile } from '../../api/securityHeaders';
vi.mock('../../api/securityHeaders');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('SecurityHeaderProfileForm', () => {
const mockOnSubmit = vi.fn();
const mockOnCancel = vi.fn();
const mockOnDelete = vi.fn();
const defaultProps = {
onSubmit: mockOnSubmit,
onCancel: mockOnCancel,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({
score: 85,
max_score: 100,
breakdown: {},
suggestions: [],
});
});
it('should render with empty form', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByPlaceholderText(/Production Security Headers/)).toBeInTheDocument();
expect(screen.getByText('HTTP Strict Transport Security (HSTS)')).toBeInTheDocument();
});
it('should render with initial data', () => {
const initialData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Test Profile',
description: 'Test description',
hsts_enabled: true,
hsts_max_age: 31536000,
security_score: 85,
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={initialData as SecurityHeaderProfile} />,
{ wrapper: createWrapper() }
);
expect(screen.getByDisplayValue('Test Profile')).toBeInTheDocument();
expect(screen.getByDisplayValue('Test description')).toBeInTheDocument();
});
it('should submit form with valid data', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'New Profile' } });
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled();
});
const submitData = mockOnSubmit.mock.calls[0][0];
expect(submitData.name).toBe('New Profile');
});
it('should not submit with empty name', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
fireEvent.click(submitButton);
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('should call onCancel when cancel button clicked', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const cancelButton = screen.getByRole('button', { name: /Cancel/ });
fireEvent.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it('should toggle HSTS enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// Switch component uses checkbox with sr-only class
const hstsSection = screen.getByText('HTTP Strict Transport Security (HSTS)').closest('div');
const hstsToggle = hstsSection?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(hstsToggle).toBeTruthy();
expect(hstsToggle.checked).toBe(true);
fireEvent.click(hstsToggle);
expect(hstsToggle.checked).toBe(false);
});
it('should show HSTS options when enabled', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/Max Age \(seconds\)/)).toBeInTheDocument();
expect(screen.getByText('Include Subdomains')).toBeInTheDocument();
expect(screen.getByText('Preload')).toBeInTheDocument();
});
it('should show preload warning when enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// Find the preload switch by finding the parent container with the "Preload" label
const preloadText = screen.getByText('Preload');
const preloadContainer = preloadText.closest('div')?.parentElement; // Go up to the flex container
const preloadSwitch = preloadContainer?.querySelector('input[type="checkbox"]');
expect(preloadSwitch).toBeTruthy();
if (preloadSwitch) {
fireEvent.click(preloadSwitch);
}
await waitFor(() => {
expect(screen.getByText(/Warning: HSTS Preload is Permanent/)).toBeInTheDocument();
});
});
it('should toggle CSP enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// CSP is disabled by default, so builder should not be visible
expect(screen.queryByText('Content Security Policy Builder')).not.toBeInTheDocument();
// Find and click the CSP toggle switch (checkbox with sr-only class)
const cspSection = screen.getByText('Content Security Policy (CSP)').closest('div');
const cspCheckbox = cspSection?.querySelector('input[type="checkbox"]');
if (cspCheckbox) {
fireEvent.click(cspCheckbox);
}
// Builder should now be visible
await waitFor(() => {
expect(screen.getByText('Content Security Policy Builder')).toBeInTheDocument();
});
});
it('should disable form for presets', () => {
const presetData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Basic Security',
is_preset: true,
preset_type: 'basic',
security_score: 65,
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={presetData as SecurityHeaderProfile} />,
{ wrapper: createWrapper() }
);
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
expect(nameInput).toBeDisabled();
expect(screen.getByText(/This is a system preset and cannot be modified/)).toBeInTheDocument();
});
it('should show delete button for non-presets', () => {
const profileData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 80,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={profileData as SecurityHeaderProfile}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('button', { name: /Delete Profile/ })).toBeInTheDocument();
});
it('should not show delete button for presets', () => {
const presetData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Basic Security',
is_preset: true,
preset_type: 'basic',
security_score: 65,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={presetData as SecurityHeaderProfile}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.queryByRole('button', { name: /Delete Profile/ })).not.toBeInTheDocument();
});
it('should change referrer policy', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const referrerSelect = screen.getAllByRole('combobox')[1]; // Referrer policy is second select
fireEvent.change(referrerSelect, { target: { value: 'no-referrer' } });
expect(referrerSelect).toHaveValue('no-referrer');
});
it('should change x-frame-options', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const xfoSelect = screen.getAllByRole('combobox')[0]; // X-Frame-Options is first select
fireEvent.change(xfoSelect, { target: { value: 'SAMEORIGIN' } });
expect(xfoSelect).toHaveValue('SAMEORIGIN');
});
it('should show loading state', () => {
render(<SecurityHeaderProfileForm {...defaultProps} isLoading={true} />, { wrapper: createWrapper() });
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
it('should show deleting state', () => {
const profileData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 80,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={profileData as SecurityHeaderProfile}
onDelete={mockOnDelete}
isDeleting={true}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Deleting...')).toBeInTheDocument();
});
it('should calculate security score on form changes', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'Test' } });
await waitFor(() => {
expect(securityHeadersApi.calculateScore).toHaveBeenCalled();
}, { timeout: 1000 });
});
});

View File

@@ -1,299 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import { SecurityNotificationSettingsModal } from '../SecurityNotificationSettingsModal';
import { createTestQueryClient } from '../../test/createTestQueryClient';
import * as notificationsApi from '../../api/notifications';
// Mock the API
vi.mock('../../api/notifications', async () => {
const actual = await vi.importActual('../../api/notifications');
return {
...actual,
getSecurityNotificationSettings: vi.fn(),
updateSecurityNotificationSettings: vi.fn(),
};
});
// Mock toast
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe('SecurityNotificationSettingsModal', () => {
const mockSettings: notificationsApi.SecurityNotificationSettings = {
enabled: true,
min_log_level: 'warn',
notify_waf_blocks: true,
notify_acl_denials: true,
notify_rate_limit_hits: false,
webhook_url: 'https://example.com/webhook',
email_recipients: 'admin@example.com',
};
let queryClient: ReturnType<typeof createTestQueryClient>;
beforeEach(() => {
queryClient = createTestQueryClient();
vi.clearAllMocks();
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue(mockSettings);
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockResolvedValue(mockSettings);
});
const renderModal = (isOpen = true, onClose = vi.fn()) => {
return render(
<QueryClientProvider client={queryClient}>
<SecurityNotificationSettingsModal isOpen={isOpen} onClose={onClose} />
</QueryClientProvider>
);
};
it('does not render when isOpen is false', () => {
renderModal(false);
expect(screen.queryByText('Security Notification Settings')).toBeFalsy();
});
it('renders the modal when isOpen is true', async () => {
renderModal();
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
});
it('loads and displays existing settings', async () => {
renderModal();
await waitFor(() => {
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
});
// Check that settings are loaded
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
expect(enableSwitch.checked).toBe(true);
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
expect(levelSelect.value).toBe('warn');
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
expect(webhookInput.value).toBe('https://example.com/webhook');
});
it('closes modal when close button is clicked', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
const closeButton = screen.getByLabelText('Close');
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
it('closes modal when clicking outside', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
const { container } = renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
// Click on the backdrop
const backdrop = container.querySelector('.fixed.inset-0');
if (backdrop) {
await user.click(backdrop);
expect(mockOnClose).toHaveBeenCalled();
}
});
it('submits updated settings', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
});
// Change minimum log level
const levelSelect = screen.getByLabelText(/minimum log level/i);
await user.selectOptions(levelSelect, 'error');
// Change webhook URL
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i);
await user.clear(webhookInput);
await user.type(webhookInput, 'https://new-webhook.com');
// Submit form
const saveButton = screen.getByRole('button', { name: /save settings/i });
await user.click(saveButton);
await waitFor(() => {
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
expect.objectContaining({
min_log_level: 'error',
webhook_url: 'https://new-webhook.com',
})
);
});
// Modal should close on success
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('toggles notification enable/disable', async () => {
const user = userEvent.setup();
renderModal();
await waitFor(() => {
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
});
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
expect(enableSwitch.checked).toBe(true);
// Disable notifications
await user.click(enableSwitch);
await waitFor(() => {
expect(enableSwitch.checked).toBe(false);
});
});
it('disables controls when notifications are disabled', async () => {
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue({
...mockSettings,
enabled: false,
});
renderModal();
// Wait for settings to be loaded and form to render
await waitFor(() => {
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
expect(enableSwitch.checked).toBe(false);
});
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
expect(levelSelect.disabled).toBe(true);
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
expect(webhookInput.disabled).toBe(true);
});
it('toggles event type filters', async () => {
const user = userEvent.setup();
renderModal();
await waitFor(() => {
expect(screen.getByText('WAF Blocks')).toBeTruthy();
});
// Find and toggle WAF blocks switch
const wafSwitch = screen.getByLabelText('WAF Blocks') as HTMLInputElement;
expect(wafSwitch.checked).toBe(true);
await user.click(wafSwitch);
// Submit form
const saveButton = screen.getByRole('button', { name: /save settings/i });
await user.click(saveButton);
await waitFor(() => {
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
expect.objectContaining({
notify_waf_blocks: false,
})
);
});
});
it('handles API errors gracefully', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockRejectedValue(
new Error('API Error')
);
renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
// Submit form
const saveButton = screen.getByRole('button', { name: /save settings/i });
await user.click(saveButton);
await waitFor(() => {
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalled();
});
// Modal should NOT close on error
expect(mockOnClose).not.toHaveBeenCalled();
});
it('shows loading state', () => {
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockReturnValue(
new Promise(() => {}) // Never resolves
);
renderModal();
expect(screen.getByText('Loading settings...')).toBeTruthy();
});
it('handles email recipients input', async () => {
const user = userEvent.setup();
renderModal();
await waitFor(() => {
expect(screen.getByPlaceholderText(/admin@example.com/i)).toBeTruthy();
});
const emailInput = screen.getByPlaceholderText(/admin@example.com/i);
await user.clear(emailInput);
await user.type(emailInput, 'user1@test.com, user2@test.com');
const saveButton = screen.getByRole('button', { name: /save settings/i });
await user.click(saveButton);
await waitFor(() => {
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
expect.objectContaining({
email_recipients: 'user1@test.com, user2@test.com',
})
);
});
});
it('prevents modal content clicks from closing modal', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
// Click inside the modal content
const modalContent = screen.getByText('Security Notification Settings');
await user.click(modalContent);
// Modal should not close
expect(mockOnClose).not.toHaveBeenCalled();
});
});

View File

@@ -1,152 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { SecurityScoreDisplay } from '../SecurityScoreDisplay';
describe('SecurityScoreDisplay', () => {
const mockBreakdown = {
hsts: 25,
csp: 20,
x_frame_options: 10,
x_content_type_options: 10,
};
const mockSuggestions = [
'Enable HSTS to enforce HTTPS',
'Add Content-Security-Policy',
];
it('should render with basic score', () => {
render(<SecurityScoreDisplay score={85} />);
expect(screen.getByText('85')).toBeInTheDocument();
expect(screen.getByText('/100')).toBeInTheDocument();
});
it('should render small size variant', () => {
render(<SecurityScoreDisplay score={50} size="sm" showDetails={false} />);
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('Security Score')).not.toBeInTheDocument();
});
it('should show correct color for high score', () => {
const { container } = render(<SecurityScoreDisplay score={85} maxScore={100} />);
const scoreElement = container.querySelector('.text-green-600');
expect(scoreElement).toBeInTheDocument();
});
it('should show correct color for medium score', () => {
const { container } = render(<SecurityScoreDisplay score={60} maxScore={100} />);
const scoreElement = container.querySelector('.text-yellow-600');
expect(scoreElement).toBeInTheDocument();
});
it('should show correct color for low score', () => {
const { container } = render(<SecurityScoreDisplay score={30} maxScore={100} />);
const scoreElement = container.querySelector('.text-red-600');
expect(scoreElement).toBeInTheDocument();
});
it('should display breakdown when provided', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
expect(screen.getByText('Score Breakdown by Category')).toBeInTheDocument();
});
it('should toggle breakdown visibility', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
const breakdownButton = screen.getByText('Score Breakdown by Category');
expect(screen.queryByText('HSTS')).not.toBeInTheDocument();
fireEvent.click(breakdownButton);
expect(screen.getByText('HSTS')).toBeInTheDocument();
});
it('should display suggestions when provided', () => {
render(
<SecurityScoreDisplay
score={50}
suggestions={mockSuggestions}
showDetails={true}
/>
);
expect(screen.getByText(/Security Suggestions \(2\)/)).toBeInTheDocument();
});
it('should toggle suggestions visibility', () => {
render(
<SecurityScoreDisplay
score={50}
suggestions={mockSuggestions}
showDetails={true}
/>
);
const suggestionsButton = screen.getByText(/Security Suggestions/);
expect(screen.queryByText('Enable HSTS to enforce HTTPS')).not.toBeInTheDocument();
fireEvent.click(suggestionsButton);
expect(screen.getByText('Enable HSTS to enforce HTTPS')).toBeInTheDocument();
});
it('should not show details when showDetails is false', () => {
render(
<SecurityScoreDisplay
score={75}
breakdown={mockBreakdown}
suggestions={mockSuggestions}
showDetails={false}
/>
);
expect(screen.queryByText('Score Breakdown by Category')).not.toBeInTheDocument();
expect(screen.queryByText('Security Suggestions')).not.toBeInTheDocument();
});
it('should display custom max score', () => {
render(<SecurityScoreDisplay score={40} maxScore={50} />);
expect(screen.getByText('40')).toBeInTheDocument();
expect(screen.getByText('/50')).toBeInTheDocument();
});
it('should calculate percentage correctly', () => {
render(<SecurityScoreDisplay score={75} maxScore={100} />);
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('should render all breakdown categories', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
fireEvent.click(screen.getByText('Score Breakdown by Category'));
expect(screen.getByText('HSTS')).toBeInTheDocument();
expect(screen.getByText('Content Security Policy')).toBeInTheDocument();
expect(screen.getByText('X-Frame-Options')).toBeInTheDocument();
expect(screen.getByText('X-Content-Type-Options')).toBeInTheDocument();
});
});

View File

@@ -1,42 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import SystemStatus from '../SystemStatus'
import * as systemApi from '../../api/system'
// Mock the API module
vi.mock('../../api/system', () => ({
checkUpdates: vi.fn(),
}))
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
)
}
describe('SystemStatus', () => {
it('calls checkUpdates on mount', async () => {
vi.mocked(systemApi.checkUpdates).mockResolvedValue({
available: false,
latest_version: '1.0.0',
changelog_url: '',
})
renderWithClient(<SystemStatus />)
await waitFor(() => {
expect(systemApi.checkUpdates).toHaveBeenCalled()
})
})
})

View File

@@ -1,260 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WebSocketStatusCard } from '../WebSocketStatusCard';
import * as websocketApi from '../../api/websocket';
// Mock the API functions
vi.mock('../../api/websocket');
// Mock date-fns to avoid timezone issues in tests
vi.mock('date-fns', () => ({
formatDistanceToNow: vi.fn(() => '5 minutes ago'),
}));
describe('WebSocketStatusCard', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
vi.clearAllMocks();
});
const renderComponent = (props = {}) => {
return render(
<QueryClientProvider client={queryClient}>
<WebSocketStatusCard {...props} />
</QueryClientProvider>
);
};
it('should render loading state', () => {
vi.mocked(websocketApi.getWebSocketConnections).mockReturnValue(
new Promise(() => {}) // Never resolves
);
vi.mocked(websocketApi.getWebSocketStats).mockReturnValue(
new Promise(() => {}) // Never resolves
);
renderComponent();
// Loading state shows skeleton elements
expect(screen.getAllByRole('generic').length).toBeGreaterThan(0);
});
it('should render with no active connections', async () => {
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
connections: [],
count: 0,
});
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
total_active: 0,
logs_connections: 0,
cerberus_connections: 0,
last_updated: '2024-01-15T10:10:00Z',
});
renderComponent();
await waitFor(() => {
expect(screen.getByText('WebSocket Connections')).toBeInTheDocument();
});
expect(screen.getByText('0 Active')).toBeInTheDocument();
expect(screen.getByText('No active WebSocket connections')).toBeInTheDocument();
});
it('should render with active connections', async () => {
const mockConnections = [
{
id: 'conn-1',
type: 'logs' as const,
connected_at: '2024-01-15T10:00:00Z',
last_activity_at: '2024-01-15T10:05:00Z',
remote_addr: '192.168.1.1:12345',
filters: 'level=error',
},
{
id: 'conn-2',
type: 'cerberus' as const,
connected_at: '2024-01-15T10:02:00Z',
last_activity_at: '2024-01-15T10:06:00Z',
remote_addr: '192.168.1.2:54321',
filters: 'source=waf',
},
];
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
connections: mockConnections,
count: 2,
});
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
total_active: 2,
logs_connections: 1,
cerberus_connections: 1,
oldest_connection: '2024-01-15T10:00:00Z',
last_updated: '2024-01-15T10:10:00Z',
});
renderComponent();
await waitFor(() => {
expect(screen.getByText('WebSocket Connections')).toBeInTheDocument();
});
expect(screen.getByText('2 Active')).toBeInTheDocument();
expect(screen.getByText('General Logs')).toBeInTheDocument();
expect(screen.getByText('Security Logs')).toBeInTheDocument();
// Use getAllByText since we have two "1" values
const ones = screen.getAllByText('1');
expect(ones).toHaveLength(2);
});
it('should show details when expanded', async () => {
const mockConnections = [
{
id: 'conn-123',
type: 'logs' as const,
connected_at: '2024-01-15T10:00:00Z',
last_activity_at: '2024-01-15T10:05:00Z',
remote_addr: '192.168.1.1:12345',
filters: 'level=error',
},
];
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
connections: mockConnections,
count: 1,
});
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
total_active: 1,
logs_connections: 1,
cerberus_connections: 0,
last_updated: '2024-01-15T10:10:00Z',
});
renderComponent({ showDetails: true });
await waitFor(() => {
expect(screen.getByText('WebSocket Connections')).toBeInTheDocument();
});
// Check for connection details
expect(screen.getByText('Active Connections')).toBeInTheDocument();
expect(screen.getByText(/conn-123/i)).toBeInTheDocument();
expect(screen.getByText('192.168.1.1:12345')).toBeInTheDocument();
expect(screen.getByText('level=error')).toBeInTheDocument();
});
it('should toggle details on button click', async () => {
const user = userEvent.setup();
const mockConnections = [
{
id: 'conn-1',
type: 'logs' as const,
connected_at: '2024-01-15T10:00:00Z',
last_activity_at: '2024-01-15T10:05:00Z',
},
];
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
connections: mockConnections,
count: 1,
});
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
total_active: 1,
logs_connections: 1,
cerberus_connections: 0,
last_updated: '2024-01-15T10:10:00Z',
});
renderComponent();
await waitFor(() => {
expect(screen.getByText('Show Details')).toBeInTheDocument();
});
// Initially hidden
expect(screen.queryByText('Active Connections')).not.toBeInTheDocument();
// Click to show
await user.click(screen.getByText('Show Details'));
await waitFor(() => {
expect(screen.getByText('Active Connections')).toBeInTheDocument();
});
// Click to hide
await user.click(screen.getByText('Hide Details'));
await waitFor(() => {
expect(screen.queryByText('Active Connections')).not.toBeInTheDocument();
});
});
it('should handle API errors gracefully', async () => {
vi.mocked(websocketApi.getWebSocketConnections).mockRejectedValue(
new Error('API Error')
);
vi.mocked(websocketApi.getWebSocketStats).mockRejectedValue(
new Error('API Error')
);
renderComponent();
await waitFor(() => {
expect(screen.getByText('Unable to load WebSocket status')).toBeInTheDocument();
});
});
it('should display oldest connection when available', async () => {
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
connections: [],
count: 1,
});
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
total_active: 1,
logs_connections: 1,
cerberus_connections: 0,
oldest_connection: '2024-01-15T09:55:00Z',
last_updated: '2024-01-15T10:10:00Z',
});
renderComponent();
await waitFor(() => {
expect(screen.getByText('Oldest Connection')).toBeInTheDocument();
});
expect(screen.getByText('5 minutes ago')).toBeInTheDocument();
});
it('should apply custom className', async () => {
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
connections: [],
count: 0,
});
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
total_active: 0,
logs_connections: 0,
cerberus_connections: 0,
last_updated: '2024-01-15T10:10:00Z',
});
const { container } = renderComponent({ className: 'custom-class' });
await waitFor(() => {
expect(screen.getByText('WebSocket Connections')).toBeInTheDocument();
});
const card = container.querySelector('.custom-class');
expect(card).toBeInTheDocument();
});
});

View File

@@ -1,117 +0,0 @@
import { AlertTriangle } from 'lucide-react'
interface CertificateCleanupDialogProps {
onConfirm: (deleteCerts: boolean) => void
onCancel: () => void
certificates: Array<{ id: number; name: string; domain: string }>
hostNames: string[]
isBulk?: boolean
}
export default function CertificateCleanupDialog({
onConfirm,
onCancel,
certificates,
hostNames,
isBulk = false
}: CertificateCleanupDialogProps) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const deleteCerts = formData.get('delete_certs') === 'on'
onConfirm(deleteCerts)
}
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onCancel}
>
<div
className="bg-dark-card border border-orange-900/50 rounded-lg p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={handleSubmit}>
<div className="flex items-start gap-3 mb-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-orange-900/30 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-orange-400" />
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-white">
Delete {isBulk ? `${hostNames.length} Proxy Hosts` : 'Proxy Host'}?
</h2>
<p className="text-sm text-gray-400 mt-1">
{isBulk ? 'These hosts will be permanently deleted.' : 'This host will be permanently deleted.'}
</p>
</div>
</div>
{/* Host names */}
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-4 mb-4">
<p className="text-xs font-medium text-gray-400 uppercase mb-2">
{isBulk ? 'Hosts to be deleted:' : 'Host to be deleted:'}
</p>
<ul className="space-y-1 max-h-32 overflow-y-auto">
{hostNames.map((name, idx) => (
<li key={idx} className="text-sm text-white flex items-center gap-2">
<span className="text-red-400"></span>
<span className="font-medium">{name}</span>
</li>
))}
</ul>
</div>
{/* Certificate cleanup option */}
{certificates.length > 0 && (
<div className="bg-orange-900/10 border border-orange-800/50 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="delete_certs"
name="delete_certs"
className="mt-1 w-4 h-4 rounded border-gray-600 text-orange-500 focus:ring-orange-500 focus:ring-offset-0 bg-gray-700"
/>
<div className="flex-1">
<label htmlFor="delete_certs" className="text-sm text-orange-300 font-medium cursor-pointer">
Also delete {certificates.length === 1 ? 'orphaned certificate' : `${certificates.length} orphaned certificates`}
</label>
<p className="text-xs text-gray-400 mt-1">
{certificates.length === 1
? 'This custom/staging certificate will no longer be used by any hosts.'
: 'These custom/staging certificates will no longer be used by any hosts.'}
</p>
<ul className="mt-2 space-y-1">
{certificates.map((cert) => (
<li key={cert.id} className="text-xs text-gray-300 flex items-center gap-2">
<span className="text-orange-400"></span>
<span className="font-medium">{cert.name || cert.domain}</span>
<span className="text-gray-500">({cert.domain})</span>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Confirmation buttons */}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Delete
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,143 +0,0 @@
import { CheckCircle, Plus, RefreshCw, SkipForward, AlertCircle, Info } from 'lucide-react'
export interface ImportSuccessModalProps {
visible: boolean
onClose: () => void
onNavigateDashboard: () => void
onNavigateHosts: () => void
results: {
created: number
updated: number
skipped: number
errors: string[]
} | null
}
export default function ImportSuccessModal({
visible,
onClose,
onNavigateDashboard,
onNavigateHosts,
results,
}: ImportSuccessModalProps) {
if (!visible || !results) return null
const { created, updated, skipped, errors } = results
const hasErrors = errors.length > 0
const totalProcessed = created + updated + skipped
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="import-success-modal">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-dark-card rounded-lg p-6 w-[500px] max-w-full mx-4 border border-gray-800">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-green-900/30 flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-green-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Import Completed</h2>
<p className="text-sm text-gray-400">
{totalProcessed} host{totalProcessed !== 1 ? 's' : ''} processed
</p>
</div>
</div>
{/* Results Summary */}
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-4 mb-4 space-y-3">
{created > 0 && (
<div className="flex items-center gap-3">
<Plus className="h-4 w-4 text-green-400" />
<span className="text-sm text-white">
<span className="font-medium text-green-400">{created}</span> host{created !== 1 ? 's' : ''} created
</span>
</div>
)}
{updated > 0 && (
<div className="flex items-center gap-3">
<RefreshCw className="h-4 w-4 text-blue-400" />
<span className="text-sm text-white">
<span className="font-medium text-blue-400">{updated}</span> host{updated !== 1 ? 's' : ''} updated
</span>
</div>
)}
{skipped > 0 && (
<div className="flex items-center gap-3">
<SkipForward className="h-4 w-4 text-gray-400" />
<span className="text-sm text-white">
<span className="font-medium text-gray-400">{skipped}</span> host{skipped !== 1 ? 's' : ''} skipped
</span>
</div>
)}
{totalProcessed === 0 && (
<div className="flex items-center gap-3">
<Info className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">No hosts were processed</span>
</div>
)}
</div>
{/* Errors Section */}
{hasErrors && (
<div className="bg-red-900/20 border border-red-800/50 rounded-lg p-4 mb-4">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-red-400" />
<span className="text-sm font-medium text-red-400">
{errors.length} error{errors.length !== 1 ? 's' : ''} encountered
</span>
</div>
<ul className="space-y-1 max-h-24 overflow-y-auto">
{errors.map((error, idx) => (
<li key={idx} className="text-xs text-red-300 flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
)}
{/* Certificate Provisioning Info */}
{created > 0 && (
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-blue-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-300">Certificate Provisioning</p>
<p className="text-xs text-gray-400 mt-1">
SSL certificates will be automatically provisioned by Let's Encrypt.
This typically takes 1-5 minutes per domain.
</p>
<p className="text-xs text-gray-400 mt-2">
Monitor the <span className="text-blue-400">Dashboard</span> to track certificate provisioning progress.
</p>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-3 justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
Close
</button>
<button
onClick={onNavigateHosts}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors border border-gray-700"
>
View Proxy Hosts
</button>
<button
onClick={onNavigateDashboard}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Go to Dashboard
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,154 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import ImportSuccessModal from '../ImportSuccessModal'
describe('ImportSuccessModal', () => {
const defaultProps = {
visible: true,
onClose: vi.fn(),
onNavigateDashboard: vi.fn(),
onNavigateHosts: vi.fn(),
results: {
created: 5,
updated: 2,
skipped: 1,
errors: [],
},
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders import summary correctly', () => {
render(<ImportSuccessModal {...defaultProps} />)
expect(screen.getByText('Import Completed')).toBeInTheDocument()
expect(screen.getByText('8 hosts processed')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
expect(screen.getByText(/hosts created/)).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText(/hosts updated/)).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText(/host skipped/)).toBeInTheDocument()
})
it('displays certificate provisioning guidance when hosts are created', () => {
render(<ImportSuccessModal {...defaultProps} />)
expect(screen.getByText('Certificate Provisioning')).toBeInTheDocument()
expect(screen.getByText(/SSL certificates will be automatically provisioned/)).toBeInTheDocument()
expect(screen.getByText(/1-5 minutes per domain/)).toBeInTheDocument()
})
it('hides certificate provisioning guidance when no hosts are created', () => {
const props = {
...defaultProps,
results: { created: 0, updated: 2, skipped: 0, errors: [] },
}
render(<ImportSuccessModal {...props} />)
expect(screen.queryByText('Certificate Provisioning')).not.toBeInTheDocument()
})
it('shows errors when present', () => {
const props = {
...defaultProps,
results: {
created: 0,
updated: 0,
skipped: 0,
errors: ['example.com: duplicate entry', 'api.example.com: invalid config'],
},
}
render(<ImportSuccessModal {...props} />)
expect(screen.getByText('2 errors encountered')).toBeInTheDocument()
expect(screen.getByText('example.com: duplicate entry')).toBeInTheDocument()
expect(screen.getByText('api.example.com: invalid config')).toBeInTheDocument()
})
it('calls onNavigateDashboard when clicking Dashboard button', () => {
const onNavigateDashboard = vi.fn()
render(<ImportSuccessModal {...defaultProps} onNavigateDashboard={onNavigateDashboard} />)
fireEvent.click(screen.getByText('Go to Dashboard'))
expect(onNavigateDashboard).toHaveBeenCalledTimes(1)
})
it('calls onNavigateHosts when clicking View Proxy Hosts button', () => {
const onNavigateHosts = vi.fn()
render(<ImportSuccessModal {...defaultProps} onNavigateHosts={onNavigateHosts} />)
fireEvent.click(screen.getByText('View Proxy Hosts'))
expect(onNavigateHosts).toHaveBeenCalledTimes(1)
})
it('calls onClose when clicking Close button', () => {
const onClose = vi.fn()
render(<ImportSuccessModal {...defaultProps} onClose={onClose} />)
fireEvent.click(screen.getByText('Close'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('calls onClose when clicking backdrop', () => {
const onClose = vi.fn()
const { container } = render(<ImportSuccessModal {...defaultProps} onClose={onClose} />)
// Click the backdrop (the overlay behind the modal)
const backdrop = container.querySelector('.bg-black\\/60')
if (backdrop) {
fireEvent.click(backdrop)
}
expect(onClose).toHaveBeenCalledTimes(1)
})
it('does not render when visible is false', () => {
render(<ImportSuccessModal {...defaultProps} visible={false} />)
expect(screen.queryByText('Import Completed')).not.toBeInTheDocument()
})
it('does not render when results is null', () => {
render(<ImportSuccessModal {...defaultProps} results={null} />)
expect(screen.queryByText('Import Completed')).not.toBeInTheDocument()
})
it('handles singular grammar correctly for single host', () => {
const props = {
...defaultProps,
results: { created: 1, updated: 0, skipped: 0, errors: [] },
}
render(<ImportSuccessModal {...props} />)
expect(screen.getByText('1 host processed')).toBeInTheDocument()
expect(screen.getByText(/host created/)).toBeInTheDocument()
})
it('handles single error with correct grammar', () => {
const props = {
...defaultProps,
results: {
created: 0,
updated: 0,
skipped: 0,
errors: ['single error'],
},
}
render(<ImportSuccessModal {...props} />)
expect(screen.getByText('1 error encountered')).toBeInTheDocument()
})
it('shows message when no hosts were processed', () => {
const props = {
...defaultProps,
results: { created: 0, updated: 0, skipped: 0, errors: [] },
}
render(<ImportSuccessModal {...props} />)
expect(screen.getByText('No hosts were processed')).toBeInTheDocument()
})
})

View File

@@ -1,481 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
Copy,
Check,
Clock,
RefreshCw,
CheckCircle2,
XCircle,
AlertCircle,
Loader2,
Info,
} from 'lucide-react'
import { Button, Card, CardHeader, CardTitle, CardContent, Progress, Alert } from '../ui'
import { useChallengePoll, useManualChallengeMutations } from '../../hooks/useManualChallenge'
import type { ManualChallenge, ChallengeStatus } from '../../api/manualChallenge'
import { toast } from '../../utils/toast'
interface ManualDNSChallengeProps {
/** The DNS provider ID */
providerId: number
/** Initial challenge data */
challenge: ManualChallenge
/** Callback when challenge is completed or cancelled */
onComplete: (success: boolean) => void
/** Callback when challenge is cancelled */
onCancel: () => void
}
/** Maps challenge status to visual properties */
const STATUS_CONFIG: Record<
ChallengeStatus,
{
icon: typeof CheckCircle2
colorClass: string
labelKey: string
}
> = {
created: {
icon: Clock,
colorClass: 'text-content-muted',
labelKey: 'dnsProvider.manual.status.created',
},
pending: {
icon: Clock,
colorClass: 'text-yellow-500',
labelKey: 'dnsProvider.manual.status.pending',
},
verifying: {
icon: Loader2,
colorClass: 'text-brand-500',
labelKey: 'dnsProvider.manual.status.verifying',
},
verified: {
icon: CheckCircle2,
colorClass: 'text-success',
labelKey: 'dnsProvider.manual.status.verified',
},
expired: {
icon: XCircle,
colorClass: 'text-error',
labelKey: 'dnsProvider.manual.status.expired',
},
failed: {
icon: AlertCircle,
colorClass: 'text-error',
labelKey: 'dnsProvider.manual.status.failed',
},
}
/** Terminal states where polling should stop */
const TERMINAL_STATES: ChallengeStatus[] = ['verified', 'expired', 'failed']
/**
* Formats seconds into MM:SS display format
*/
function formatTimeRemaining(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
/**
* Calculates progress percentage based on time elapsed
*/
function calculateProgress(expiresAt: string, createdAt: string): number {
const now = Date.now()
const created = new Date(createdAt).getTime()
const expires = new Date(expiresAt).getTime()
const total = expires - created
const elapsed = now - created
const remaining = Math.max(0, 100 - (elapsed / total) * 100)
return Math.round(remaining)
}
/**
* Calculate time remaining in seconds
*/
function getTimeRemainingSeconds(expiresAt: string): number {
const now = Date.now()
const expires = new Date(expiresAt).getTime()
return Math.max(0, Math.floor((expires - now) / 1000))
}
export default function ManualDNSChallenge({
providerId,
challenge,
onComplete,
onCancel,
}: ManualDNSChallengeProps) {
const { t } = useTranslation()
const [copiedField, setCopiedField] = useState<'name' | 'value' | null>(null)
const [timeRemaining, setTimeRemaining] = useState(() =>
getTimeRemainingSeconds(challenge.expires_at)
)
const [progress, setProgress] = useState(() =>
calculateProgress(challenge.expires_at, challenge.created_at)
)
const statusAnnouncerRef = useRef<HTMLDivElement>(null)
const previousStatusRef = useRef<ChallengeStatus>(challenge.status)
// Determine if challenge is in a terminal state
const isTerminal = TERMINAL_STATES.includes(challenge.status)
// Poll for status updates (every 10 seconds when not terminal)
const { data: pollData } = useChallengePoll(
providerId,
challenge.id,
!isTerminal,
10000
)
const { verifyMutation, deleteMutation } = useManualChallengeMutations()
// Current status from poll data or initial challenge
const currentStatus: ChallengeStatus = pollData?.status || challenge.status
const dnsPropagated = pollData?.dns_propagated ?? challenge.dns_propagated
const lastCheckAt = pollData?.last_check_at ?? challenge.last_check_at
// Update countdown timer
useEffect(() => {
if (isTerminal) return
const interval = setInterval(() => {
const remaining = getTimeRemainingSeconds(challenge.expires_at)
setTimeRemaining(remaining)
setProgress(calculateProgress(challenge.expires_at, challenge.created_at))
// Auto-expire if time runs out
if (remaining <= 0) {
clearInterval(interval)
}
}, 1000)
return () => clearInterval(interval)
}, [challenge.expires_at, challenge.created_at, isTerminal])
// Announce status changes to screen readers
useEffect(() => {
if (currentStatus !== previousStatusRef.current) {
previousStatusRef.current = currentStatus
const statusLabel = t(STATUS_CONFIG[currentStatus].labelKey)
// Announce the status change for screen readers
if (statusAnnouncerRef.current) {
statusAnnouncerRef.current.textContent = t('dnsProvider.manual.statusChanged', {
status: statusLabel,
})
}
// Show toast for terminal states
if (currentStatus === 'verified') {
toast.success(t('dnsProvider.manual.verifySuccess'))
onComplete(true)
} else if (currentStatus === 'expired') {
toast.error(t('dnsProvider.manual.challengeExpired'))
onComplete(false)
} else if (currentStatus === 'failed') {
toast.error(pollData?.error_message || t('dnsProvider.manual.verifyFailed'))
onComplete(false)
}
}
}, [currentStatus, pollData?.error_message, onComplete, t])
// Copy to clipboard handler
const handleCopy = useCallback(
async (field: 'name' | 'value', text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedField(field)
toast.success(t('dnsProvider.manual.copied'))
// Reset copied state after 2 seconds
setTimeout(() => setCopiedField(null), 2000)
} catch {
toast.error(t('dnsProvider.manual.copyFailed'))
}
},
[t]
)
// Verify challenge handler
const handleVerify = useCallback(async () => {
try {
const result = await verifyMutation.mutateAsync({
providerId,
challengeId: challenge.id,
})
if (result.success) {
toast.success(t('dnsProvider.manual.verifySuccess'))
} else if (!result.dns_found) {
toast.warning(t('dnsProvider.manual.dnsNotFound'))
}
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } }
toast.error(err.response?.data?.message || t('dnsProvider.manual.verifyFailed'))
}
}, [verifyMutation, providerId, challenge.id, t])
// Cancel challenge handler
const handleCancel = useCallback(async () => {
try {
await deleteMutation.mutateAsync({
providerId,
challengeId: challenge.id,
})
toast.info(t('dnsProvider.manual.challengeCancelled'))
onCancel()
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } }
toast.error(err.response?.data?.message || t('dnsProvider.manual.cancelFailed'))
}
}, [deleteMutation, providerId, challenge.id, onCancel, t])
// Get status display properties
const statusConfig = STATUS_CONFIG[currentStatus]
const StatusIcon = statusConfig.icon
// Format last check time
const getLastCheckText = (): string => {
if (!lastCheckAt) return ''
const seconds = Math.floor((Date.now() - new Date(lastCheckAt).getTime()) / 1000)
if (seconds < 60) return t('dnsProvider.manual.lastCheckSecondsAgo', { seconds })
const minutes = Math.floor(seconds / 60)
return t('dnsProvider.manual.lastCheckMinutesAgo', { minutes })
}
return (
<Card className="w-full max-w-2xl">
{/* Screen reader announcer for status changes */}
<div
ref={statusAnnouncerRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span aria-hidden="true">🔐</span>
{t('dnsProvider.manual.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Instructions */}
<p className="text-content-secondary">
{t('dnsProvider.manual.instructions', { domain: challenge.fqdn.replace('_acme-challenge.', '') })}
</p>
{/* DNS Record Details */}
<div
className="border border-border rounded-lg overflow-hidden"
role="region"
aria-labelledby="dns-record-heading"
>
<div className="bg-surface-subtle px-4 py-2 border-b border-border">
<h3 id="dns-record-heading" className="text-sm font-medium flex items-center gap-2">
<span aria-hidden="true">📋</span>
{t('dnsProvider.manual.createRecord')}
</h3>
</div>
{/* Record Name */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<label
htmlFor="record-name"
className="block text-xs font-medium text-content-muted mb-1"
>
{t('dnsProvider.manual.recordName')}
</label>
<code
id="record-name"
className="block text-sm font-mono text-content-primary truncate"
title={challenge.fqdn}
>
{challenge.fqdn}
</code>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy('name', challenge.fqdn)}
aria-label={t('dnsProvider.manual.copyRecordName')}
className="flex-shrink-0"
>
{copiedField === 'name' ? (
<Check className="h-4 w-4 text-success" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{copiedField === 'name' ? t('dnsProvider.manual.copied') : t('dnsProvider.manual.copy')}
</span>
</Button>
</div>
</div>
{/* Record Value */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<label
htmlFor="record-value"
className="block text-xs font-medium text-content-muted mb-1"
>
{t('dnsProvider.manual.recordValue')}
</label>
<code
id="record-value"
className="block text-sm font-mono text-content-primary truncate"
title={challenge.value}
>
{challenge.value}
</code>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy('value', challenge.value)}
aria-label={t('dnsProvider.manual.copyRecordValue')}
className="flex-shrink-0"
>
{copiedField === 'value' ? (
<Check className="h-4 w-4 text-success" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{copiedField === 'value' ? t('dnsProvider.manual.copied') : t('dnsProvider.manual.copy')}
</span>
</Button>
</div>
</div>
{/* TTL */}
<div className="px-4 py-3 bg-surface-subtle/50">
<span className="text-sm text-content-muted">
{t('dnsProvider.manual.ttl')}: {challenge.ttl} {t('dnsProvider.manual.seconds')} ({Math.floor(challenge.ttl / 60)} {t('dnsProvider.manual.minutes')})
</span>
</div>
</div>
{/* Progress Section */}
{!isTerminal && (
<div className="space-y-2" role="region" aria-labelledby="time-remaining-heading">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-content-muted" aria-hidden="true" />
<span id="time-remaining-heading" className="text-sm font-medium">
{t('dnsProvider.manual.timeRemaining')}: {formatTimeRemaining(timeRemaining)}
</span>
</div>
<span
className="text-sm text-content-muted tabular-nums"
aria-label={t('dnsProvider.manual.progressPercent', { percent: progress })}
>
{progress}%
</span>
</div>
<Progress
value={progress}
variant={progress < 25 ? 'error' : progress < 50 ? 'warning' : 'default'}
aria-label={t('dnsProvider.manual.challengeProgress')}
/>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-3">
<Button
variant="secondary"
onClick={handleVerify}
disabled={isTerminal || verifyMutation.isPending}
isLoading={verifyMutation.isPending}
leftIcon={RefreshCw}
aria-describedby="check-dns-description"
>
{t('dnsProvider.manual.checkDnsNow')}
</Button>
<span id="check-dns-description" className="sr-only">
{t('dnsProvider.manual.checkDnsDescription')}
</span>
<Button
variant="primary"
onClick={handleVerify}
disabled={isTerminal || verifyMutation.isPending}
isLoading={verifyMutation.isPending}
leftIcon={CheckCircle2}
aria-describedby="verify-description"
>
{t('dnsProvider.manual.verifyButton')}
</Button>
<span id="verify-description" className="sr-only">
{t('dnsProvider.manual.verifyDescription')}
</span>
</div>
{/* Status Indicator */}
<Alert
variant={
currentStatus === 'verified'
? 'success'
: currentStatus === 'failed' || currentStatus === 'expired'
? 'error'
: 'info'
}
>
<div className="flex items-start gap-3">
<StatusIcon
className={`h-5 w-5 flex-shrink-0 ${statusConfig.colorClass} ${
currentStatus === 'verifying' ? 'animate-spin' : ''
}`}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<p className="font-medium">
{t(`dnsProvider.manual.statusMessage.${currentStatus}`, {
defaultValue: t(statusConfig.labelKey),
})}
</p>
{lastCheckAt && !isTerminal && (
<p className="text-sm text-content-muted mt-1">
{t('dnsProvider.manual.lastCheck')}: {getLastCheckText()}
</p>
)}
{!dnsPropagated && !isTerminal && (
<p className="text-sm text-content-muted mt-1 flex items-center gap-1">
<Info className="h-3 w-3" aria-hidden="true" />
{t('dnsProvider.manual.notPropagated')}
</p>
)}
{pollData?.error_message && (
<p className="text-sm text-error mt-1">{pollData.error_message}</p>
)}
</div>
</div>
</Alert>
{/* Cancel Button */}
{!isTerminal && (
<div className="flex justify-end pt-2 border-t border-border">
<Button
variant="ghost"
onClick={handleCancel}
disabled={deleteMutation.isPending}
isLoading={deleteMutation.isPending}
>
{t('dnsProvider.manual.cancelChallenge')}
</Button>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1 +0,0 @@
export { default as ManualDNSChallenge } from './ManualDNSChallenge'

View File

@@ -1,47 +0,0 @@
import * as React from 'react'
import { cn } from '../../utils/cn'
export interface PageShellProps {
title: string
description?: string
actions?: React.ReactNode
children: React.ReactNode
className?: string
}
/**
* PageShell - Consistent page wrapper component
*
* Provides standardized page layout with:
* - Title (h1, text-2xl font-bold)
* - Optional description (text-sm text-content-secondary)
* - Optional actions slot for buttons
* - Responsive flex layout (column on mobile, row on desktop)
* - Consistent page spacing
*/
export function PageShell({
title,
description,
actions,
children,
className,
}: PageShellProps) {
return (
<div className={cn('space-y-6', className)}>
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-bold text-content-primary truncate">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-content-secondary">{description}</p>
)}
</div>
{actions && (
<div className="flex shrink-0 items-center gap-3">{actions}</div>
)}
</header>
{children}
</div>
)
}

View File

@@ -1,3 +0,0 @@
// Layout Components - Barrel Exports
export { PageShell, type PageShellProps } from './PageShell'

View File

@@ -1,125 +0,0 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
import {
Info,
CheckCircle,
AlertTriangle,
XCircle,
X,
type LucideIcon,
} from 'lucide-react'
const alertVariants = cva(
'relative flex gap-3 p-4 rounded-lg border transition-all duration-normal',
{
variants: {
variant: {
default: 'bg-surface-subtle border-border text-content-primary',
info: 'bg-info-muted border-info/30 text-content-primary',
success: 'bg-success-muted border-success/30 text-content-primary',
warning: 'bg-warning-muted border-warning/30 text-content-primary',
error: 'bg-error-muted border-error/30 text-content-primary',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const iconMap: Record<string, LucideIcon> = {
default: Info,
info: Info,
success: CheckCircle,
warning: AlertTriangle,
error: XCircle,
}
const iconColorMap: Record<string, string> = {
default: 'text-content-muted',
info: 'text-info',
success: 'text-success',
warning: 'text-warning',
error: 'text-error',
}
export interface AlertProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof alertVariants> {
title?: string
icon?: LucideIcon
dismissible?: boolean
onDismiss?: () => void
}
export function Alert({
className,
variant = 'default',
title,
icon,
dismissible = false,
onDismiss,
children,
...props
}: AlertProps) {
const [isVisible, setIsVisible] = React.useState(true)
if (!isVisible) return null
const IconComponent = icon || iconMap[variant || 'default']
const iconColor = iconColorMap[variant || 'default']
const handleDismiss = () => {
setIsVisible(false)
onDismiss?.()
}
return (
<div
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
>
<IconComponent className={cn('h-5 w-5 flex-shrink-0 mt-0.5', iconColor)} />
<div className="flex-1 min-w-0">
{title && (
<h5 className="font-semibold text-sm mb-1">{title}</h5>
)}
<div className="text-sm text-content-secondary">{children}</div>
</div>
{dismissible && (
<button
type="button"
onClick={handleDismiss}
className="flex-shrink-0 p-1 rounded-md text-content-muted hover:text-content-primary hover:bg-surface-muted transition-colors duration-fast"
aria-label="Dismiss alert"
>
<X className="h-4 w-4" />
</button>
)}
</div>
)
}
export type AlertTitleProps = React.HTMLAttributes<HTMLHeadingElement>
export function AlertTitle({ className, ...props }: AlertTitleProps) {
return (
<h5
className={cn('font-semibold text-sm mb-1', className)}
{...props}
/>
)
}
export type AlertDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>
export function AlertDescription({ className, ...props }: AlertDescriptionProps) {
return (
<p
className={cn('text-sm text-content-secondary', className)}
{...props}
/>
)
}

View File

@@ -1,42 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
const badgeVariants = cva(
'inline-flex items-center justify-center font-medium transition-colors duration-fast',
{
variants: {
variant: {
default: 'bg-surface-muted text-content-primary border border-border',
primary: 'bg-brand-500 text-white',
success: 'bg-success text-white',
warning: 'bg-warning text-content-inverted',
destructive: 'bg-error text-white',
error: 'bg-error text-white',
secondary: 'bg-surface-muted text-content-secondary border border-border',
outline: 'border border-border text-content-secondary bg-transparent',
},
size: {
sm: 'text-xs px-2 py-0.5 rounded',
md: 'text-sm px-2.5 py-0.5 rounded-md',
lg: 'text-base px-3 py-1 rounded-lg',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, size, ...props }: BadgeProps) {
return (
<span
className={cn(badgeVariants({ variant, size }), className)}
{...props}
/>
)
}

View File

@@ -1,111 +0,0 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Loader2, type LucideIcon } from 'lucide-react'
import { cn } from '../../utils/cn'
const buttonVariants = cva(
[
'inline-flex items-center justify-center gap-2',
'rounded-lg font-medium',
'transition-all duration-fast',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none',
],
{
variants: {
variant: {
primary: [
'bg-brand-500 text-white',
'hover:bg-brand-600',
'focus-visible:ring-brand-500',
'active:bg-brand-700',
],
secondary: [
'bg-surface-muted text-content-primary',
'hover:bg-surface-subtle',
'focus-visible:ring-content-muted',
'active:bg-surface-base',
],
danger: [
'bg-error text-white',
'hover:bg-error/90',
'focus-visible:ring-error',
'active:bg-error/80',
],
ghost: [
'text-content-secondary bg-transparent',
'hover:bg-surface-muted hover:text-content-primary',
'focus-visible:ring-content-muted',
],
outline: [
'border border-border bg-transparent text-content-primary',
'hover:bg-surface-subtle hover:border-border-strong',
'focus-visible:ring-brand-500',
],
link: [
'text-brand-500 bg-transparent underline-offset-4',
'hover:underline hover:text-brand-400',
'focus-visible:ring-brand-500',
'p-0 h-auto',
],
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10 p-0',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
leftIcon?: LucideIcon
rightIcon?: LucideIcon
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
isLoading = false,
leftIcon: LeftIcon,
rightIcon: RightIcon,
disabled,
children,
...props
},
ref
) => {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
LeftIcon && <LeftIcon className="h-4 w-4" />
)}
{children}
{!isLoading && RightIcon && <RightIcon className="h-4 w-4" />}
</button>
)
}
)
Button.displayName = 'Button'
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }

View File

@@ -1,102 +0,0 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
const cardVariants = cva(
'rounded-lg border border-border bg-surface-elevated overflow-hidden transition-all duration-normal',
{
variants: {
variant: {
default: '',
interactive: [
'cursor-pointer',
'hover:shadow-lg hover:border-border-strong',
'active:shadow-md',
],
compact: 'p-0',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
className={cn(cardVariants({ variant }), className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6 pb-4', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-lg font-semibold leading-tight text-content-primary',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-content-secondary', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex items-center p-6 pt-0 border-t border-border bg-surface-subtle/50 mt-4 -mx-px -mb-px rounded-b-lg',
className
)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }

View File

@@ -1,46 +0,0 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check, Minus } from 'lucide-react'
import { cn } from '../../utils/cn'
export interface CheckboxProps
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {
indeterminate?: boolean
}
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
CheckboxProps
>(({ className, indeterminate, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded',
'border border-border',
'bg-surface-base',
'ring-offset-surface-base',
'transition-colors duration-fast',
'hover:border-brand-400',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500 data-[state=checked]:text-white',
'data-[state=indeterminate]:bg-brand-500 data-[state=indeterminate]:border-brand-500 data-[state=indeterminate]:text-white',
className
)}
checked={indeterminate ? 'indeterminate' : props.checked}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
{indeterminate ? (
<Minus className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -1,247 +0,0 @@
import * as React from 'react'
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { cn } from '../../utils/cn'
import { Checkbox } from './Checkbox'
export interface Column<T> {
key: string
header: string
cell: (row: T) => React.ReactNode
sortable?: boolean
width?: string
}
export interface DataTableProps<T> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
data: T[]
columns: Column<T>[]
rowKey: (row: T) => string
selectable?: boolean
selectedKeys?: Set<string>
onSelectionChange?: (keys: Set<string>) => void
onRowClick?: (row: T) => void
emptyState?: React.ReactNode
isLoading?: boolean
stickyHeader?: boolean
}
/**
* DataTable - Reusable data table component
*
* Features:
* - Generic type <T> for row data
* - Sortable columns with chevron icons
* - Row selection with Checkbox component
* - Sticky header support
* - Row hover states
* - Selected row highlighting
* - Empty state slot
* - Responsive horizontal scroll
*/
export function DataTable<T>({
data,
columns,
rowKey,
selectable = false,
selectedKeys = new Set(),
onSelectionChange,
onRowClick,
emptyState,
isLoading = false,
stickyHeader = false,
className,
...props
}: DataTableProps<T>) {
const [sortConfig, setSortConfig] = React.useState<{
key: string
direction: 'asc' | 'desc'
} | null>(null)
const handleSort = (key: string) => {
setSortConfig((prev) => {
if (prev?.key === key) {
if (prev.direction === 'asc') {
return { key, direction: 'desc' }
}
// Reset sort if clicking third time
return null
}
return { key, direction: 'asc' }
})
}
const handleSelectAll = () => {
if (!onSelectionChange) return
if (selectedKeys.size === data.length) {
// All selected, deselect all
onSelectionChange(new Set())
} else {
// Select all
onSelectionChange(new Set(data.map(rowKey)))
}
}
const handleSelectRow = (key: string) => {
if (!onSelectionChange) return
const newKeys = new Set(selectedKeys)
if (newKeys.has(key)) {
newKeys.delete(key)
} else {
newKeys.add(key)
}
onSelectionChange(newKeys)
}
const allSelected = data.length > 0 && selectedKeys.size === data.length
const someSelected = selectedKeys.size > 0 && selectedKeys.size < data.length
const colSpan = columns.length + (selectable ? 1 : 0)
return (
<div
className={cn(
'rounded-xl border border-border overflow-hidden',
className
)}
{...props}
>
<div className="overflow-x-auto">
<table className="w-full">
<thead
className={cn(
'bg-surface-subtle border-b border-border',
stickyHeader && 'sticky top-0 z-10'
)}
>
<tr>
{selectable && (
<th className="w-12 px-4 py-3">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onCheckedChange={handleSelectAll}
aria-label="Select all rows"
/>
</th>
)}
{columns.map((col) => (
<th
key={col.key}
className={cn(
'px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-content-secondary',
col.sortable &&
'cursor-pointer select-none hover:text-content-primary transition-colors'
)}
style={{ width: col.width }}
onClick={() => col.sortable && handleSort(col.key)}
role={col.sortable ? 'button' : undefined}
tabIndex={col.sortable ? 0 : undefined}
onKeyDown={(e) => {
if (col.sortable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
handleSort(col.key)
}
}}
aria-sort={
sortConfig?.key === col.key
? sortConfig.direction === 'asc'
? 'ascending'
: 'descending'
: undefined
}
>
<div className="flex items-center gap-1">
<span>{col.header}</span>
{col.sortable && (
<span className="text-content-muted">
{sortConfig?.key === col.key ? (
sortConfig.direction === 'asc' ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)
) : (
<ChevronsUpDown className="h-4 w-4 opacity-50" />
)}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border bg-surface-elevated">
{isLoading ? (
<tr>
<td colSpan={colSpan} className="px-6 py-12">
<div className="flex justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
</div>
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={colSpan} className="px-6 py-12">
{emptyState || (
<div className="text-center text-content-muted">
No data available
</div>
)}
</td>
</tr>
) : (
data.map((row) => {
const key = rowKey(row)
const isSelected = selectedKeys.has(key)
return (
<tr
key={key}
className={cn(
'transition-colors',
isSelected && 'bg-brand-500/5',
onRowClick &&
'cursor-pointer hover:bg-surface-muted',
!onRowClick && 'hover:bg-surface-subtle'
)}
onClick={() => onRowClick?.(row)}
role={onRowClick ? 'button' : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={(e) => {
if (onRowClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onRowClick(row)
}
}}
>
{selectable && (
<td
className="w-12 px-4 py-4"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleSelectRow(key)}
aria-label={`Select row ${key}`}
/>
</td>
)}
{columns.map((col) => (
<td
key={col.key}
className="px-6 py-4 text-sm text-content-primary"
>
{col.cell(row)}
</td>
))}
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -1,142 +0,0 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '../../utils/cn'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
aria-describedby={undefined}
className={cn(
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
'bg-surface-elevated border border-border rounded-xl shadow-xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 p-1.5 rounded-md',
'text-content-muted hover:text-content-primary',
'hover:bg-surface-muted',
'transition-colors duration-fast',
'focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-elevated'
)}
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 px-6 pt-6 pb-4',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3',
'px-6 pb-6 pt-4',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold text-content-primary leading-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-content-secondary', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -1,71 +0,0 @@
import * as React from 'react'
import { cn } from '../../utils/cn'
import { Button, type ButtonProps } from './Button'
export interface EmptyStateAction {
label: string
onClick: () => void
variant?: ButtonProps['variant']
}
export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: React.ReactNode
title: string
description: string
action?: EmptyStateAction
secondaryAction?: EmptyStateAction
}
/**
* EmptyState - Empty state pattern component
*
* Features:
* - Centered content with dashed border
* - Icon in muted background circle
* - Primary and secondary action buttons
* - Uses Button component for actions
*/
export function EmptyState({
icon,
title,
description,
action,
secondaryAction,
className,
...props
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-16 px-6 text-center',
'rounded-xl border border-dashed border-border bg-surface-subtle/50',
className
)}
{...props}
>
{icon && (
<div className="mb-4 rounded-full bg-surface-muted p-4 text-content-muted">
{icon}
</div>
)}
<h3 className="text-lg font-semibold text-content-primary">{title}</h3>
<p className="mt-2 max-w-sm text-sm text-content-secondary">
{description}
</p>
{(action || secondaryAction) && (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
{action && (
<Button variant={action.variant || 'primary'} onClick={action.onClick}>
{action.label}
</Button>
)}
{secondaryAction && (
<Button variant="ghost" onClick={secondaryAction.onClick}>
{secondaryAction.label}
</Button>
)}
</div>
)}
</div>
)
}

View File

@@ -1,113 +0,0 @@
import * as React from 'react'
import { Eye, EyeOff, type LucideIcon } from 'lucide-react'
import { cn } from '../../utils/cn'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
errorTestId?: string
leftIcon?: LucideIcon
rightIcon?: LucideIcon
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
label,
error,
helperText,
errorTestId,
leftIcon: LeftIcon,
rightIcon: RightIcon,
className,
type,
disabled,
...props
},
ref
) => {
const [showPassword, setShowPassword] = React.useState(false)
const isPassword = type === 'password'
return (
<div className="w-full">
{label && (
<label
htmlFor={props.id}
className="block text-sm font-medium text-content-secondary mb-1.5"
>
{label}
</label>
)}
<div className="relative">
{LeftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
<LeftIcon className="h-4 w-4 text-content-muted" />
</div>
)}
<input
ref={ref}
type={isPassword ? (showPassword ? 'text' : 'password') : type}
disabled={disabled}
className={cn(
'flex h-10 w-full rounded-lg px-4 py-2',
'bg-surface-base border text-content-primary',
'text-sm placeholder:text-content-muted',
'transition-colors duration-fast',
error
? 'border-error focus:ring-error/20'
: 'border-border hover:border-border-strong focus:border-brand-500',
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border',
LeftIcon && 'pl-10',
(isPassword || RightIcon) && 'pr-10',
className
)}
{...props}
/>
{isPassword && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={cn(
'absolute right-3 top-1/2 -translate-y-1/2',
'text-content-muted hover:text-content-primary',
'focus:outline-none transition-colors duration-fast'
)}
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
)}
{!isPassword && RightIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<RightIcon className="h-4 w-4 text-content-muted" />
</div>
)}
</div>
{error && (
<p
className="mt-1.5 text-sm text-error"
data-testid={errorTestId}
role="alert"
>
{error}
</p>
)}
{helperText && !error && (
<p className="mt-1.5 text-sm text-content-muted">{helperText}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -1,45 +0,0 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
{
variants: {
variant: {
default: 'text-content-primary',
muted: 'text-content-muted',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement>,
VariantProps<typeof labelVariants> {
required?: boolean
}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, variant, required, children, ...props }, ref) => (
<label
ref={ref}
className={cn(labelVariants({ variant }), className)}
{...props}
>
{children}
{required && (
<span className="ml-1 text-error" aria-hidden="true">
*
</span>
)}
</label>
)
)
Label.displayName = 'Label'
// eslint-disable-next-line react-refresh/only-export-components
export { Label, labelVariants }

View File

@@ -1,32 +0,0 @@
import { forwardRef } from 'react';
import { cn } from '../../utils/cn';
export interface NativeSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
error?: boolean;
}
export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
({ className, error, ...props }, ref) => {
return (
<select
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between gap-2',
'rounded-lg border px-3 py-2',
'bg-surface-base text-content-primary text-sm',
'placeholder:text-content-muted',
'transition-colors duration-fast',
error
? 'border-error focus:ring-error'
: 'border-border hover:border-border-strong focus:border-brand-500',
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
);
}
);
NativeSelect.displayName = 'NativeSelect';

View File

@@ -1,56 +0,0 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
const progressVariants = cva(
'h-full w-full flex-1 transition-all duration-normal',
{
variants: {
variant: {
default: 'bg-brand-500',
success: 'bg-success',
warning: 'bg-warning',
error: 'bg-error',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface ProgressProps
extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>,
VariantProps<typeof progressVariants> {
showValue?: boolean
}
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
ProgressProps
>(({ className, value, variant, showValue = false, ...props }, ref) => (
<div className="flex items-center gap-3">
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-surface-muted',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(progressVariants({ variant }), 'rounded-full')}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
{showValue && (
<span className="text-sm font-medium text-content-secondary tabular-nums">
{Math.round(value || 0)}%
</span>
)}
</div>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -1,180 +0,0 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '../../utils/cn'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
error?: boolean
}
>(({ className, children, error, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between gap-2',
'rounded-lg border px-3 py-2',
'bg-surface-base text-content-primary text-sm',
'placeholder:text-content-muted',
'transition-colors duration-fast',
error
? 'border-error focus:ring-error'
: 'border-border hover:border-border-strong focus:border-brand-500',
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-content-muted flex-shrink-0" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4 text-content-muted" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4 text-content-muted" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden',
'rounded-lg border border-border',
'bg-surface-elevated text-content-primary shadow-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-content-muted', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-pointer select-none items-center',
'rounded-md py-2 pl-8 pr-2 text-sm',
'outline-none',
'focus:bg-surface-muted focus:text-content-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-brand-500" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -1,142 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
const skeletonVariants = cva(
'animate-pulse bg-surface-muted',
{
variants: {
variant: {
default: 'rounded-md',
circular: 'rounded-full',
text: 'rounded h-4',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface SkeletonProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof skeletonVariants> {}
export function Skeleton({ className, variant, ...props }: SkeletonProps) {
return (
<div
className={cn(skeletonVariants({ variant }), className)}
{...props}
/>
)
}
// Pre-built patterns
export interface SkeletonCardProps extends React.HTMLAttributes<HTMLDivElement> {
showImage?: boolean
lines?: number
}
export function SkeletonCard({
className,
showImage = true,
lines = 3,
...props
}: SkeletonCardProps) {
return (
<div
className={cn(
'rounded-lg border border-border bg-surface-elevated p-4 space-y-4',
className
)}
{...props}
>
{showImage && (
<Skeleton className="h-32 w-full rounded-md" />
)}
<div className="space-y-2">
<Skeleton className="h-5 w-3/4" />
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
className={cn(
'h-4',
i === lines - 1 ? 'w-1/2' : 'w-full'
)}
/>
))}
</div>
</div>
)
}
export interface SkeletonTableProps extends React.HTMLAttributes<HTMLDivElement> {
rows?: number
columns?: number
}
export function SkeletonTable({
className,
rows = 5,
columns = 4,
...props
}: SkeletonTableProps) {
return (
<div
className={cn('rounded-lg border border-border overflow-hidden', className)}
{...props}
>
{/* Header */}
<div className="flex gap-4 p-4 bg-surface-subtle border-b border-border">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} className="h-4 flex-1" />
))}
</div>
{/* Rows */}
<div className="divide-y divide-border">
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="flex gap-4 p-4">
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={colIndex}
className={cn(
'h-4 flex-1',
colIndex === 0 && 'w-1/4 flex-none'
)}
/>
))}
</div>
))}
</div>
</div>
)
}
export interface SkeletonListProps extends React.HTMLAttributes<HTMLDivElement> {
items?: number
showAvatar?: boolean
}
export function SkeletonList({
className,
items = 3,
showAvatar = true,
...props
}: SkeletonListProps) {
return (
<div className={cn('space-y-4', className)} {...props}>
{Array.from({ length: items }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
{showAvatar && (
<Skeleton variant="circular" className="h-10 w-10 flex-shrink-0" />
)}
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-2/3" />
</div>
</div>
))}
</div>
)
}

View File

@@ -1,108 +0,0 @@
import * as React from 'react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { cn } from '../../utils/cn'
export interface StatsCardChange {
value: number
trend: 'up' | 'down' | 'neutral'
label?: string
}
export interface StatsCardProps {
title: string
value: string | number
change?: StatsCardChange
icon?: React.ReactNode
href?: string
className?: string
}
/**
* StatsCard - KPI/metric card component
*
* Features:
* - Trend indicators with TrendingUp/TrendingDown/Minus icons
* - Color-coded trends (success for up, error for down, muted for neutral)
* - Interactive hover state when href is provided
* - Card styles (rounded-xl, border, shadow on hover)
*/
export function StatsCard({
title,
value,
change,
icon,
href,
className,
}: StatsCardProps) {
const isInteractive = Boolean(href)
const TrendIcon =
change?.trend === 'up'
? TrendingUp
: change?.trend === 'down'
? TrendingDown
: Minus
const trendColorClass =
change?.trend === 'up'
? 'text-success'
: change?.trend === 'down'
? 'text-error'
: 'text-content-muted'
const content = (
<>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-content-secondary truncate">
{title}
</p>
<p className="mt-2 text-3xl font-bold text-content-primary tabular-nums">
{value}
</p>
{change && (
<div
className={cn(
'mt-2 flex items-center gap-1 text-sm',
trendColorClass
)}
>
<TrendIcon className="h-4 w-4 shrink-0" />
<span className="font-medium">{change.value}%</span>
{change.label && (
<span className="text-content-muted truncate">
{change.label}
</span>
)}
</div>
)}
</div>
{icon && (
<div className="shrink-0 rounded-lg bg-brand-500/10 p-3 text-brand-500">
{icon}
</div>
)}
</div>
</>
)
const baseClasses = cn(
'block rounded-xl border border-border bg-surface-elevated p-6',
'transition-all duration-fast',
isInteractive && [
'hover:shadow-md hover:border-brand-500/50 cursor-pointer',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base',
],
className
)
if (href) {
return (
<a href={href} className={baseClasses}>
{content}
</a>
)
}
return <div className={baseClasses}>{content}</div>
}

View File

@@ -1,50 +0,0 @@
import * as React from 'react'
import { cn } from '../../utils/cn'
interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
onCheckedChange?: (checked: boolean) => void
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, onCheckedChange, onChange, id, disabled, ...props }, ref) => {
return (
<label
htmlFor={id}
className={cn(
'relative inline-flex items-center',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
className
)}
>
<input
id={id}
type="checkbox"
className="sr-only peer"
ref={ref}
disabled={disabled}
onChange={(e) => {
onChange?.(e)
onCheckedChange?.(e.target.checked)
}}
{...props}
/>
<div
className={cn(
'w-11 h-6 rounded-full transition-colors duration-fast',
'bg-surface-muted',
'peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-brand-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-surface-base',
'peer-checked:bg-brand-500',
"after:content-[''] after:absolute after:top-[2px] after:start-[2px]",
'after:bg-white after:border after:border-border after:rounded-full',
'after:h-5 after:w-5 after:transition-all after:duration-fast',
'peer-checked:after:translate-x-full peer-checked:after:border-white',
'rtl:peer-checked:after:-translate-x-full'
)}
/>
</label>
)
}
)
Switch.displayName = 'Switch'
export { Switch }

View File

@@ -1,221 +0,0 @@
import '@testing-library/jest-dom/vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import userEvent from '@testing-library/user-event'
import { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs'
describe('Tabs', () => {
it('renders tabs container with proper role', () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
</TabsList>
</Tabs>
)
const tablist = screen.getByRole('tablist')
expect(tablist).toBeInTheDocument()
})
it('renders all tabs with correct labels', () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">First Tab</TabsTrigger>
<TabsTrigger value="tab2">Second Tab</TabsTrigger>
<TabsTrigger value="tab3">Third Tab</TabsTrigger>
</TabsList>
</Tabs>
)
expect(screen.getByRole('tab', { name: 'First Tab' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Second Tab' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Third Tab' })).toBeInTheDocument()
})
it('first tab is active by default', () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
</Tabs>
)
const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
expect(tab1).toHaveAttribute('data-state', 'active')
expect(tab2).toHaveAttribute('data-state', 'inactive')
})
it('clicking tab changes active state', async () => {
const user = userEvent.setup()
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
</Tabs>
)
const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
await user.click(tab2)
expect(tab2).toHaveAttribute('data-state', 'active')
})
it('only one tab active at a time', async () => {
const user = userEvent.setup()
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
<TabsTrigger value="tab3">Tab 3</TabsTrigger>
</TabsList>
</Tabs>
)
const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
const tab3 = screen.getByRole('tab', { name: 'Tab 3' })
// Initially tab1 is active
expect(tab1).toHaveAttribute('data-state', 'active')
// Click tab2
await user.click(tab2)
expect(tab2).toHaveAttribute('data-state', 'active')
expect(tab1).toHaveAttribute('data-state', 'inactive')
expect(tab3).toHaveAttribute('data-state', 'inactive')
// Click tab3
await user.click(tab3)
expect(tab3).toHaveAttribute('data-state', 'active')
expect(tab1).toHaveAttribute('data-state', 'inactive')
expect(tab2).toHaveAttribute('data-state', 'inactive')
})
it('disabled tab cannot be clicked', async () => {
const user = userEvent.setup()
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2" disabled>Tab 2</TabsTrigger>
</TabsList>
</Tabs>
)
const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
expect(tab2).toBeDisabled()
await user.click(tab2)
// Tab 1 should still be active
expect(tab1).toHaveAttribute('data-state', 'active')
expect(tab2).toHaveAttribute('data-state', 'inactive')
})
it('keyboard navigation with arrow keys', async () => {
const user = userEvent.setup()
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
<TabsTrigger value="tab3">Tab 3</TabsTrigger>
</TabsList>
</Tabs>
)
const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
tab1.focus()
expect(tab1).toHaveFocus()
// Arrow right should move focus and activate tab2
await user.keyboard('{ArrowRight}')
expect(tab2).toHaveFocus()
expect(tab2).toHaveAttribute('data-state', 'active')
})
it('active tab has correct aria-selected', async () => {
const user = userEvent.setup()
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
</Tabs>
)
const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
expect(tab1).toHaveAttribute('aria-selected', 'true')
expect(tab2).toHaveAttribute('aria-selected', 'false')
await user.click(tab2)
expect(tab1).toHaveAttribute('aria-selected', 'false')
expect(tab2).toHaveAttribute('aria-selected', 'true')
})
it('tab panels show/hide based on active tab', async () => {
const user = userEvent.setup()
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1" data-testid="content1">Content 1</TabsContent>
<TabsContent value="tab2" data-testid="content2">Content 2</TabsContent>
</Tabs>
)
// Content 1 should be visible (active)
const content1 = screen.getByTestId('content1')
expect(content1).toBeInTheDocument()
expect(content1).toHaveAttribute('data-state', 'active')
// Content 2 should be hidden (inactive)
const content2 = screen.getByTestId('content2')
expect(content2).toBeInTheDocument()
expect(content2).toHaveAttribute('data-state', 'inactive')
const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
await user.click(tab2)
// After click, content states should swap
expect(content1).toHaveAttribute('data-state', 'inactive')
expect(content2).toHaveAttribute('data-state', 'active')
})
it('custom className is applied', () => {
render(
<Tabs defaultValue="tab1">
<TabsList className="custom-list-class">
<TabsTrigger value="tab1" className="custom-trigger-class">Tab 1</TabsTrigger>
</TabsList>
<TabsContent value="tab1" className="custom-content-class">Content</TabsContent>
</Tabs>
)
const tablist = screen.getByRole('tablist')
const tab = screen.getByRole('tab', { name: 'Tab 1' })
const content = screen.getByText('Content')
expect(tablist).toHaveClass('custom-list-class')
expect(tab).toHaveClass('custom-trigger-class')
expect(content).toHaveClass('custom-content-class')
})
})

View File

@@ -1,59 +0,0 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '../../utils/cn'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-lg',
'bg-surface-subtle p-1 text-content-muted',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap',
'rounded-md px-3 py-1.5 text-sm font-medium',
'ring-offset-surface-base transition-all duration-fast',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-surface-elevated data-[state=active]:text-content-primary data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-surface-base',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -1,34 +0,0 @@
import * as React from 'react'
import { cn } from '../../utils/cn'
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: boolean
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, error, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-lg px-3 py-2',
'border bg-surface-base text-content-primary',
'text-sm placeholder:text-content-muted',
'transition-colors duration-fast',
error
? 'border-error focus:ring-error/20'
: 'border-border hover:border-border-strong focus:border-brand-500',
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
'disabled:cursor-not-allowed disabled:opacity-50',
'resize-y',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -1,37 +0,0 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '../../utils/cn'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md px-3 py-1.5',
'bg-surface-overlay text-content-primary text-sm',
'border border-border shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,181 +0,0 @@
import '@testing-library/jest-dom/vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertTitle, AlertDescription } from '../Alert'
describe('Alert', () => {
it('renders with default variant', () => {
render(<Alert>Default alert content</Alert>)
const alert = screen.getByRole('alert')
expect(alert).toBeInTheDocument()
expect(alert).toHaveClass('bg-surface-subtle')
expect(screen.getByText('Default alert content')).toBeInTheDocument()
})
it('renders with info variant', () => {
render(<Alert variant="info">Info message</Alert>)
const alert = screen.getByRole('alert')
expect(alert).toHaveClass('bg-info-muted')
expect(alert).toHaveClass('border-info/30')
})
it('renders with success variant', () => {
render(<Alert variant="success">Success message</Alert>)
const alert = screen.getByRole('alert')
expect(alert).toHaveClass('bg-success-muted')
expect(alert).toHaveClass('border-success/30')
})
it('renders with warning variant', () => {
render(<Alert variant="warning">Warning message</Alert>)
const alert = screen.getByRole('alert')
expect(alert).toHaveClass('bg-warning-muted')
expect(alert).toHaveClass('border-warning/30')
})
it('renders with error variant', () => {
render(<Alert variant="error">Error message</Alert>)
const alert = screen.getByRole('alert')
expect(alert).toHaveClass('bg-error-muted')
expect(alert).toHaveClass('border-error/30')
})
it('renders with title', () => {
render(<Alert title="Alert Title">Alert content</Alert>)
expect(screen.getByText('Alert Title')).toBeInTheDocument()
expect(screen.getByText('Alert content')).toBeInTheDocument()
})
it('renders dismissible alert with dismiss button', () => {
const onDismiss = vi.fn()
render(
<Alert dismissible onDismiss={onDismiss}>
Dismissible alert
</Alert>
)
const dismissButton = screen.getByRole('button', { name: /dismiss alert/i })
expect(dismissButton).toBeInTheDocument()
})
it('calls onDismiss and hides alert when dismiss button is clicked', () => {
const onDismiss = vi.fn()
render(
<Alert dismissible onDismiss={onDismiss}>
Dismissible alert
</Alert>
)
const dismissButton = screen.getByRole('button', { name: /dismiss alert/i })
fireEvent.click(dismissButton)
expect(onDismiss).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('hides alert on dismiss without onDismiss callback', () => {
render(
<Alert dismissible>
Dismissible alert
</Alert>
)
const dismissButton = screen.getByRole('button', { name: /dismiss alert/i })
fireEvent.click(dismissButton)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('renders with custom icon', () => {
render(
<Alert icon={AlertCircle} data-testid="alert-with-icon">
Alert with custom icon
</Alert>
)
const alert = screen.getByTestId('alert-with-icon')
// Custom icon should be rendered (AlertCircle)
const iconContainer = alert.querySelector('svg')
expect(iconContainer).toBeInTheDocument()
})
it('renders default icon based on variant', () => {
render(<Alert variant="error">Error alert</Alert>)
const alert = screen.getByRole('alert')
// Error variant uses XCircle icon
const icon = alert.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('text-error')
})
it('applies custom className', () => {
render(<Alert className="custom-class">Alert content</Alert>)
const alert = screen.getByRole('alert')
expect(alert).toHaveClass('custom-class')
})
it('does not render dismiss button when not dismissible', () => {
render(<Alert>Non-dismissible alert</Alert>)
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument()
})
})
describe('AlertTitle', () => {
it('renders correctly', () => {
render(<AlertTitle>Test Title</AlertTitle>)
const title = screen.getByText('Test Title')
expect(title).toBeInTheDocument()
expect(title.tagName).toBe('H5')
expect(title).toHaveClass('font-semibold')
})
it('applies custom className', () => {
render(<AlertTitle className="custom-class">Title</AlertTitle>)
const title = screen.getByText('Title')
expect(title).toHaveClass('custom-class')
})
})
describe('AlertDescription', () => {
it('renders correctly', () => {
render(<AlertDescription>Test Description</AlertDescription>)
const description = screen.getByText('Test Description')
expect(description).toBeInTheDocument()
expect(description.tagName).toBe('P')
expect(description).toHaveClass('text-sm')
})
it('applies custom className', () => {
render(<AlertDescription className="custom-class">Description</AlertDescription>)
const description = screen.getByText('Description')
expect(description).toHaveClass('custom-class')
})
})
describe('Alert composition', () => {
it('works with AlertTitle and AlertDescription subcomponents', () => {
render(
<Alert>
<AlertTitle>Composed Title</AlertTitle>
<AlertDescription>Composed description text</AlertDescription>
</Alert>
)
expect(screen.getByText('Composed Title')).toBeInTheDocument()
expect(screen.getByText('Composed description text')).toBeInTheDocument()
})
})

View File

@@ -1,352 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { DataTable, type Column } from '../DataTable'
interface TestRow {
id: string
name: string
status: string
}
const mockData: TestRow[] = [
{ id: '1', name: 'Item 1', status: 'Active' },
{ id: '2', name: 'Item 2', status: 'Inactive' },
{ id: '3', name: 'Item 3', status: 'Active' },
]
const mockColumns: Column<TestRow>[] = [
{ key: 'name', header: 'Name', cell: (row) => row.name },
{ key: 'status', header: 'Status', cell: (row) => row.status },
]
const sortableColumns: Column<TestRow>[] = [
{ key: 'name', header: 'Name', cell: (row) => row.name, sortable: true },
{ key: 'status', header: 'Status', cell: (row) => row.status, sortable: true },
]
describe('DataTable', () => {
it('renders correctly with data', () => {
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
/>
)
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
expect(screen.getByText('Item 3')).toBeInTheDocument()
})
it('renders empty state when no data', () => {
render(
<DataTable
data={[]}
columns={mockColumns}
rowKey={(row) => row.id}
/>
)
expect(screen.getByText('No data available')).toBeInTheDocument()
})
it('renders custom empty state', () => {
render(
<DataTable
data={[]}
columns={mockColumns}
rowKey={(row) => row.id}
emptyState={<div>Custom empty message</div>}
/>
)
expect(screen.getByText('Custom empty message')).toBeInTheDocument()
})
it('renders loading state', () => {
render(
<DataTable
data={[]}
columns={mockColumns}
rowKey={(row) => row.id}
isLoading={true}
/>
)
// Loading spinner should be present (animated div)
const spinnerContainer = document.querySelector('.animate-spin')
expect(spinnerContainer).toBeInTheDocument()
})
it('handles sortable column click - ascending', () => {
render(
<DataTable
data={mockData}
columns={sortableColumns}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
expect(nameHeader).toHaveAttribute('role', 'button')
fireEvent.click(nameHeader!)
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
})
it('handles sortable column click - descending on second click', () => {
render(
<DataTable
data={mockData}
columns={sortableColumns}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
// First click - ascending
fireEvent.click(nameHeader!)
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
// Second click - descending
fireEvent.click(nameHeader!)
expect(nameHeader).toHaveAttribute('aria-sort', 'descending')
})
it('handles sortable column click - resets on third click', () => {
render(
<DataTable
data={mockData}
columns={sortableColumns}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
// First click - ascending
fireEvent.click(nameHeader!)
// Second click - descending
fireEvent.click(nameHeader!)
// Third click - reset
fireEvent.click(nameHeader!)
expect(nameHeader).not.toHaveAttribute('aria-sort')
})
it('handles sortable column keyboard navigation', () => {
render(
<DataTable
data={mockData}
columns={sortableColumns}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
fireEvent.keyDown(nameHeader!, { key: 'Enter' })
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
fireEvent.keyDown(nameHeader!, { key: ' ' })
expect(nameHeader).toHaveAttribute('aria-sort', 'descending')
})
it('handles row selection - single row', () => {
const onSelectionChange = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set()}
onSelectionChange={onSelectionChange}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
// First checkbox is "select all", row checkboxes start at index 1
fireEvent.click(checkboxes[1])
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['1']))
})
it('handles row selection - deselect row', () => {
const onSelectionChange = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set(['1'])}
onSelectionChange={onSelectionChange}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
fireEvent.click(checkboxes[1])
expect(onSelectionChange).toHaveBeenCalledWith(new Set())
})
it('handles row selection - select all', () => {
const onSelectionChange = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set()}
onSelectionChange={onSelectionChange}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
// First checkbox is "select all"
fireEvent.click(checkboxes[0])
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['1', '2', '3']))
})
it('handles row selection - deselect all when all selected', () => {
const onSelectionChange = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set(['1', '2', '3'])}
onSelectionChange={onSelectionChange}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
// First checkbox is "select all" - clicking it deselects all
fireEvent.click(checkboxes[0])
expect(onSelectionChange).toHaveBeenCalledWith(new Set())
})
it('handles row click', () => {
const onRowClick = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
onRowClick={onRowClick}
/>
)
const row = screen.getByText('Item 1').closest('tr')
fireEvent.click(row!)
expect(onRowClick).toHaveBeenCalledWith(mockData[0])
})
it('handles row keyboard navigation', () => {
const onRowClick = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
onRowClick={onRowClick}
/>
)
const row = screen.getByText('Item 1').closest('tr')
fireEvent.keyDown(row!, { key: 'Enter' })
expect(onRowClick).toHaveBeenCalledWith(mockData[0])
fireEvent.keyDown(row!, { key: ' ' })
expect(onRowClick).toHaveBeenCalledTimes(2)
})
it('applies sticky header class when stickyHeader is true', () => {
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
stickyHeader={true}
/>
)
const thead = document.querySelector('thead')
expect(thead).toHaveClass('sticky')
})
it('applies custom className', () => {
const { container } = render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('highlights selected rows', () => {
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set(['1'])}
onSelectionChange={() => {}}
/>
)
const selectedRow = screen.getByText('Item 1').closest('tr')
expect(selectedRow).toHaveClass('bg-brand-500/5')
})
it('does not call onSelectionChange when not provided', () => {
// This test ensures no error when clicking selection without handler
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set()}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
// Should not throw
fireEvent.click(checkboxes[0])
fireEvent.click(checkboxes[1])
})
it('applies column width when specified', () => {
const columnsWithWidth: Column<TestRow>[] = [
{ key: 'name', header: 'Name', cell: (row) => row.name, width: '200px' },
{ key: 'status', header: 'Status', cell: (row) => row.status },
]
render(
<DataTable
data={mockData}
columns={columnsWithWidth}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
expect(nameHeader).toHaveStyle({ width: '200px' })
})
})

View File

@@ -1,161 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Search, Mail, Lock } from 'lucide-react'
import { Input } from '../Input'
describe('Input', () => {
it('renders correctly with default props', () => {
render(<Input placeholder="Enter text" />)
const input = screen.getByPlaceholderText('Enter text')
expect(input).toBeInTheDocument()
expect(input.tagName).toBe('INPUT')
})
it('renders with label', () => {
render(<Input label="Email" id="email-input" />)
const label = screen.getByText('Email')
expect(label).toBeInTheDocument()
expect(label.tagName).toBe('LABEL')
expect(label).toHaveAttribute('for', 'email-input')
})
it('renders with error state and message', () => {
render(
<Input
error="This field is required"
errorTestId="input-error"
/>
)
const errorMessage = screen.getByTestId('input-error')
expect(errorMessage).toBeInTheDocument()
expect(errorMessage).toHaveTextContent('This field is required')
expect(errorMessage).toHaveAttribute('role', 'alert')
const input = screen.getByRole('textbox')
expect(input).toHaveClass('border-error')
})
it('renders with helper text', () => {
render(<Input helperText="Enter your email address" />)
expect(screen.getByText('Enter your email address')).toBeInTheDocument()
})
it('does not show helper text when error is present', () => {
render(
<Input
helperText="Helper text"
error="Error message"
/>
)
expect(screen.getByText('Error message')).toBeInTheDocument()
expect(screen.queryByText('Helper text')).not.toBeInTheDocument()
})
it('renders with leftIcon', () => {
render(<Input leftIcon={Search} data-testid="input-with-left-icon" />)
const input = screen.getByRole('textbox')
expect(input).toHaveClass('pl-10')
// Icon should be rendered
const container = input.parentElement
const icon = container?.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('renders with rightIcon', () => {
render(<Input rightIcon={Mail} data-testid="input-with-right-icon" />)
const input = screen.getByRole('textbox')
expect(input).toHaveClass('pr-10')
})
it('renders with both leftIcon and rightIcon', () => {
render(<Input leftIcon={Search} rightIcon={Mail} />)
const input = screen.getByRole('textbox')
expect(input).toHaveClass('pl-10')
expect(input).toHaveClass('pr-10')
})
it('renders disabled state', () => {
render(<Input disabled placeholder="Disabled input" />)
const input = screen.getByPlaceholderText('Disabled input')
expect(input).toBeDisabled()
expect(input).toHaveClass('disabled:cursor-not-allowed')
expect(input).toHaveClass('disabled:opacity-50')
})
it('applies custom className', () => {
render(<Input className="custom-class" />)
const input = screen.getByRole('textbox')
expect(input).toHaveClass('custom-class')
})
it('forwards ref correctly', () => {
const ref = vi.fn()
render(<Input ref={ref} />)
expect(ref).toHaveBeenCalled()
expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement)
})
it('handles password type with toggle visibility', () => {
render(<Input type="password" placeholder="Enter password" />)
const input = screen.getByPlaceholderText('Enter password')
expect(input).toHaveAttribute('type', 'password')
// Toggle button should be present
const toggleButton = screen.getByRole('button', { name: /show password/i })
expect(toggleButton).toBeInTheDocument()
// Click to show password
fireEvent.click(toggleButton)
expect(input).toHaveAttribute('type', 'text')
expect(screen.getByRole('button', { name: /hide password/i })).toBeInTheDocument()
// Click again to hide
fireEvent.click(screen.getByRole('button', { name: /hide password/i }))
expect(input).toHaveAttribute('type', 'password')
})
it('does not show password toggle for non-password types', () => {
render(<Input type="email" placeholder="Enter email" />)
expect(screen.queryByRole('button', { name: /password/i })).not.toBeInTheDocument()
})
it('handles value changes', () => {
const handleChange = vi.fn()
render(<Input onChange={handleChange} placeholder="Input" />)
const input = screen.getByPlaceholderText('Input')
fireEvent.change(input, { target: { value: 'test value' } })
expect(handleChange).toHaveBeenCalled()
expect(input).toHaveValue('test value')
})
it('renders password input with leftIcon', () => {
render(<Input type="password" leftIcon={Lock} placeholder="Password" />)
const input = screen.getByPlaceholderText('Password')
expect(input).toHaveClass('pl-10')
expect(input).toHaveClass('pr-10') // Password toggle adds right padding
})
it('prioritizes password toggle over rightIcon for password type', () => {
render(<Input type="password" rightIcon={Mail} placeholder="Password" />)
// Should show password toggle, not the Mail icon
expect(screen.getByRole('button', { name: /show password/i })).toBeInTheDocument()
})
})

View File

@@ -1,173 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import {
Skeleton,
SkeletonCard,
SkeletonTable,
SkeletonList,
} from '../Skeleton'
describe('Skeleton', () => {
it('renders with default variant', () => {
render(<Skeleton data-testid="skeleton" />)
const skeleton = screen.getByTestId('skeleton')
expect(skeleton).toBeInTheDocument()
expect(skeleton).toHaveClass('animate-pulse')
expect(skeleton).toHaveClass('rounded-md')
})
it('renders with circular variant', () => {
render(<Skeleton variant="circular" data-testid="skeleton" />)
const skeleton = screen.getByTestId('skeleton')
expect(skeleton).toHaveClass('rounded-full')
})
it('renders with text variant', () => {
render(<Skeleton variant="text" data-testid="skeleton" />)
const skeleton = screen.getByTestId('skeleton')
expect(skeleton).toHaveClass('rounded')
expect(skeleton).toHaveClass('h-4')
})
it('applies custom className', () => {
render(<Skeleton className="custom-class" data-testid="skeleton" />)
const skeleton = screen.getByTestId('skeleton')
expect(skeleton).toHaveClass('custom-class')
})
it('passes through HTML attributes', () => {
render(<Skeleton data-testid="skeleton" style={{ width: '100px' }} />)
const skeleton = screen.getByTestId('skeleton')
expect(skeleton).toHaveStyle({ width: '100px' })
})
})
describe('SkeletonCard', () => {
it('renders with default props (image and 3 lines)', () => {
render(<SkeletonCard data-testid="skeleton-card" />)
const card = screen.getByTestId('skeleton-card')
expect(card).toBeInTheDocument()
// Should have image skeleton (h-32)
const skeletons = card.querySelectorAll('.animate-pulse')
// 1 image + 1 title + 3 text lines = 5 total
expect(skeletons.length).toBe(5)
})
it('renders without image when showImage is false', () => {
render(<SkeletonCard showImage={false} data-testid="skeleton-card" />)
const card = screen.getByTestId('skeleton-card')
const skeletons = card.querySelectorAll('.animate-pulse')
// 1 title + 3 text lines = 4 total (no image)
expect(skeletons.length).toBe(4)
})
it('renders with custom number of lines', () => {
render(<SkeletonCard lines={5} showImage={false} data-testid="skeleton-card" />)
const card = screen.getByTestId('skeleton-card')
const skeletons = card.querySelectorAll('.animate-pulse')
// 1 title + 5 text lines = 6 total
expect(skeletons.length).toBe(6)
})
it('applies custom className', () => {
render(<SkeletonCard className="custom-class" data-testid="skeleton-card" />)
const card = screen.getByTestId('skeleton-card')
expect(card).toHaveClass('custom-class')
})
})
describe('SkeletonTable', () => {
it('renders with default rows and columns (5 rows, 4 columns)', () => {
render(<SkeletonTable data-testid="skeleton-table" />)
const table = screen.getByTestId('skeleton-table')
expect(table).toBeInTheDocument()
// Header row + 5 data rows
const rows = table.querySelectorAll('.flex.gap-4')
expect(rows.length).toBe(6) // 1 header + 5 rows
})
it('renders with custom rows', () => {
render(<SkeletonTable rows={3} data-testid="skeleton-table" />)
const table = screen.getByTestId('skeleton-table')
// Header row + 3 data rows
const rows = table.querySelectorAll('.flex.gap-4')
expect(rows.length).toBe(4) // 1 header + 3 rows
})
it('renders with custom columns', () => {
render(<SkeletonTable columns={6} rows={1} data-testid="skeleton-table" />)
const table = screen.getByTestId('skeleton-table')
// Check header has 6 skeletons
const headerRow = table.querySelector('.bg-surface-subtle')
const headerSkeletons = headerRow?.querySelectorAll('.animate-pulse')
expect(headerSkeletons?.length).toBe(6)
})
it('applies custom className', () => {
render(<SkeletonTable className="custom-class" data-testid="skeleton-table" />)
const table = screen.getByTestId('skeleton-table')
expect(table).toHaveClass('custom-class')
})
})
describe('SkeletonList', () => {
it('renders with default props (3 items with avatars)', () => {
render(<SkeletonList data-testid="skeleton-list" />)
const list = screen.getByTestId('skeleton-list')
expect(list).toBeInTheDocument()
// Each item has: 1 avatar (circular) + 2 text lines = 3 skeletons per item
// 3 items * 3 = 9 total skeletons
const items = list.querySelectorAll('.flex.items-center.gap-4')
expect(items.length).toBe(3)
})
it('renders with custom number of items', () => {
render(<SkeletonList items={5} data-testid="skeleton-list" />)
const list = screen.getByTestId('skeleton-list')
const items = list.querySelectorAll('.flex.items-center.gap-4')
expect(items.length).toBe(5)
})
it('renders without avatars when showAvatar is false', () => {
render(<SkeletonList showAvatar={false} items={2} data-testid="skeleton-list" />)
const list = screen.getByTestId('skeleton-list')
// No circular skeletons
const circularSkeletons = list.querySelectorAll('.rounded-full')
expect(circularSkeletons.length).toBe(0)
})
it('renders with avatars when showAvatar is true', () => {
render(<SkeletonList showAvatar={true} items={2} data-testid="skeleton-list" />)
const list = screen.getByTestId('skeleton-list')
// Should have circular skeletons for avatars
const circularSkeletons = list.querySelectorAll('.rounded-full')
expect(circularSkeletons.length).toBe(2)
})
it('applies custom className', () => {
render(<SkeletonList className="custom-class" data-testid="skeleton-list" />)
const list = screen.getByTestId('skeleton-list')
expect(list).toHaveClass('custom-class')
})
})

View File

@@ -1,167 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Users } from 'lucide-react'
import { StatsCard, type StatsCardChange } from '../StatsCard'
describe('StatsCard', () => {
it('renders with title and value', () => {
render(<StatsCard title="Total Users" value={1234} />)
expect(screen.getByText('Total Users')).toBeInTheDocument()
expect(screen.getByText('1234')).toBeInTheDocument()
})
it('renders with string value', () => {
render(<StatsCard title="Revenue" value="$10,000" />)
expect(screen.getByText('Revenue')).toBeInTheDocument()
expect(screen.getByText('$10,000')).toBeInTheDocument()
})
it('renders with icon', () => {
render(
<StatsCard
title="Users"
value={100}
icon={<Users data-testid="users-icon" />}
/>
)
expect(screen.getByTestId('users-icon')).toBeInTheDocument()
// Icon container should have brand styling
const iconContainer = screen.getByTestId('users-icon').parentElement
expect(iconContainer).toHaveClass('bg-brand-500/10')
expect(iconContainer).toHaveClass('text-brand-500')
})
it('renders as link when href is provided', () => {
render(<StatsCard title="Dashboard" value={50} href="/dashboard" />)
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', '/dashboard')
})
it('renders as div when href is not provided', () => {
render(<StatsCard title="Static Card" value={25} />)
expect(screen.queryByRole('link')).not.toBeInTheDocument()
const card = screen.getByText('Static Card').closest('div')
expect(card).toBeInTheDocument()
})
it('renders with upward trend', () => {
const change: StatsCardChange = {
value: 12,
trend: 'up',
}
render(<StatsCard title="Growth" value={100} change={change} />)
expect(screen.getByText('12%')).toBeInTheDocument()
// Should have success color for upward trend
const trendContainer = screen.getByText('12%').closest('div')
expect(trendContainer).toHaveClass('text-success')
})
it('renders with downward trend', () => {
const change: StatsCardChange = {
value: 8,
trend: 'down',
}
render(<StatsCard title="Decline" value={50} change={change} />)
expect(screen.getByText('8%')).toBeInTheDocument()
// Should have error color for downward trend
const trendContainer = screen.getByText('8%').closest('div')
expect(trendContainer).toHaveClass('text-error')
})
it('renders with neutral trend', () => {
const change: StatsCardChange = {
value: 0,
trend: 'neutral',
}
render(<StatsCard title="Stable" value={75} change={change} />)
expect(screen.getByText('0%')).toBeInTheDocument()
// Should have muted color for neutral trend
const trendContainer = screen.getByText('0%').closest('div')
expect(trendContainer).toHaveClass('text-content-muted')
})
it('renders trend with label', () => {
const change: StatsCardChange = {
value: 15,
trend: 'up',
label: 'from last month',
}
render(<StatsCard title="Monthly Growth" value={200} change={change} />)
expect(screen.getByText('15%')).toBeInTheDocument()
expect(screen.getByText('from last month')).toBeInTheDocument()
})
it('applies custom className', () => {
const { container } = render(
<StatsCard title="Custom" value={10} className="custom-class" />
)
const card = container.firstChild
expect(card).toHaveClass('custom-class')
})
it('has hover styles when href is provided', () => {
render(<StatsCard title="Hoverable" value={30} href="/test" />)
const link = screen.getByRole('link')
expect(link).toHaveClass('hover:shadow-md')
expect(link).toHaveClass('hover:border-brand-500/50')
expect(link).toHaveClass('cursor-pointer')
})
it('does not have interactive styles when href is not provided', () => {
const { container } = render(<StatsCard title="Static" value={40} />)
const card = container.firstChild
expect(card).not.toHaveClass('cursor-pointer')
})
it('has focus styles for accessibility when interactive', () => {
render(<StatsCard title="Focusable" value={60} href="/link" />)
const link = screen.getByRole('link')
expect(link).toHaveClass('focus:outline-none')
expect(link).toHaveClass('focus-visible:ring-2')
})
it('renders all elements together correctly', () => {
const change: StatsCardChange = {
value: 5,
trend: 'up',
label: 'vs yesterday',
}
render(
<StatsCard
title="Complete Card"
value="99.9%"
change={change}
icon={<Users data-testid="icon" />}
href="/stats"
className="test-class"
/>
)
expect(screen.getByText('Complete Card')).toBeInTheDocument()
expect(screen.getByText('99.9%')).toBeInTheDocument()
expect(screen.getByText('5%')).toBeInTheDocument()
expect(screen.getByText('vs yesterday')).toBeInTheDocument()
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByRole('link')).toHaveAttribute('href', '/stats')
expect(screen.getByRole('link')).toHaveClass('test-class')
})
})

View File

@@ -1,94 +0,0 @@
// Core UI Components - Barrel Exports
// Badge
export { Badge, type BadgeProps } from './Badge'
// Alert
export { Alert, AlertTitle, AlertDescription, type AlertProps, type AlertTitleProps, type AlertDescriptionProps } from './Alert'
// StatsCard
export { StatsCard, type StatsCardProps, type StatsCardChange } from './StatsCard'
// EmptyState
export { EmptyState, type EmptyStateProps, type EmptyStateAction } from './EmptyState'
// DataTable
export { DataTable, type DataTableProps, type Column } from './DataTable'
// Dialog
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from './Dialog'
// Select
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
} from './Select'
// Tabs
export { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs'
// Tooltip
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip'
// Skeleton
export {
Skeleton,
SkeletonCard,
SkeletonTable,
SkeletonList,
type SkeletonProps,
type SkeletonCardProps,
type SkeletonTableProps,
type SkeletonListProps,
} from './Skeleton'
// Progress
export { Progress, type ProgressProps } from './Progress'
// Checkbox
export { Checkbox, type CheckboxProps } from './Checkbox'
// Label
export { Label, labelVariants, type LabelProps } from './Label'
// Textarea
export { Textarea, type TextareaProps } from './Textarea'
// Button
export { Button, buttonVariants, type ButtonProps } from './Button'
// Card
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
type CardProps,
} from './Card'
// Input
export { Input, type InputProps } from './Input'
// Switch
export { Switch } from './Switch'