chore: clean git cache
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter'
|
||||
import { toast } from '../utils/toast'
|
||||
import { validateInvite, acceptInvite } from '../api/users'
|
||||
import { Loader2, CheckCircle2, XCircle, UserCheck } from 'lucide-react'
|
||||
|
||||
export default function AcceptInvite() {
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
|
||||
const {
|
||||
data: validation,
|
||||
isLoading: isValidating,
|
||||
error: validationError,
|
||||
} = useQuery({
|
||||
queryKey: ['validate-invite', token],
|
||||
queryFn: () => validateInvite(token),
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return acceptInvite({ token, name, password })
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAccepted(true)
|
||||
toast.success(t('acceptInvite.welcomeMessage', { email: data.email }))
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('acceptInvite.acceptFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (password !== confirmPassword) {
|
||||
toast.error(t('acceptInvite.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
toast.error(t('errors.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
acceptMutation.mutate()
|
||||
}
|
||||
|
||||
// Redirect to login after successful acceptance
|
||||
useEffect(() => {
|
||||
if (accepted) {
|
||||
const timer = setTimeout(() => {
|
||||
navigate('/login')
|
||||
}, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [accepted, navigate])
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<XCircle className="h-16 w-16 text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{t('acceptInvite.invalidLink')}</h2>
|
||||
<p className="text-gray-400 text-center mb-6">
|
||||
{t('acceptInvite.invalidLinkMessage')}
|
||||
</p>
|
||||
<Button onClick={() => navigate('/login')}>{t('acceptInvite.goToLogin')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-blue-500 mb-4" />
|
||||
<p className="text-gray-400">{t('acceptInvite.validating')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (validationError || !validation?.valid) {
|
||||
const errorData = validationError as { response?: { data?: { error?: string } } } | undefined
|
||||
const errorMessage = errorData?.response?.data?.error || t('acceptInvite.expiredOrInvalid')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<XCircle className="h-16 w-16 text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{t('acceptInvite.invitationInvalid')}</h2>
|
||||
<p className="text-gray-400 text-center mb-6">{errorMessage}</p>
|
||||
<Button onClick={() => navigate('/login')}>{t('acceptInvite.goToLogin')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (accepted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{t('acceptInvite.accountCreated')}</h2>
|
||||
<p className="text-gray-400 text-center mb-6">
|
||||
{t('acceptInvite.accountCreatedMessage')}
|
||||
</p>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '100px', width: 'auto' }} />
|
||||
</div>
|
||||
|
||||
<Card title={t('acceptInvite.title')}>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-blue-400 mb-1">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
<span className="font-medium">{t('acceptInvite.youveBeenInvited')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
{t('acceptInvite.completeSetup')} <strong>{validation.email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label={t('acceptInvite.yourName')}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('acceptInvite.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label={t('auth.password')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordStrengthMeter password={password} />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label={t('acceptInvite.confirmPassword')}
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
error={
|
||||
confirmPassword && password !== confirmPassword
|
||||
? t('acceptInvite.passwordsDoNotMatch')
|
||||
: undefined
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={acceptMutation.isPending}
|
||||
disabled={!name || !password || password !== confirmPassword}
|
||||
>
|
||||
{t('acceptInvite.createAccount')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, TestTube2, ExternalLink, Shield } from 'lucide-react';
|
||||
import {
|
||||
useAccessLists,
|
||||
useCreateAccessList,
|
||||
useUpdateAccessList,
|
||||
useDeleteAccessList,
|
||||
useTestIP,
|
||||
} from '../hooks/useAccessLists';
|
||||
import { AccessListForm, type AccessListFormData } from '../components/AccessListForm';
|
||||
import type { AccessList } from '../api/accessLists';
|
||||
import { createBackup } from '../api/backups';
|
||||
import toast from 'react-hot-toast';
|
||||
import { PageShell } from '../components/layout/PageShell';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Alert,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
SkeletonTable,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Input,
|
||||
Card,
|
||||
type Column,
|
||||
} from '../components/ui';
|
||||
|
||||
export default function AccessLists() {
|
||||
const { t } = useTranslation();
|
||||
const { data: accessLists, isLoading } = useAccessLists();
|
||||
const createMutation = useCreateAccessList();
|
||||
const updateMutation = useUpdateAccessList();
|
||||
const deleteMutation = useDeleteAccessList();
|
||||
const testIPMutation = useTestIP();
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingACL, setEditingACL] = useState<AccessList | null>(null);
|
||||
const [testingACL, setTestingACL] = useState<AccessList | null>(null);
|
||||
const [testIP, setTestIP] = useState('');
|
||||
const [showCGNATWarning, setShowCGNATWarning] = useState(true);
|
||||
|
||||
// Selection state for bulk operations
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<AccessList | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleCreate = (data: AccessListFormData) => {
|
||||
createMutation.mutate(data, {
|
||||
onSuccess: () => setShowCreateForm(false),
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdate = (data: AccessListFormData) => {
|
||||
if (!editingACL) return;
|
||||
updateMutation.mutate(
|
||||
{ id: editingACL.id, data },
|
||||
{
|
||||
onSuccess: () => setEditingACL(null),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteWithBackup = async (acl: AccessList) => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// Create backup before deletion
|
||||
toast.loading(t('accessLists.creatingBackup'), { id: 'backup-toast' });
|
||||
await createBackup();
|
||||
toast.success(t('accessLists.backupCreated'), { id: 'backup-toast' });
|
||||
|
||||
// Now delete
|
||||
deleteMutation.mutate(acl.id, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteConfirm(null);
|
||||
setEditingACL(null);
|
||||
toast.success(`"${acl.name}" ${t('accessLists.deletedWithBackup')}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`${t('accessLists.failedToDelete')}: ${error.message}`);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsDeleting(false);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error(t('accessLists.failedToCreateBackup'), { id: 'backup-toast' });
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDeleteWithBackup = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// Create backup before deletion
|
||||
toast.loading(t('accessLists.creatingBulkBackup'), { id: 'backup-toast' });
|
||||
await createBackup();
|
||||
toast.success(t('accessLists.backupCreated'), { id: 'backup-toast' });
|
||||
|
||||
// Delete each selected ACL
|
||||
const deletePromises = Array.from(selectedIds).map(
|
||||
(id) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
deleteMutation.mutate(Number(id), {
|
||||
onSuccess: () => resolve(),
|
||||
onError: (error) => reject(error),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
setSelectedIds(new Set());
|
||||
setShowBulkDeleteConfirm(false);
|
||||
toast.success(t('accessLists.bulkDeletedWithBackup', { count: selectedIds.size }));
|
||||
} catch {
|
||||
toast.error(t('accessLists.failedToDeleteSome'));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestIP = () => {
|
||||
if (!testingACL || !testIP.trim()) return;
|
||||
|
||||
testIPMutation.mutate(
|
||||
{ id: testingACL.id, ipAddress: testIP.trim() },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (result.allowed) {
|
||||
toast.success(`✅ ${t('accessLists.ipAllowed', { ip: testIP })}\n${result.reason}`);
|
||||
} else {
|
||||
toast.error(`🚫 ${t('accessLists.ipBlocked', { ip: testIP })}\n${result.reason}`);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getRulesDisplay = (acl: AccessList) => {
|
||||
if (acl.local_network_only) {
|
||||
return <Badge variant="primary" size="sm">🏠 {t('accessLists.rfc1918Only')}</Badge>;
|
||||
}
|
||||
|
||||
if (acl.type.startsWith('geo_')) {
|
||||
const countries = acl.country_codes?.split(',').filter(Boolean) || [];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{countries.slice(0, 3).map((code) => (
|
||||
<Badge key={code} variant="outline" size="sm">{code}</Badge>
|
||||
))}
|
||||
{countries.length > 3 && <span className="text-xs text-content-muted">+{countries.length - 3}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const rules = JSON.parse(acl.ip_rules || '[]');
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rules.slice(0, 2).map((rule: { cidr: string }, idx: number) => (
|
||||
<Badge key={idx} variant="outline" size="sm" className="font-mono">{rule.cidr}</Badge>
|
||||
))}
|
||||
{rules.length > 2 && <span className="text-xs text-content-muted">+{rules.length - 2}</span>}
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return <span className="text-content-muted">-</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadge = (acl: AccessList) => {
|
||||
const type = acl.type;
|
||||
if (type === 'whitelist' || type === 'geo_whitelist') {
|
||||
return <Badge variant="success" size="sm">{t('accessLists.allow')}</Badge>;
|
||||
}
|
||||
// blacklist or geo_blacklist
|
||||
return <Badge variant="error" size="sm">{t('accessLists.deny')}</Badge>;
|
||||
};
|
||||
|
||||
const columns: Column<AccessList>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: t('common.name'),
|
||||
sortable: true,
|
||||
cell: (acl) => (
|
||||
<div>
|
||||
<p className="font-medium text-content-primary">{acl.name}</p>
|
||||
{acl.description && (
|
||||
<p className="text-sm text-content-secondary">{acl.description}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: t('common.type'),
|
||||
sortable: true,
|
||||
cell: (acl) => getTypeBadge(acl),
|
||||
},
|
||||
{
|
||||
key: 'rules',
|
||||
header: t('accessLists.rules'),
|
||||
cell: (acl) => getRulesDisplay(acl),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('common.status'),
|
||||
sortable: true,
|
||||
cell: (acl) => (
|
||||
<Badge variant={acl.enabled ? 'success' : 'default'} size="sm">
|
||||
{acl.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: t('common.actions'),
|
||||
cell: (acl) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setTestingACL(acl);
|
||||
setTestIP('');
|
||||
}}
|
||||
title={t('accessLists.testIp')}
|
||||
>
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingACL(acl);
|
||||
}}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteConfirm(acl);
|
||||
}}
|
||||
title={t('common.delete')}
|
||||
disabled={deleteMutation.isPending || isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-error" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Header actions
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setShowBulkDeleteConfirm(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('common.delete')} ({selectedIds.size})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/security#acl-best-practices-by-service-type', '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{t('accessLists.bestPractices')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('accessLists.createAccessList')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageShell
|
||||
title={t('accessLists.title')}
|
||||
description={t('accessLists.description')}
|
||||
actions={headerActions}
|
||||
>
|
||||
<SkeletonTable rows={5} columns={5} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('accessLists.title')}
|
||||
description={t('accessLists.description')}
|
||||
actions={headerActions}
|
||||
>
|
||||
{/* CGNAT Warning */}
|
||||
{showCGNATWarning && accessLists && accessLists.length > 0 && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
title={t('accessLists.cgnatWarningTitle')}
|
||||
dismissible
|
||||
onDismiss={() => setShowCGNATWarning(false)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t('accessLists.cgnatWarningMessage')}
|
||||
</p>
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer hover:text-content-primary font-medium mb-1">{t('accessLists.solutionsIfLockedOut')}</summary>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2 ml-2">
|
||||
<li>{t('accessLists.solutionLocalNetwork')}</li>
|
||||
<li>{t('accessLists.solutionWhitelist')}</li>
|
||||
<li>{t('accessLists.solutionTestIp')}</li>
|
||||
<li>{t('accessLists.solutionDisableAcl')}</li>
|
||||
<li>{t('accessLists.solutionVpn')}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-bold text-content-primary mb-4">{t('accessLists.createAccessList')}</h2>
|
||||
<AccessListForm
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Edit Form */}
|
||||
{editingACL && (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-bold text-content-primary mb-4">{t('accessLists.editAccessList')}</h2>
|
||||
<AccessListForm
|
||||
initialData={editingACL}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={() => setEditingACL(null)}
|
||||
onDelete={() => setShowDeleteConfirm(editingACL)}
|
||||
isLoading={updateMutation.isPending}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm !== null} onOpenChange={() => setShowDeleteConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('accessLists.deleteAccessList')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-content-secondary py-4">
|
||||
{t('accessLists.deleteConfirmation', { name: showDeleteConfirm?.name })}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setShowDeleteConfirm(null)} disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => showDeleteConfirm && handleDeleteWithBackup(showDeleteConfirm)} disabled={isDeleting}>
|
||||
{isDeleting ? t('common.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Delete Confirmation Dialog */}
|
||||
<Dialog open={showBulkDeleteConfirm} onOpenChange={setShowBulkDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('accessLists.deleteSelectedAccessLists')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-content-secondary py-4">
|
||||
{t('accessLists.bulkDeleteConfirmation', { count: selectedIds.size })}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setShowBulkDeleteConfirm(false)} disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleBulkDeleteWithBackup} disabled={isDeleting}>
|
||||
{isDeleting ? t('common.deleting') : t('accessLists.deleteItems', { count: selectedIds.size })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Test IP Dialog */}
|
||||
<Dialog open={testingACL !== null} onOpenChange={() => setTestingACL(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('accessLists.testIpAddress')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-2">{t('accessLists.accessList')}</label>
|
||||
<p className="text-sm text-content-primary">{testingACL?.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-2">{t('accessLists.ipAddress')}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={testIP}
|
||||
onChange={(e) => setTestIP(e.target.value)}
|
||||
placeholder="192.168.1.100"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleTestIP()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleTestIP} disabled={testIPMutation.isPending}>
|
||||
<TestTube2 className="h-4 w-4 mr-2" />
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setTestingACL(null)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Empty State or DataTable */}
|
||||
{!showCreateForm && !editingACL && (
|
||||
<>
|
||||
{(!accessLists || accessLists.length === 0) ? (
|
||||
<EmptyState
|
||||
icon={<Shield className="h-12 w-12" />}
|
||||
title={t('accessLists.noAccessLists')}
|
||||
description={t('accessLists.noAccessListsDescription')}
|
||||
action={{
|
||||
label: t('accessLists.createAccessList'),
|
||||
onClick: () => setShowCreateForm(true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
data={accessLists}
|
||||
columns={columns}
|
||||
rowKey={(acl) => String(acl.id)}
|
||||
selectable
|
||||
selectedKeys={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Shield className="h-12 w-12" />}
|
||||
title={t('accessLists.noAccessLists')}
|
||||
description={t('accessLists.noAccessListsDescription')}
|
||||
action={{
|
||||
label: t('accessLists.createAccessList'),
|
||||
onClick: () => setShowCreateForm(true),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Label } from '../components/ui/Label'
|
||||
import { Alert } from '../components/ui/Alert'
|
||||
import { Checkbox } from '../components/ui/Checkbox'
|
||||
import { Skeleton } from '../components/ui/Skeleton'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../api/user'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { Copy, RefreshCw, Shield, Mail, User, AlertTriangle, Key } from 'lucide-react'
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter'
|
||||
import { isValidEmail } from '../utils/validation'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function Account() {
|
||||
const { t } = useTranslation()
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Profile State
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailValid, setEmailValid] = useState<boolean | null>(null)
|
||||
const [confirmPasswordForUpdate, setConfirmPasswordForUpdate] = useState('')
|
||||
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
|
||||
const [pendingProfileUpdate, setPendingProfileUpdate] = useState<{name: string, email: string} | null>(null)
|
||||
const [previousEmail, setPreviousEmail] = useState('')
|
||||
const [showEmailConfirmModal, setShowEmailConfirmModal] = useState(false)
|
||||
|
||||
// Certificate Email State
|
||||
const [certEmail, setCertEmail] = useState('')
|
||||
const [certEmailValid, setCertEmailValid] = useState<boolean | null>(null)
|
||||
const [useUserEmail, setUseUserEmail] = useState(true)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { changePassword } = useAuth()
|
||||
|
||||
const { data: profile, isLoading: isLoadingProfile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Initialize profile state
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setName(profile.name)
|
||||
setEmail(profile.email)
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
// Validate profile email
|
||||
useEffect(() => {
|
||||
if (email) {
|
||||
setEmailValid(isValidEmail(email))
|
||||
} else {
|
||||
setEmailValid(null)
|
||||
}
|
||||
}, [email])
|
||||
|
||||
// Initialize cert email state
|
||||
useEffect(() => {
|
||||
if (settings && profile) {
|
||||
const savedEmail = settings['caddy.email']
|
||||
if (savedEmail && savedEmail !== profile.email) {
|
||||
setCertEmail(savedEmail)
|
||||
setUseUserEmail(false)
|
||||
} else {
|
||||
setCertEmail(profile.email)
|
||||
setUseUserEmail(true)
|
||||
}
|
||||
}
|
||||
}, [settings, profile])
|
||||
|
||||
// Validate cert email
|
||||
useEffect(() => {
|
||||
if (certEmail && !useUserEmail) {
|
||||
setCertEmailValid(isValidEmail(certEmail))
|
||||
} else {
|
||||
setCertEmailValid(null)
|
||||
}
|
||||
}, [certEmail, useUserEmail])
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success(t('account.profileUpdated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.profileUpdateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: (variables: { key: string; value: string; category: string }) =>
|
||||
updateSetting(variables.key, variables.value, variables.category),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success(t('account.certEmailUpdated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.certEmailUpdateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: regenerateApiKey,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success(t('account.apiKeyRegenerated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.apiKeyRegenerateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!emailValid) return
|
||||
|
||||
// Check if email changed
|
||||
if (email !== profile?.email) {
|
||||
setPreviousEmail(profile?.email || '')
|
||||
setPendingProfileUpdate({ name, email })
|
||||
setShowPasswordPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
updateProfileMutation.mutate({ name, email })
|
||||
}
|
||||
|
||||
const handlePasswordPromptSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pendingProfileUpdate) return
|
||||
|
||||
setShowPasswordPrompt(false)
|
||||
|
||||
// If email changed, we might need to ask about cert email too
|
||||
// But first, let's update the profile with the password
|
||||
updateProfileMutation.mutate({
|
||||
name: pendingProfileUpdate.name,
|
||||
email: pendingProfileUpdate.email,
|
||||
current_password: confirmPasswordForUpdate
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setConfirmPasswordForUpdate('')
|
||||
// Check if we need to prompt for cert email
|
||||
// We do this AFTER success to ensure profile is updated
|
||||
// But wait, if we do it after success, the profile email is already new.
|
||||
// The user wanted to be asked.
|
||||
// Let's ask about cert email first? No, user said "Updateing email test the popup worked as expected"
|
||||
// But "I chose to keep my certificate email as the old email and it changed anyway"
|
||||
// This implies the logic below is flawed or the backend/frontend sync is weird.
|
||||
|
||||
// Let's show the cert email modal if the update was successful AND it was an email change
|
||||
setShowEmailConfirmModal(true)
|
||||
},
|
||||
onError: () => {
|
||||
setConfirmPasswordForUpdate('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmEmailUpdate = (updateCertEmail: boolean) => {
|
||||
setShowEmailConfirmModal(false)
|
||||
|
||||
if (updateCertEmail) {
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: email,
|
||||
category: 'caddy'
|
||||
})
|
||||
setCertEmail(email)
|
||||
setUseUserEmail(true)
|
||||
} else {
|
||||
// If user chose NO, we must ensure the cert email stays as the OLD email.
|
||||
// If settings['caddy.email'] is empty, it defaults to profile email (which is now NEW).
|
||||
// So we must explicitly save the OLD email.
|
||||
const savedEmail = settings?.['caddy.email']
|
||||
if (!savedEmail && previousEmail) {
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: previousEmail,
|
||||
category: 'caddy'
|
||||
})
|
||||
// Update local state immediately
|
||||
setCertEmail(previousEmail)
|
||||
setUseUserEmail(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateCertEmail = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!useUserEmail && !certEmailValid) return
|
||||
|
||||
const emailToSave = useUserEmail ? profile?.email : certEmail
|
||||
if (!emailToSave) return
|
||||
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: emailToSave,
|
||||
category: 'caddy'
|
||||
})
|
||||
}
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error(t('account.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await changePassword(oldPassword, newPassword)
|
||||
toast.success(t('account.passwordUpdated'))
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err) {
|
||||
const error = err as Error
|
||||
toast.error(error.message || t('account.passwordUpdateFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyApiKey = () => {
|
||||
if (profile?.api_key) {
|
||||
navigator.clipboard.writeText(profile.api_key)
|
||||
toast.success(t('account.apiKeyCopied'))
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-500/10 rounded-lg">
|
||||
<User className="h-6 w-6 text-brand-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-content-primary">{t('account.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Profile Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-brand-500" />
|
||||
<CardTitle>{t('account.profile')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t('account.profileDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name" required>{t('common.name')}</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-email" required>{t('auth.email')}</Label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
error={emailValid === false ? t('errors.invalidEmail') : undefined}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="submit" isLoading={updateProfileMutation.isPending} disabled={emailValid === false}>
|
||||
{t('account.saveProfile')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Certificate Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-info" />
|
||||
<CardTitle>{t('account.certificateEmail')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.certificateEmailDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleUpdateCertEmail}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="useUserEmail"
|
||||
checked={useUserEmail}
|
||||
onCheckedChange={(checked) => {
|
||||
setUseUserEmail(checked === true)
|
||||
if (checked && profile) {
|
||||
setCertEmail(profile.email)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="useUserEmail" className="cursor-pointer">
|
||||
{t('account.useAccountEmail', { email: profile?.email })}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!useUserEmail && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cert-email" required>{t('account.customEmail')}</Label>
|
||||
<Input
|
||||
id="cert-email"
|
||||
type="email"
|
||||
value={certEmail}
|
||||
onChange={(e) => setCertEmail(e.target.value)}
|
||||
required={!useUserEmail}
|
||||
error={certEmailValid === false ? t('errors.invalidEmail') : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="submit" isLoading={updateSettingMutation.isPending} disabled={!useUserEmail && certEmailValid === false}>
|
||||
{t('account.saveCertificateEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-success" />
|
||||
<CardTitle>{t('account.changePassword')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t('account.changePasswordDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handlePasswordChange}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password" required>{t('account.currentPassword')}</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password" required>{t('account.newPassword')}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" required>{t('account.confirmNewPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
error={confirmPassword && newPassword !== confirmPassword ? t('account.passwordsDoNotMatch') : undefined}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="submit" isLoading={loading}>
|
||||
{t('account.updatePassword')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* API Key */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-warning" />
|
||||
<CardTitle>{t('account.apiKey')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.apiKeyDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={profile?.api_key || ''}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={copyApiKey} title={t('account.copyToClipboard')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
isLoading={regenerateMutation.isPending}
|
||||
title={t('account.regenerateApiKey')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Alert variant="warning" title={t('account.securityNotice')}>
|
||||
{t('account.securityNoticeMessage')}
|
||||
</Alert>
|
||||
|
||||
{/* Password Prompt Modal */}
|
||||
{showPasswordPrompt && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 text-brand-500">
|
||||
<Shield className="h-6 w-6" />
|
||||
<CardTitle>{t('account.confirmPassword')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.confirmPasswordDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handlePasswordPromptSubmit}>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-current-password" required>{t('account.currentPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-current-password"
|
||||
type="password"
|
||||
placeholder={t('account.enterPassword')}
|
||||
value={confirmPasswordForUpdate}
|
||||
onChange={(e) => setConfirmPasswordForUpdate(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-3">
|
||||
<Button type="submit" className="w-full" isLoading={updateProfileMutation.isPending}>
|
||||
{t('account.confirmAndUpdate')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPasswordPrompt(false)
|
||||
setConfirmPasswordForUpdate('')
|
||||
setPendingProfileUpdate(null)
|
||||
}}
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Update Confirmation Modal */}
|
||||
{showEmailConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 text-warning">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
<CardTitle>{t('account.updateCertEmailTitle')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.updateCertEmailDescription', { email })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col gap-3">
|
||||
<Button onClick={() => confirmEmailUpdate(true)} className="w-full">
|
||||
{t('account.yesUpdateCertEmail')}
|
||||
</Button>
|
||||
<Button onClick={() => confirmEmailUpdate(false)} variant="secondary" className="w-full">
|
||||
{t('account.noKeepEmail', { email: previousEmail || certEmail })}
|
||||
</Button>
|
||||
<Button onClick={() => setShowEmailConfirmModal(false)} variant="ghost" className="w-full">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getBackups, createBackup, restoreBackup, deleteBackup, BackupFile } from '../api/backups'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { Download, RotateCcw, Plus, Archive, Trash2, Save } from 'lucide-react'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Badge,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
SkeletonTable,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
type Column,
|
||||
} from '../components/ui'
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
export default function Backups() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [interval, setInterval] = useState('7')
|
||||
const [retention, setRetention] = useState('30')
|
||||
const [restoreConfirm, setRestoreConfirm] = useState<BackupFile | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<BackupFile | null>(null)
|
||||
|
||||
// Fetch Backups
|
||||
const { data: backups, isLoading: isLoadingBackups } = useQuery({
|
||||
queryKey: ['backups'],
|
||||
queryFn: getBackups,
|
||||
})
|
||||
|
||||
// Fetch Settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Update local state when settings load
|
||||
useState(() => {
|
||||
if (settings) {
|
||||
if (settings['backup.interval']) setInterval(settings['backup.interval'])
|
||||
if (settings['backup.retention']) setRetention(settings['backup.retention'])
|
||||
}
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createBackup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] })
|
||||
toast.success(t('backups.createSuccess'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('backups.createFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: restoreBackup,
|
||||
onSuccess: () => {
|
||||
setRestoreConfirm(null)
|
||||
toast.success(t('backups.restoreSuccess'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('backups.restoreFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteBackup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] })
|
||||
setDeleteConfirm(null)
|
||||
toast.success(t('backups.deleteSuccess'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('backups.deleteFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const saveSettingsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateSetting('backup.interval', interval, 'system', 'int')
|
||||
await updateSetting('backup.retention', retention, 'system', 'int')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success(t('backups.settingsSaved'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('backups.settingsFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const handleDownload = (filename: string) => {
|
||||
// Trigger download via browser navigation
|
||||
// The browser will send the auth cookie automatically
|
||||
window.location.href = `/api/v1/backups/${filename}/download`
|
||||
}
|
||||
|
||||
const columns: Column<BackupFile>[] = [
|
||||
{
|
||||
key: 'filename',
|
||||
header: t('backups.filename'),
|
||||
sortable: true,
|
||||
cell: (backup) => (
|
||||
<span className="font-medium text-content-primary">{backup.filename}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
header: t('backups.size'),
|
||||
sortable: true,
|
||||
cell: (backup) => (
|
||||
<Badge variant="outline" size="sm">{formatSize(backup.size)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
header: t('backups.createdAt'),
|
||||
sortable: true,
|
||||
cell: (backup) => (
|
||||
<span className="text-content-secondary">
|
||||
{new Date(backup.time).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: t('common.type'),
|
||||
cell: (backup) => {
|
||||
const isAuto = backup.filename.includes('auto')
|
||||
return (
|
||||
<Badge variant={isAuto ? 'default' : 'primary'} size="sm">
|
||||
{isAuto ? t('backups.auto') : t('backups.manual')}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: t('common.actions'),
|
||||
cell: (backup) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(backup.filename)}
|
||||
title={t('backups.download')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRestoreConfirm(backup)}
|
||||
title={t('backups.restore')}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirm(backup)}
|
||||
title={t('common.delete')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-error" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Header actions
|
||||
const headerActions = (
|
||||
<Button onClick={() => createMutation.mutate()} isLoading={createMutation.isPending}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('backups.createBackup')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('backups.title')}
|
||||
description={t('backups.description')}
|
||||
actions={headerActions}
|
||||
>
|
||||
{/* Settings Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('backups.configuration')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<Input
|
||||
label={t('backups.intervalDays')}
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
<Input
|
||||
label={t('backups.retentionDays')}
|
||||
type="number"
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{t('backups.saveSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Backup List */}
|
||||
{isLoadingBackups ? (
|
||||
<SkeletonTable rows={5} columns={5} />
|
||||
) : !backups || backups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Archive className="h-12 w-12" />}
|
||||
title={t('backups.noBackups')}
|
||||
description={t('backups.noBackupsDescription')}
|
||||
action={{
|
||||
label: t('backups.createBackup'),
|
||||
onClick: () => createMutation.mutate(),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
data={backups}
|
||||
columns={columns}
|
||||
rowKey={(backup) => backup.filename}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Archive className="h-12 w-12" />}
|
||||
title={t('backups.noBackups')}
|
||||
description={t('backups.noBackupsDescription')}
|
||||
action={{
|
||||
label: t('backups.createBackup'),
|
||||
onClick: () => createMutation.mutate(),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Restore Confirmation Dialog */}
|
||||
<Dialog open={restoreConfirm !== null} onOpenChange={() => setRestoreConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('backups.restoreBackup')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-content-secondary py-4">
|
||||
{t('backups.restoreConfirmMessage')}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setRestoreConfirm(null)} disabled={restoreMutation.isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => restoreConfirm && restoreMutation.mutate(restoreConfirm.filename)}
|
||||
isLoading={restoreMutation.isPending}
|
||||
>
|
||||
{t('backups.restore')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteConfirm !== null} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('backups.deleteBackup')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-content-secondary py-4">
|
||||
{t('backups.deleteConfirmMessage', { filename: deleteConfirm?.filename })}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setDeleteConfirm(null)} disabled={deleteMutation.isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.filename)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, ShieldCheck } from 'lucide-react'
|
||||
import CertificateList from '../components/CertificateList'
|
||||
import { uploadCertificate } from '../api/certificates'
|
||||
import { toast } from '../utils/toast'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Label,
|
||||
} from '../components/ui'
|
||||
|
||||
export default function Certificates() {
|
||||
const { t } = useTranslation()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [certFile, setCertFile] = useState<File | null>(null)
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!certFile || !keyFile) throw new Error('Files required')
|
||||
await uploadCertificate(name, certFile, keyFile)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
setIsModalOpen(false)
|
||||
setName('')
|
||||
setCertFile(null)
|
||||
setKeyFile(null)
|
||||
toast.success(t('certificates.uploadSuccess'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`${t('certificates.uploadFailed')}: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
uploadMutation.mutate()
|
||||
}
|
||||
|
||||
// Header actions
|
||||
const headerActions = (
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('certificates.addCertificate')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('certificates.title')}
|
||||
description={t('certificates.description')}
|
||||
actions={headerActions}
|
||||
>
|
||||
<Alert variant="info" icon={ShieldCheck}>
|
||||
<strong>{t('certificates.note')}:</strong> {t('certificates.noteText')}
|
||||
</Alert>
|
||||
|
||||
<CertificateList />
|
||||
|
||||
{/* Upload Certificate Dialog */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||
<Input
|
||||
label={t('certificates.friendlyName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. My Custom Cert"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="cert-file">{t('certificates.certificatePem')}</Label>
|
||||
<input
|
||||
id="cert-file"
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer"
|
||||
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
|
||||
className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="key-file">{t('certificates.privateKeyPem')}</Label>
|
||||
<input
|
||||
id="key-file"
|
||||
type="file"
|
||||
accept=".pem,.key"
|
||||
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||
className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={uploadMutation.isPending}>
|
||||
{t('common.upload')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plus, Cloud } from 'lucide-react'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import { Button, Alert, EmptyState, Skeleton } from '../components/ui'
|
||||
import DNSProviderCard from '../components/DNSProviderCard'
|
||||
import DNSProviderForm from '../components/DNSProviderForm'
|
||||
import { useDNSProviders, useDNSProviderMutations, type DNSProvider } from '../hooks/useDNSProviders'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
export default function DNSProviders() {
|
||||
const { t } = useTranslation()
|
||||
const { data: providers = [], isLoading, refetch } = useDNSProviders()
|
||||
const { deleteMutation, testMutation } = useDNSProviderMutations()
|
||||
|
||||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||
const [editingProvider, setEditingProvider] = useState<DNSProvider | null>(null)
|
||||
const [testingProviderId, setTestingProviderId] = useState<number | null>(null)
|
||||
|
||||
const handleAddProvider = () => {
|
||||
setEditingProvider(null)
|
||||
setIsFormOpen(true)
|
||||
}
|
||||
|
||||
const handleEditProvider = (provider: DNSProvider) => {
|
||||
setEditingProvider(provider)
|
||||
setIsFormOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteProvider = async (id: number) => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id)
|
||||
toast.success(t('dnsProviders.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
t('dnsProviders.deleteFailed') +
|
||||
': ' +
|
||||
(error.response?.data?.error || error.message)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestProvider = async (id: number) => {
|
||||
setTestingProviderId(id)
|
||||
try {
|
||||
const result = await testMutation.mutateAsync(id)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t('dnsProviders.testSuccess'))
|
||||
} else {
|
||||
toast.error(result.error || t('dnsProviders.testFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
t('dnsProviders.testFailed') +
|
||||
': ' +
|
||||
(error.response?.data?.error || error.message)
|
||||
)
|
||||
} finally {
|
||||
setTestingProviderId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
toast.success(
|
||||
editingProvider ? t('dnsProviders.updateSuccess') : t('dnsProviders.createSuccess')
|
||||
)
|
||||
refetch()
|
||||
}
|
||||
|
||||
// Header actions
|
||||
const headerActions = (
|
||||
<Button onClick={handleAddProvider}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('dnsProviders.addProvider')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('dnsProviders.title')}
|
||||
description={t('dnsProviders.description')}
|
||||
actions={headerActions}
|
||||
>
|
||||
{/* Info Alert */}
|
||||
<Alert variant="info" icon={Cloud}>
|
||||
<strong>{t('dnsProviders.note')}:</strong> {t('dnsProviders.noteText')}
|
||||
</Alert>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-64 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && providers.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<Cloud className="w-10 h-10" />}
|
||||
title={t('dnsProviders.noProviders')}
|
||||
description={t('dnsProviders.noProvidersDescription')}
|
||||
action={{
|
||||
label: t('dnsProviders.addFirstProvider'),
|
||||
onClick: handleAddProvider,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Provider Cards Grid */}
|
||||
{!isLoading && providers.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{providers.map((provider) => (
|
||||
<DNSProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
onEdit={handleEditProvider}
|
||||
onDelete={handleDeleteProvider}
|
||||
onTest={handleTestProvider}
|
||||
isTesting={testingProviderId === provider.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form Dialog */}
|
||||
<DNSProviderForm
|
||||
open={isFormOpen}
|
||||
onOpenChange={setIsFormOpen}
|
||||
provider={editingProvider}
|
||||
onSuccess={handleFormSuccess}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { useAccessLists } from '../hooks/useAccessLists'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { checkHealth } from '../api/health'
|
||||
import { Globe, Server, FileKey, Activity, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import { StatsCard, Skeleton } from '../components/ui'
|
||||
import UptimeWidget from '../components/UptimeWidget'
|
||||
|
||||
function StatsCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-elevated p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation()
|
||||
const { hosts, loading: hostsLoading } = useProxyHosts()
|
||||
const { servers, loading: serversLoading } = useRemoteServers()
|
||||
const { data: accessLists, isLoading: accessListsLoading } = useAccessLists()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch certificates (polling interval managed via effect below)
|
||||
const { certificates, isLoading: certificatesLoading } = useCertificates()
|
||||
|
||||
// Build set of certified domains for pending detection
|
||||
// ACME certificates (Let's Encrypt) are auto-managed and don't set certificate_id,
|
||||
// so we match by domain name instead
|
||||
const hasPendingCerts = useMemo(() => {
|
||||
const certifiedDomains = new Set<string>()
|
||||
certificates.forEach(cert => {
|
||||
// Handle missing or undefined domain field
|
||||
if (!cert.domain) return
|
||||
cert.domain.split(',').forEach(d => {
|
||||
const trimmed = d.trim().toLowerCase()
|
||||
if (trimmed) certifiedDomains.add(trimmed)
|
||||
})
|
||||
})
|
||||
|
||||
// Check if any SSL host lacks a certificate
|
||||
const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled)
|
||||
return sslHosts.some(host => {
|
||||
const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase())
|
||||
return !hostDomains.some(domain => certifiedDomains.has(domain))
|
||||
})
|
||||
}, [hosts, certificates])
|
||||
|
||||
// Poll certificates every 15s when there are pending certs
|
||||
useEffect(() => {
|
||||
if (!hasPendingCerts) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
}, 15000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [hasPendingCerts, queryClient])
|
||||
|
||||
// Use React Query for health check - benefits from global caching
|
||||
const { data: health, isLoading: healthLoading } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: checkHealth,
|
||||
staleTime: 1000 * 60, // 1 minute for health checks
|
||||
refetchInterval: 1000 * 60, // Auto-refresh every minute
|
||||
})
|
||||
|
||||
const enabledHosts = hosts.filter(h => h.enabled).length
|
||||
const enabledServers = servers.filter(s => s.enabled).length
|
||||
const enabledAccessLists = accessLists?.filter(a => a.enabled).length ?? 0
|
||||
const validCertificates = certificates.filter(c => c.status === 'valid').length
|
||||
|
||||
const isInitialLoading = hostsLoading || serversLoading || accessListsLoading || certificatesLoading
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('dashboard.title')}
|
||||
description={t('dashboard.description')}
|
||||
>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{isInitialLoading ? (
|
||||
<>
|
||||
<StatsCardSkeleton />
|
||||
<StatsCardSkeleton />
|
||||
<StatsCardSkeleton />
|
||||
<StatsCardSkeleton />
|
||||
<StatsCardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StatsCard
|
||||
title={t('dashboard.proxyHosts')}
|
||||
value={hosts.length}
|
||||
icon={<Globe className="h-6 w-6" />}
|
||||
href="/proxy-hosts"
|
||||
change={enabledHosts > 0 ? {
|
||||
value: Math.round((enabledHosts / hosts.length) * 100) || 0,
|
||||
trend: 'neutral',
|
||||
label: t('common.enabledCount', { count: enabledHosts }),
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title={t('dashboard.certificateStatus')}
|
||||
value={certificates.length}
|
||||
icon={<FileKey className="h-6 w-6" />}
|
||||
href="/certificates"
|
||||
change={validCertificates > 0 ? {
|
||||
value: Math.round((validCertificates / certificates.length) * 100) || 0,
|
||||
trend: 'neutral',
|
||||
label: t('common.validCount', { count: validCertificates }),
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
|
||||
<StatsCard
|
||||
title={t('dashboard.remoteServers')}
|
||||
value={servers.length}
|
||||
icon={<Server className="h-6 w-6" />}
|
||||
href="/remote-servers"
|
||||
change={enabledServers > 0 ? {
|
||||
value: Math.round((enabledServers / servers.length) * 100) || 0,
|
||||
trend: 'neutral',
|
||||
label: t('common.enabledCount', { count: enabledServers }),
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title={t('dashboard.accessLists')}
|
||||
value={accessLists?.length ?? 0}
|
||||
icon={<FileKey className="h-6 w-6" />}
|
||||
href="/access-lists"
|
||||
change={enabledAccessLists > 0 ? {
|
||||
value: Math.round((enabledAccessLists / (accessLists?.length ?? 1)) * 100) || 0,
|
||||
trend: 'neutral',
|
||||
label: t('common.activeCount', { count: enabledAccessLists }),
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title={t('dashboard.systemStatus')}
|
||||
value={healthLoading ? '...' : health?.status === 'ok' ? t('dashboard.healthy') : t('common.error')}
|
||||
icon={
|
||||
healthLoading ? (
|
||||
<Activity className="h-6 w-6 animate-pulse" />
|
||||
) : health?.status === 'ok' ? (
|
||||
<CheckCircle2 className="h-6 w-6 text-success" />
|
||||
) : (
|
||||
<AlertTriangle className="h-6 w-6 text-error" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Uptime Widget */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-1 gap-4">
|
||||
|
||||
<UptimeWidget />
|
||||
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDomains } from '../hooks/useDomains'
|
||||
import { Trash2, Plus, Globe, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function Domains() {
|
||||
const { t } = useTranslation()
|
||||
const { domains, isLoading, isFetching, error, createDomain, deleteDomain } = useDomains()
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newDomain.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createDomain(newDomain)
|
||||
setNewDomain('')
|
||||
} catch {
|
||||
alert(t('domains.createFailed'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm(t('domains.deleteConfirm'))) {
|
||||
try {
|
||||
await deleteDomain(uuid)
|
||||
} catch {
|
||||
alert(t('domains.deleteFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-8 text-white">{t('common.loading')}</div>
|
||||
if (error) return <div className="p-8 text-red-400">{t('domains.loadError')}</div>
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">{t('domains.title')}</h1>
|
||||
{isFetching && !isLoading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Add New Domain Card */}
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4 flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
{t('domains.addDomain')}
|
||||
</h3>
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
{t('domains.domainName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder={t('domains.placeholder')}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !newDomain.trim()}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? t('domains.adding') : t('domains.addDomain')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Domain List */}
|
||||
{domains.map((domain) => (
|
||||
<div key={domain.uuid} className="bg-dark-card border border-gray-800 rounded-lg p-6 flex flex-col justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-900/30 rounded-lg text-blue-400">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">{domain.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('domains.added', { date: new Date(domain.created_at).toLocaleDateString() })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(domain.uuid)}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
title={t('domains.deleteDomain')}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { useImport } from '../hooks/useImport'
|
||||
import ImportBanner from '../components/ImportBanner'
|
||||
import ImportReviewTable from '../components/ImportReviewTable'
|
||||
import ImportSitesModal from '../components/ImportSitesModal'
|
||||
import ImportSuccessModal from '../components/dialogs/ImportSuccessModal'
|
||||
|
||||
export default function ImportCaddy() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { session, preview, loading, error, upload, commit, cancel, commitResult, clearCommitResult } = useImport()
|
||||
const [content, setContent] = useState('')
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [showMultiModal, setShowMultiModal] = useState(false)
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false)
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!content.trim()) {
|
||||
alert(t('importCaddy.enterCaddyfileContent'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await upload(content)
|
||||
setShowReview(true)
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const text = await file.text()
|
||||
setContent(text)
|
||||
}
|
||||
|
||||
const handleCommit = async (resolutions: Record<string, string>, names: Record<string, string>) => {
|
||||
try {
|
||||
// Create a backup before committing import to allow rollback
|
||||
await createBackup()
|
||||
await commit(resolutions, names)
|
||||
setContent('')
|
||||
setShowReview(false)
|
||||
setShowSuccessModal(true)
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseSuccessModal = () => {
|
||||
setShowSuccessModal(false)
|
||||
clearCommitResult()
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (confirm(t('importCaddy.cancelConfirm'))) {
|
||||
try {
|
||||
await cancel()
|
||||
setShowReview(false)
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">{t('importCaddy.title')}</h1>
|
||||
|
||||
{session && (
|
||||
<ImportBanner
|
||||
session={session}
|
||||
onReview={() => setShowReview(true)}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show warning if preview is empty but session exists (e.g. mounted file was empty or invalid) */}
|
||||
{session && preview && preview.preview && preview.preview.hosts.length === 0 && (
|
||||
<div className="bg-yellow-900/20 border border-yellow-500 text-yellow-400 px-4 py-3 rounded mb-6">
|
||||
<p className="font-bold">{t('importCaddy.noDomainsFound')}</p>
|
||||
<p className="text-sm mt-1">
|
||||
{t('importCaddy.emptyFileWarning')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!session && (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{t('importCaddy.uploadOrPaste')}</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{t('importCaddy.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
{t('importCaddy.uploadCaddyfile')}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".caddyfile,.txt,text/plain"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Or Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
<span className="text-gray-500 text-sm">{t('importCaddy.orPasteContent')}</span>
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
</div>
|
||||
|
||||
{/* Text Area */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
{t('importCaddy.caddyfileContent')}
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={loading || !content.trim()}
|
||||
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('importCaddy.processing') : t('importCaddy.parseAndReview')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMultiModal(true)}
|
||||
className="ml-4 px-4 py-2 bg-gray-800 text-white rounded-lg"
|
||||
>
|
||||
{t('importCaddy.multiSiteImport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReview && preview && preview.preview && (
|
||||
<ImportReviewTable
|
||||
hosts={preview.preview.hosts}
|
||||
conflicts={preview.preview.conflicts}
|
||||
conflictDetails={preview.conflict_details}
|
||||
errors={preview.preview.errors}
|
||||
caddyfileContent={preview.caddyfile_content}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowReview(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ImportSitesModal
|
||||
visible={showMultiModal}
|
||||
onClose={() => setShowMultiModal(false)}
|
||||
onUploaded={() => setShowReview(true)}
|
||||
/>
|
||||
|
||||
<ImportSuccessModal
|
||||
visible={showSuccessModal}
|
||||
onClose={handleCloseSuccessModal}
|
||||
onNavigateDashboard={() => {
|
||||
handleCloseSuccessModal()
|
||||
navigate('/')
|
||||
}}
|
||||
onNavigateHosts={() => {
|
||||
handleCloseSuccessModal()
|
||||
navigate('/proxy-hosts')
|
||||
}}
|
||||
results={commitResult}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { importCrowdsecConfig } from '../api/crowdsec'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
export default function ImportCrowdSec() {
|
||||
const { t } = useTranslation()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
|
||||
const backupMutation = useMutation({
|
||||
mutationFn: () => createBackup(),
|
||||
})
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (file: File) => importCrowdsecConfig(file),
|
||||
onSuccess: () => {
|
||||
toast.success(t('importCrowdSec.configImported'))
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
toast.error(t('importCrowdSec.importFailed', { error: msg }))
|
||||
}
|
||||
})
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
setFile(f)
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return
|
||||
try {
|
||||
toast.loading(t('importCrowdSec.creatingBackup'))
|
||||
await backupMutation.mutateAsync()
|
||||
toast.dismiss()
|
||||
toast.loading(t('importCrowdSec.importing'))
|
||||
await importMutation.mutateAsync(file)
|
||||
toast.dismiss()
|
||||
} catch {
|
||||
toast.dismiss()
|
||||
// importMutation onError handles toast
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">{t('importCrowdSec.title')}</h1>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">{t('importCrowdSec.description')}</p>
|
||||
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" data-testid="crowdsec-import-file" />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => handleImport()} isLoading={backupMutation.isPending || importMutation.isPending} disabled={!file}>{t('importCrowdSec.import')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../utils/toast'
|
||||
import client from '../api/client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { getSetupStatus } from '../api/setup'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showResetInfo, setShowResetInfo] = useState(false)
|
||||
const { login } = useAuth()
|
||||
|
||||
const { data: setupStatus, isLoading: isCheckingSetup } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (setupStatus?.setupRequired) {
|
||||
navigate('/setup')
|
||||
}
|
||||
}, [setupStatus, navigate])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await client.post('/auth/login', { email, password })
|
||||
const token = (res.data as { token?: string }).token
|
||||
await login(token)
|
||||
await queryClient.invalidateQueries({ queryKey: ['setupStatus'] })
|
||||
toast.success(t('auth.loginSuccess'))
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } }
|
||||
toast.error(error.response?.data?.error || t('auth.loginFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isCheckingSetup) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="text-white">{t('auth.checkingSetup')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<ConfigReloadOverlay
|
||||
message={t('auth.loggingIn')}
|
||||
submessage={t('auth.loggingInSub')}
|
||||
type="coin"
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
|
||||
|
||||
|
||||
</div>
|
||||
<Card className="w-full" title={t('auth.login')}>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label={t('auth.email')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
disabled={loading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
label={t('auth.password')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetInfo(!showResetInfo)}
|
||||
className="text-sm text-blue-400 hover:text-blue-300"
|
||||
disabled={loading}
|
||||
>
|
||||
{t('auth.forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showResetInfo && (
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 text-sm text-blue-200">
|
||||
<p className="mb-2 font-medium">{t('auth.resetPasswordTitle')}</p>
|
||||
<p className="mb-2">{t('auth.resetPasswordInstructions')}</p>
|
||||
<code className="block bg-black/50 p-2 rounded font-mono text-xs break-all select-all">
|
||||
docker exec -it caddy-proxy-manager /app/backend reset-password <email> <new-password>
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
{t('auth.signIn')}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { getLogs, getLogContent, downloadLog, LogFilter } from '../api/logs';
|
||||
import { FileText, ChevronLeft, ChevronRight, ScrollText } from 'lucide-react';
|
||||
import { LogTable } from '../components/LogTable';
|
||||
import { LogFilters } from '../components/LogFilters';
|
||||
import { PageShell } from '../components/layout/PageShell';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Badge,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
SkeletonList,
|
||||
} from '../components/ui';
|
||||
|
||||
const Logs: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [selectedLog, setSelectedLog] = useState<string | null>(null);
|
||||
|
||||
// Filter State
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [host, setHost] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [sort, setSort] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
const { data: logs, isLoading: isLoadingLogs } = useQuery({
|
||||
queryKey: ['logs'],
|
||||
queryFn: getLogs,
|
||||
});
|
||||
|
||||
// Select first log by default if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedLog && logs && logs.length > 0) {
|
||||
setSelectedLog(logs[0].name);
|
||||
}
|
||||
}, [logs, selectedLog]);
|
||||
|
||||
const filter: LogFilter = {
|
||||
search,
|
||||
host,
|
||||
status,
|
||||
level,
|
||||
limit,
|
||||
offset: page * limit,
|
||||
sort,
|
||||
};
|
||||
|
||||
const { data: logData, isLoading: isLoadingContent, refetch: refetchContent } = useQuery({
|
||||
queryKey: ['logContent', selectedLog, search, host, status, level, page, sort],
|
||||
queryFn: () => (selectedLog ? getLogContent(selectedLog, filter) : Promise.resolve(null)),
|
||||
enabled: !!selectedLog,
|
||||
});
|
||||
|
||||
const handleDownload = () => {
|
||||
if (selectedLog) downloadLog(selectedLog);
|
||||
};
|
||||
|
||||
const totalPages = logData ? Math.ceil(logData.total / limit) : 0;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('logs.title')}
|
||||
description={t('logs.description')}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Log File List */}
|
||||
<div className="md:col-span-1 space-y-4">
|
||||
<Card className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-4 text-content-primary">{t('logs.logFiles')}</h2>
|
||||
{isLoadingLogs ? (
|
||||
<SkeletonList items={4} showAvatar={false} />
|
||||
) : logs?.length === 0 ? (
|
||||
<div className="text-sm text-content-muted text-center py-4">{t('logs.noLogFiles')}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs?.map((log) => (
|
||||
<button
|
||||
key={log.name}
|
||||
onClick={() => {
|
||||
setSelectedLog(log.name);
|
||||
setPage(0);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center ${
|
||||
selectedLog === log.name
|
||||
? 'bg-brand-500/10 text-brand-500 border border-brand-500/30'
|
||||
: 'hover:bg-surface-muted text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{log.name}</div>
|
||||
<div className="text-xs text-content-muted">{(log.size / 1024 / 1024).toFixed(2)} MB</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Log Content */}
|
||||
<div className="md:col-span-3 space-y-4">
|
||||
{selectedLog ? (
|
||||
<>
|
||||
<LogFilters
|
||||
search={search}
|
||||
onSearchChange={(v) => {
|
||||
setSearch(v);
|
||||
setPage(0);
|
||||
}}
|
||||
host={host}
|
||||
onHostChange={(v) => {
|
||||
setHost(v);
|
||||
setPage(0);
|
||||
}}
|
||||
status={status}
|
||||
onStatusChange={(v) => {
|
||||
setStatus(v);
|
||||
setPage(0);
|
||||
}}
|
||||
level={level}
|
||||
onLevelChange={(v) => {
|
||||
setLevel(v);
|
||||
setPage(0);
|
||||
}}
|
||||
sort={sort}
|
||||
onSortChange={(v) => {
|
||||
setSort(v);
|
||||
setPage(0);
|
||||
}}
|
||||
onRefresh={refetchContent}
|
||||
onDownload={handleDownload}
|
||||
isLoading={isLoadingContent}
|
||||
/>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
{isLoadingContent ? (
|
||||
<div className="p-6 space-y-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<LogTable logs={logData?.logs || []} isLoading={isLoadingContent} />
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{logData && logData.total > 0 && (
|
||||
<div className="px-6 py-4 border-t border-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="text-sm text-content-muted">
|
||||
{t('logs.showingEntries', { from: logData.offset + 1, to: Math.min(logData.offset + limit, logData.total), total: logData.total })}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" size="sm">
|
||||
{t('logs.pageOf', { current: page + 1, total: totalPages })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoadingContent}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={page >= totalPages - 1 || isLoadingContent}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<ScrollText className="h-12 w-12" />}
|
||||
title={t('logs.noLogSelected')}
|
||||
description={t('logs.selectLogDescription')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -0,0 +1,526 @@
|
||||
import { useState, type FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate, NotificationTemplate } from '../api/notifications';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
// supportsJSONTemplates returns true if the provider type can use JSON templates
|
||||
const supportsJSONTemplates = (providerType: string | undefined): boolean => {
|
||||
if (!providerType) return false;
|
||||
switch (providerType.toLowerCase()) {
|
||||
case 'webhook':
|
||||
case 'discord':
|
||||
case 'slack':
|
||||
case 'gotify':
|
||||
case 'generic':
|
||||
return true;
|
||||
case 'telegram':
|
||||
return false; // Telegram uses URL parameters
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ProviderForm: FC<{
|
||||
initialData?: Partial<NotificationProvider>;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: Partial<NotificationProvider>) => void;
|
||||
}> = ({ initialData, onClose, onSubmit }) => {
|
||||
const { t } = useTranslation();
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({
|
||||
defaultValues: initialData || {
|
||||
type: 'discord',
|
||||
enabled: true,
|
||||
config: '',
|
||||
template: 'minimal',
|
||||
notify_proxy_hosts: true,
|
||||
notify_remote_servers: true,
|
||||
notify_domains: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: true
|
||||
}
|
||||
});
|
||||
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [previewContent, setPreviewContent] = useState<string | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testProvider,
|
||||
onSuccess: () => {
|
||||
setTestStatus('success');
|
||||
setTimeout(() => setTestStatus('idle'), 3000);
|
||||
},
|
||||
onError: () => {
|
||||
setTestStatus('error');
|
||||
setTimeout(() => setTestStatus('idle'), 3000);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTest = () => {
|
||||
const formData = watch();
|
||||
testMutation.mutate(formData as Partial<NotificationProvider>);
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
const formData = watch();
|
||||
setPreviewContent(null);
|
||||
setPreviewError(null);
|
||||
try {
|
||||
// If using an external saved template (id), call previewExternalTemplate with template_id
|
||||
if (formData.template && typeof formData.template === 'string' && formData.template.length === 36) {
|
||||
const res = await previewExternalTemplate(formData.template, undefined, undefined);
|
||||
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
|
||||
} else {
|
||||
const res = await previewProvider(formData as Partial<NotificationProvider>);
|
||||
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setPreviewError(msg || 'Failed to generate preview');
|
||||
}
|
||||
};
|
||||
|
||||
const type = watch('type');
|
||||
const { data: builtins } = useQuery({ queryKey: ['notificationTemplates'], queryFn: getTemplates });
|
||||
const { data: externalTemplates } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates });
|
||||
const template = watch('template');
|
||||
|
||||
const setTemplate = (templateStr: string, templateName?: string) => {
|
||||
// If templateName is provided, set template selection as well
|
||||
if (templateName) setValue('template', templateName);
|
||||
setValue('config', templateStr);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.providerName')}</label>
|
||||
<input
|
||||
{...register('name', { required: t('errors.required') as string })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
/>
|
||||
{errors.name && <span className="text-red-500 text-xs">{errors.name.message as string}</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('common.type')}</label>
|
||||
<select
|
||||
{...register('type')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="gotify">Gotify</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="generic">{t('notificationProviders.genericWebhook')}</option>
|
||||
<option value="webhook">{t('notificationProviders.customWebhook')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.urlWebhook')}</label>
|
||||
<input
|
||||
{...register('url', { required: t('notificationProviders.urlRequired') as string })}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
/>
|
||||
{!supportsJSONTemplates(type) && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('notificationProviders.shoutrrrHelp')} <a href="https://containrrr.dev/shoutrrr/" target="_blank" rel="noreferrer" className="text-blue-500 hover:underline">{t('common.docs')}</a>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{supportsJSONTemplates(type) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.jsonPayloadTemplate')}</label>
|
||||
<div className="flex gap-2 mb-2 mt-1">
|
||||
<Button type="button" size="sm" variant={template === 'minimal' ? 'primary' : 'secondary'} onClick={() => setTemplate('{"message": "{{.Message}}", "title": "{{.Title}}", "time": "{{.Time}}", "event": "{{.EventType}}"}', 'minimal')}>
|
||||
{t('notificationProviders.minimalTemplate')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant={template === 'detailed' ? 'primary' : 'secondary'} onClick={() => setTemplate(`{"title": "{{.Title}}", "message": "{{.Message}}", "time": "{{.Time}}", "event": "{{.EventType}}", "host": "{{.HostName}}", "host_ip": "{{.HostIP}}", "service_count": {{.ServiceCount}}, "services": {{.Services}}}`, 'detailed')}>
|
||||
{t('notificationProviders.detailedTemplate')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant={template === 'custom' ? 'primary' : 'secondary'} onClick={() => setValue('template', 'custom')}>
|
||||
{t('notificationProviders.customTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.template')}</label>
|
||||
<select {...register('template')} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm">
|
||||
{/* Built-in template options */}
|
||||
{builtins?.map((t: NotificationTemplate) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
{/* External saved templates (id values are UUIDs) */}
|
||||
{externalTemplates?.map((t: ExternalTemplate) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<textarea
|
||||
{...register('config')}
|
||||
rows={8}
|
||||
className="mt-1 block w-full font-mono text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder='{"text": "{{.Message}}"}'
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('notificationProviders.availableVariables')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{t('notificationProviders.notificationEvents')}</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_proxy_hosts')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">{t('notificationProviders.proxyHosts')}</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_remote_servers')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">{t('notificationProviders.remoteServers')}</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_domains')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">{t('notificationProviders.domainsNotify')}</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_certs')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">{t('notificationProviders.certificates')}</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_uptime')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">{t('notificationProviders.uptime')}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('enabled')}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900 dark:text-gray-300">{t('common.enabled')}</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handlePreview}
|
||||
disabled={testMutation.isPending}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{t('notificationProviders.preview')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTest}
|
||||
disabled={testMutation.isPending}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{testMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> :
|
||||
testStatus === 'success' ? <Check className="w-4 h-4 text-green-500 mx-auto" /> :
|
||||
testStatus === 'error' ? <X className="w-4 h-4 text-red-500 mx-auto" /> :
|
||||
t('common.test')}
|
||||
</Button>
|
||||
<Button type="submit">{t('common.save')}</Button>
|
||||
</div>
|
||||
{previewError && <div className="mt-2 text-sm text-red-600">{t('notificationProviders.previewError')}: {previewError}</div>}
|
||||
{previewContent && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.previewResult')}</label>
|
||||
<pre className="mt-1 p-2 bg-gray-50 dark:bg-gray-800 rounded text-xs overflow-auto whitespace-pre-wrap">{previewContent}</pre>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateForm: FC<{
|
||||
initialData?: Partial<ExternalTemplate>;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: Partial<ExternalTemplate>) => void;
|
||||
}> = ({ initialData, onClose, onSubmit }) => {
|
||||
const { t } = useTranslation();
|
||||
const { register, handleSubmit, watch } = useForm({
|
||||
defaultValues: initialData || { template: 'custom', config: '' }
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [previewErr, setPreviewErr] = useState<string | null>(null);
|
||||
|
||||
const handlePreview = async () => {
|
||||
setPreview(null);
|
||||
setPreviewErr(null);
|
||||
const form = watch();
|
||||
try {
|
||||
const res = await previewExternalTemplate(undefined, form.config, { Title: 'Preview Title', Message: 'Preview Message', Time: new Date().toISOString(), EventType: 'preview' });
|
||||
if (res.parsed) setPreview(JSON.stringify(res.parsed, null, 2)); else setPreview(res.rendered);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setPreviewErr(msg || 'Preview failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('common.name')}</label>
|
||||
<input {...register('name', { required: true })} className="mt-1 block w-full rounded-md" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('common.description')}</label>
|
||||
<input {...register('description')} className="mt-1 block w-full rounded-md" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.templateType')}</label>
|
||||
<select {...register('template')} className="mt-1 block w-full rounded-md">
|
||||
<option value="minimal">{t('notificationProviders.minimal')}</option>
|
||||
<option value="detailed">{t('notificationProviders.detailed')}</option>
|
||||
<option value="custom">{t('notificationProviders.custom')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.configJson')}</label>
|
||||
<textarea {...register('config')} rows={6} className="mt-1 block w-full font-mono text-xs rounded-md" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button type="button" variant="secondary" onClick={handlePreview}>{t('notificationProviders.preview')}</Button>
|
||||
<Button type="submit">{t('common.save')}</Button>
|
||||
</div>
|
||||
{previewErr && <div className="text-sm text-red-600">{previewErr}</div>}
|
||||
{preview && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.preview')}</label>
|
||||
<pre className="mt-1 p-2 bg-gray-50 dark:bg-gray-800 rounded text-xs overflow-auto whitespace-pre-wrap">{preview}</pre>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Notifications: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [managingTemplates, setManagingTemplates] = useState(false);
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||
|
||||
const { data: providers, isLoading } = useQuery({
|
||||
queryKey: ['notificationProviders'],
|
||||
queryFn: getProviders,
|
||||
});
|
||||
|
||||
const { data: externalTemplates } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createProvider,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
setIsAdding(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<NotificationProvider> }) => updateProvider(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
setEditingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProvider,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
},
|
||||
});
|
||||
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: (data: Partial<ExternalTemplate>) => createExternalTemplate(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
|
||||
});
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<ExternalTemplate> }) => updateExternalTemplate(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
|
||||
});
|
||||
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteExternalTemplate(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testProvider,
|
||||
onSuccess: () => alert(t('notificationProviders.testSent')),
|
||||
onError: (err: Error) => alert(`${t('notificationProviders.testFailed')}: ${err.message}`),
|
||||
});
|
||||
|
||||
if (isLoading) return <div>{t('common.loading')}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Bell className="w-6 h-6" />
|
||||
{t('notificationProviders.title')}
|
||||
</h1>
|
||||
<Button onClick={() => setIsAdding(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('notificationProviders.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* External Templates Management */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('notificationProviders.externalTemplates')}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setManagingTemplates(!managingTemplates)} variant="secondary" size="sm">
|
||||
{managingTemplates ? t('notificationProviders.hideTemplates') : t('notificationProviders.manageTemplates')}
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingTemplateId(null); setManagingTemplates(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />{t('notificationProviders.newTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{managingTemplates && (
|
||||
<div className="space-y-4">
|
||||
{/* Template Form area */}
|
||||
{editingTemplateId !== null && (
|
||||
<Card className="p-4">
|
||||
<TemplateForm
|
||||
initialData={externalTemplates?.find((t: ExternalTemplate) => t.id === editingTemplateId) as Partial<ExternalTemplate>}
|
||||
onClose={() => setEditingTemplateId(null)}
|
||||
onSubmit={(data) => {
|
||||
if (editingTemplateId) updateTemplateMutation.mutate({ id: editingTemplateId, data });
|
||||
else createTemplateMutation.mutate(data as Partial<ExternalTemplate>);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create new when editingTemplateId is null and Manage Templates open -> show form */}
|
||||
{editingTemplateId === null && (
|
||||
<Card className="p-4">
|
||||
<h3 className="font-medium mb-2">{t('notificationProviders.createTemplate')}</h3>
|
||||
<TemplateForm
|
||||
onClose={() => setManagingTemplates(false)}
|
||||
onSubmit={(data) => createTemplateMutation.mutate(data as Partial<ExternalTemplate>)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* List of templates */}
|
||||
<div className="grid gap-3">
|
||||
{externalTemplates?.map((t_template: ExternalTemplate) => (
|
||||
<Card key={t_template.id} className="p-4 flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{t_template.name}</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">{t_template.description}</p>
|
||||
<pre className="mt-2 text-xs font-mono bg-gray-50 dark:bg-gray-800 p-2 rounded max-h-44 overflow-auto">{t_template.config}</pre>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 ml-4">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingTemplateId(t_template.id)}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => { if (confirm(t('notificationProviders.deleteTemplateConfirm'))) deleteTemplateMutation.mutate(t_template.id); }}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{externalTemplates?.length === 0 && (
|
||||
<div className="text-sm text-gray-500">{t('notificationProviders.noExternalTemplates')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdding && (
|
||||
<Card className="p-6 mb-6 border-blue-500 border-2">
|
||||
<h3 className="text-lg font-medium mb-4">{t('notificationProviders.addNewProvider')}</h3>
|
||||
<ProviderForm
|
||||
onClose={() => setIsAdding(false)}
|
||||
onSubmit={(data) => createMutation.mutate(data)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{providers?.map((provider) => (
|
||||
<Card key={provider.id} className="p-4">
|
||||
{editingId === provider.id ? (
|
||||
<ProviderForm
|
||||
initialData={provider}
|
||||
onClose={() => setEditingId(null)}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: provider.id, data })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2 rounded-full ${provider.enabled ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'}`}>
|
||||
<Bell className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{provider.name}</h3>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<span className="uppercase text-xs font-bold bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{provider.type}
|
||||
</span>
|
||||
<span className="truncate max-w-xs opacity-50">{provider.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => testMutation.mutate(provider)}
|
||||
isLoading={testMutation.isPending}
|
||||
title={t('notificationProviders.sendTest')}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditingId(provider.id)}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(t('notificationProviders.deleteConfirm'))) deleteMutation.mutate(provider.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{providers?.length === 0 && !isAdding && (
|
||||
<div className="text-center py-12 text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700">
|
||||
{t('notificationProviders.noProviders')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Gauge, Info } from 'lucide-react'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { useSecurityStatus, useSecurityConfig, useUpdateSecurityConfig } from '../hooks/useSecurity'
|
||||
import { updateSetting } from '../api/settings'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../utils/toast'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
export default function RateLimiting() {
|
||||
const { t } = useTranslation()
|
||||
const { data: status, isLoading: statusLoading } = useSecurityStatus()
|
||||
const { data: configData, isLoading: configLoading } = useSecurityConfig()
|
||||
const updateConfigMutation = useUpdateSecurityConfig()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [rps, setRps] = useState(10)
|
||||
const [burst, setBurst] = useState(5)
|
||||
const [window, setWindow] = useState(60)
|
||||
|
||||
const config = configData?.config
|
||||
|
||||
// Sync local state with fetched config
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setRps(config.rate_limit_requests ?? 10)
|
||||
setBurst(config.rate_limit_burst ?? 5)
|
||||
setWindow(config.rate_limit_window_sec ?? 60)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
await updateSetting('security.rate_limit.enabled', enabled ? 'true' : 'false', 'security', 'bool')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['securityStatus'] })
|
||||
toast.success(t('rateLimiting.settingUpdated'))
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`${t('common.failedToUpdate')}: ${err.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggle = () => {
|
||||
const newValue = !status?.rate_limit?.enabled
|
||||
toggleMutation.mutate(newValue)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateConfigMutation.mutate({
|
||||
rate_limit_requests: rps,
|
||||
rate_limit_burst: burst,
|
||||
rate_limit_window_sec: window,
|
||||
})
|
||||
}
|
||||
|
||||
const isApplyingConfig = toggleMutation.isPending || updateConfigMutation.isPending
|
||||
|
||||
if (statusLoading || configLoading) {
|
||||
return <div className="p-8 text-center text-white">{t('common.loading')}</div>
|
||||
}
|
||||
|
||||
const enabled = status?.rate_limit?.enabled ?? false
|
||||
|
||||
return (
|
||||
<>
|
||||
{isApplyingConfig && (
|
||||
<ConfigReloadOverlay
|
||||
message={t('rateLimiting.adjustingGates')}
|
||||
submessage={t('rateLimiting.configurationUpdating')}
|
||||
type="cerberus"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Gauge className="w-7 h-7 text-blue-400" />
|
||||
{t('rateLimiting.title')}
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
{t('rateLimiting.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-300 mb-1">
|
||||
{t('rateLimiting.aboutTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-200/90">
|
||||
{t('rateLimiting.aboutDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Settings Summary */}
|
||||
{enabled && config && (
|
||||
<Card className="bg-green-900/20 border-green-800/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-green-400 text-2xl">✓</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-green-300">{t('rateLimiting.currentlyActive')}</h3>
|
||||
<p className="text-sm text-green-200/90">
|
||||
{t('rateLimiting.activeSummary', {
|
||||
requests: config.rate_limit_requests,
|
||||
burst: config.rate_limit_burst,
|
||||
window: config.rate_limit_window_sec
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Enable/Disable Toggle */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{t('rateLimiting.enableRateLimiting')}</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{enabled
|
||||
? t('rateLimiting.activeDescription')
|
||||
: t('rateLimiting.disabledDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
className="sr-only peer"
|
||||
data-testid="rate-limit-toggle"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Configuration Section - Only visible when enabled */}
|
||||
{enabled && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">{t('rateLimiting.configuration')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
label={t('rateLimiting.requestsPerSecond')}
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={rps}
|
||||
onChange={(e) => setRps(parseInt(e.target.value, 10) || 1)}
|
||||
helperText={t('rateLimiting.requestsPerSecondHelper')}
|
||||
data-testid="rate-limit-rps"
|
||||
/>
|
||||
<Input
|
||||
label={t('rateLimiting.burst')}
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={burst}
|
||||
onChange={(e) => setBurst(parseInt(e.target.value, 10) || 1)}
|
||||
helperText={t('rateLimiting.burstHelper')}
|
||||
data-testid="rate-limit-burst"
|
||||
/>
|
||||
<Input
|
||||
label={t('rateLimiting.windowSeconds')}
|
||||
type="number"
|
||||
min={1}
|
||||
max={3600}
|
||||
value={window}
|
||||
onChange={(e) => setWindow(parseInt(e.target.value, 10) || 1)}
|
||||
helperText={t('rateLimiting.windowSecondsHelper')}
|
||||
data-testid="rate-limit-window"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
isLoading={updateConfigMutation.isPending}
|
||||
data-testid="save-rate-limit-btn"
|
||||
>
|
||||
{t('rateLimiting.saveConfiguration')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Guidance when disabled */}
|
||||
{!enabled && (
|
||||
<Card>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-gray-500 mb-4 text-4xl">⏱️</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{t('rateLimiting.disabledTitle')}</h3>
|
||||
<p className="text-gray-400 mb-4">
|
||||
{t('rateLimiting.disabledMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plus, Pencil, Trash2, Server, LayoutGrid, LayoutList } from 'lucide-react'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import type { RemoteServer } from '../api/remoteServers'
|
||||
import RemoteServerForm from '../components/RemoteServerForm'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Alert,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
SkeletonTable,
|
||||
SkeletonCard,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Card,
|
||||
type Column,
|
||||
} from '../components/ui'
|
||||
|
||||
export default function RemoteServers() {
|
||||
const { t } = useTranslation()
|
||||
const { servers, loading, error, createServer, updateServer, deleteServer } = useRemoteServers()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingServer, setEditingServer] = useState<RemoteServer | undefined>()
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<RemoteServer | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingServer(undefined)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (server: RemoteServer) => {
|
||||
setEditingServer(server)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: Partial<RemoteServer>) => {
|
||||
if (editingServer) {
|
||||
await updateServer(editingServer.uuid, data)
|
||||
} else {
|
||||
await createServer(data)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingServer(undefined)
|
||||
}
|
||||
|
||||
const handleDelete = async (server: RemoteServer) => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteServer(server.uuid)
|
||||
setDeleteConfirm(null)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<RemoteServer>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: t('remoteServers.columnName'),
|
||||
sortable: true,
|
||||
cell: (server) => (
|
||||
<span className="font-medium text-content-primary">{server.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
header: t('remoteServers.columnProvider'),
|
||||
sortable: true,
|
||||
cell: (server) => (
|
||||
<Badge variant="outline" size="sm">{server.provider}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'host',
|
||||
header: t('remoteServers.columnHost'),
|
||||
cell: (server) => (
|
||||
<span className="font-mono text-sm text-content-secondary">{server.host}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'port',
|
||||
header: t('remoteServers.columnPort'),
|
||||
cell: (server) => (
|
||||
<span className="font-mono text-sm text-content-secondary">{server.port}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('common.status'),
|
||||
sortable: true,
|
||||
cell: (server) => (
|
||||
<Badge variant={server.enabled ? 'success' : 'default'} size="sm">
|
||||
{server.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: t('common.actions'),
|
||||
cell: (server) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(server)
|
||||
}}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteConfirm(server)
|
||||
}}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-error" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Header actions
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex bg-surface-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-content-muted hover:text-content-primary'
|
||||
}`}
|
||||
title={t('remoteServers.gridView')}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-content-muted hover:text-content-primary'
|
||||
}`}
|
||||
title={t('remoteServers.listView')}
|
||||
>
|
||||
<LayoutList className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('remoteServers.addServer')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell
|
||||
title={t('remoteServers.title')}
|
||||
description={t('remoteServers.description')}
|
||||
actions={headerActions}
|
||||
>
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<SkeletonCard key={i} showImage={false} lines={4} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonTable rows={5} columns={6} />
|
||||
)}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('remoteServers.title')}
|
||||
description={t('remoteServers.description')}
|
||||
actions={headerActions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="error" title={t('common.error')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Server className="h-12 w-12" />}
|
||||
title={t('remoteServers.noServers')}
|
||||
description={t('remoteServers.noServersDescription')}
|
||||
action={{
|
||||
label: t('remoteServers.addServer'),
|
||||
onClick: handleAdd,
|
||||
}}
|
||||
/>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{servers.map((server) => (
|
||||
<Card key={server.uuid} className="flex flex-col">
|
||||
<div className="p-6 flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-content-primary mb-1">{server.name}</h3>
|
||||
<Badge variant="outline" size="sm">{server.provider}</Badge>
|
||||
</div>
|
||||
<Badge variant={server.enabled ? 'success' : 'default'} size="sm">
|
||||
{server.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-content-muted">{t('remoteServers.host')}:</span>
|
||||
<span className="text-content-primary font-mono">{server.host}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-content-muted">{t('remoteServers.port')}:</span>
|
||||
<span className="text-content-primary font-mono">{server.port}</span>
|
||||
</div>
|
||||
{server.username && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-content-muted">{t('remoteServers.user')}:</span>
|
||||
<span className="text-content-primary font-mono">{server.username}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 px-6 pb-6 pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleEdit(server)}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setDeleteConfirm(server)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={servers}
|
||||
columns={columns}
|
||||
rowKey={(server) => server.uuid}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Server className="h-12 w-12" />}
|
||||
title={t('remoteServers.noServers')}
|
||||
description={t('remoteServers.noServersDescription')}
|
||||
action={{
|
||||
label: t('remoteServers.addServer'),
|
||||
onClick: handleAdd,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteConfirm !== null} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('remoteServers.deleteServer')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-content-secondary py-4">
|
||||
{t('remoteServers.deleteConfirm', { name: deleteConfirm?.name })}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setDeleteConfirm(null)} disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? t('remoteServers.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Add/Edit Form Modal */}
|
||||
{showForm && (
|
||||
<RemoteServerForm
|
||||
server={editingServer}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingServer(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Label } from '../components/ui/Label'
|
||||
import { Alert } from '../components/ui/Alert'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
import { Skeleton } from '../components/ui/Skeleton'
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '../components/ui/Select'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getSMTPConfig, updateSMTPConfig, testSMTPConnection, sendTestEmail } from '../api/smtp'
|
||||
import type { SMTPConfigRequest } from '../api/smtp'
|
||||
import { Mail, Send, CheckCircle2, XCircle } from 'lucide-react'
|
||||
|
||||
export default function SMTPSettings() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [host, setHost] = useState('')
|
||||
const [port, setPort] = useState(587)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [fromAddress, setFromAddress] = useState('')
|
||||
const [encryption, setEncryption] = useState<'none' | 'ssl' | 'starttls'>('starttls')
|
||||
const [testEmail, setTestEmail] = useState('')
|
||||
|
||||
const { data: smtpConfig, isLoading } = useQuery({
|
||||
queryKey: ['smtp-config'],
|
||||
queryFn: getSMTPConfig,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (smtpConfig) {
|
||||
setHost(smtpConfig.host || '')
|
||||
setPort(smtpConfig.port || 587)
|
||||
setUsername(smtpConfig.username || '')
|
||||
setPassword(smtpConfig.password || '')
|
||||
setFromAddress(smtpConfig.from_address || '')
|
||||
setEncryption(smtpConfig.encryption || 'starttls')
|
||||
}
|
||||
}, [smtpConfig])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const config: SMTPConfigRequest = {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
from_address: fromAddress,
|
||||
encryption,
|
||||
}
|
||||
return updateSMTPConfig(config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['smtp-config'] })
|
||||
toast.success(t('smtp.settingsSaved'))
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('smtp.saveFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const testConnectionMutation = useMutation({
|
||||
mutationFn: testSMTPConnection,
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success(data.message || t('smtp.connectionSuccess'))
|
||||
} else {
|
||||
toast.error(data.error || t('smtp.connectionFailed'))
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('smtp.testFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const sendTestEmailMutation = useMutation({
|
||||
mutationFn: async () => sendTestEmail({ to: testEmail }),
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success(data.message || t('smtp.testEmailSent'))
|
||||
setTestEmail('')
|
||||
} else {
|
||||
toast.error(data.error || t('smtp.testEmailFailed'))
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('smtp.testEmailFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-7 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-80" />
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-500/10 rounded-lg">
|
||||
<Mail className="h-6 w-6 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-content-primary">{t('smtp.title')}</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-content-secondary">
|
||||
{t('smtp.description')}
|
||||
</p>
|
||||
|
||||
{/* SMTP Configuration Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('smtp.configuration')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('smtp.configDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-host" required>{t('smtp.host')}</Label>
|
||||
<Input
|
||||
id="smtp-host"
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-port" required>{t('smtp.port')}</Label>
|
||||
<Input
|
||||
id="smtp-port"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(parseInt(e.target.value) || 587)}
|
||||
placeholder="587"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-username">{t('smtp.username')}</Label>
|
||||
<Input
|
||||
id="smtp-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-password">{t('smtp.password')}</Label>
|
||||
<Input
|
||||
id="smtp-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
helperText={t('smtp.passwordHelper')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-from" required>{t('smtp.fromAddress')}</Label>
|
||||
<Input
|
||||
id="smtp-from"
|
||||
type="email"
|
||||
value={fromAddress}
|
||||
onChange={(e) => setFromAddress(e.target.value)}
|
||||
placeholder="Charon <no-reply@example.com>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-encryption">{t('smtp.encryption')}</Label>
|
||||
<Select value={encryption} onValueChange={(value) => setEncryption(value as 'none' | 'ssl' | 'starttls')}>
|
||||
<SelectTrigger id="smtp-encryption">
|
||||
<SelectValue placeholder={t('smtp.selectEncryption')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="starttls">{t('smtp.starttls')}</SelectItem>
|
||||
<SelectItem value="ssl">{t('smtp.sslTls')}</SelectItem>
|
||||
<SelectItem value="none">{t('smtp.none')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => testConnectionMutation.mutate()}
|
||||
isLoading={testConnectionMutation.isPending}
|
||||
disabled={!host || !fromAddress}
|
||||
>
|
||||
{t('smtp.testConnection')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
isLoading={saveMutation.isPending}
|
||||
>
|
||||
{t('smtp.saveSettings')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{smtpConfig?.configured ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
<span className="font-medium text-content-primary">{t('smtp.configured')}</span>
|
||||
<Badge variant="success" size="sm">{t('smtp.active')}</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-warning" />
|
||||
<span className="font-medium text-content-primary">{t('smtp.notConfigured')}</span>
|
||||
<Badge variant="warning" size="sm">{t('smtp.inactive')}</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test Email */}
|
||||
{smtpConfig?.configured && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('smtp.sendTestEmail')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('smtp.testEmailDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => sendTestEmailMutation.mutate()}
|
||||
isLoading={sendTestEmailMutation.isPending}
|
||||
disabled={!testEmail}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{t('smtp.sendTest')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Alert */}
|
||||
<Alert variant="info" title={t('smtp.needHelp')}>
|
||||
{t('smtp.helpText')}
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, Outlet } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink, Settings } from 'lucide-react'
|
||||
import { getSecurityStatus, type SecurityStatus } from '../api/security'
|
||||
import { useSecurityConfig, useUpdateSecurityConfig, useGenerateBreakGlassToken } from '../hooks/useSecurity'
|
||||
import { startCrowdsec, stopCrowdsec, statusCrowdsec } from '../api/crowdsec'
|
||||
import { updateSetting } from '../api/settings'
|
||||
import { toast } from '../utils/toast'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
import { LiveLogViewer } from '../components/LiveLogViewer'
|
||||
import { SecurityNotificationSettingsModal } from '../components/SecurityNotificationSettingsModal'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Button,
|
||||
Badge,
|
||||
Alert,
|
||||
Switch,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from '../components/ui'
|
||||
|
||||
// Skeleton loader for security layer cards
|
||||
function SecurityCardSkeleton() {
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between pt-4">
|
||||
<Skeleton className="h-5 w-10" />
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading skeleton for the entire security page
|
||||
function SecurityPageSkeleton({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<PageShell
|
||||
title={t('security.title')}
|
||||
description={t('security.description')}
|
||||
>
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<SecurityCardSkeleton />
|
||||
<SecurityCardSkeleton />
|
||||
<SecurityCardSkeleton />
|
||||
<SecurityCardSkeleton />
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['security-status'],
|
||||
queryFn: getSecurityStatus,
|
||||
})
|
||||
const { data: securityConfig } = useSecurityConfig()
|
||||
const [adminWhitelist, setAdminWhitelist] = useState<string>('')
|
||||
const [showNotificationSettings, setShowNotificationSettings] = useState(false)
|
||||
useEffect(() => {
|
||||
if (securityConfig && securityConfig.config) {
|
||||
setAdminWhitelist(securityConfig.config.admin_whitelist || '')
|
||||
}
|
||||
}, [securityConfig])
|
||||
const updateSecurityConfigMutation = useUpdateSecurityConfig()
|
||||
const generateBreakGlassMutation = useGenerateBreakGlassToken()
|
||||
const queryClient = useQueryClient()
|
||||
const [crowdsecStatus, setCrowdsecStatus] = useState<{ running: boolean; pid?: number } | null>(null)
|
||||
// Stable reference to prevent WebSocket reconnection loops in LiveLogViewer
|
||||
const emptySecurityFilters = useMemo(() => ({}), [])
|
||||
// Generic toggle mutation for per-service settings
|
||||
const toggleServiceMutation = useMutation({
|
||||
mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => {
|
||||
await updateSetting(key, enabled ? 'true' : 'false', 'security', 'bool')
|
||||
},
|
||||
onMutate: async ({ key, enabled }: { key: string; enabled: boolean }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['security-status'] })
|
||||
const previous = queryClient.getQueryData(['security-status'])
|
||||
queryClient.setQueryData(['security-status'], (old: unknown) => {
|
||||
if (!old || typeof old !== 'object') return old
|
||||
const parts = key.split('.')
|
||||
const section = parts[1] as keyof SecurityStatus
|
||||
const field = parts[2]
|
||||
const copy = { ...(old as SecurityStatus) }
|
||||
if (copy[section] && typeof copy[section] === 'object') {
|
||||
copy[section] = { ...copy[section], [field]: enabled } as never
|
||||
}
|
||||
return copy
|
||||
})
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _vars, context: unknown) => {
|
||||
if (context && typeof context === 'object' && 'previous' in context) {
|
||||
queryClient.setQueryData(['security-status'], context.previous)
|
||||
}
|
||||
const msg = _err instanceof Error ? _err.message : String(_err)
|
||||
toast.error(`Failed to update setting: ${msg}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
toast.success('Security setting updated')
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
const fetchCrowdsecStatus = async () => {
|
||||
|
||||
try {
|
||||
const s = await statusCrowdsec()
|
||||
setCrowdsecStatus(s)
|
||||
} catch {
|
||||
setCrowdsecStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCrowdsecStatus() }, [])
|
||||
|
||||
|
||||
|
||||
const crowdsecPowerMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
// Update setting first
|
||||
await updateSetting('security.crowdsec.enabled', enabled ? 'true' : 'false', 'security', 'bool')
|
||||
|
||||
if (enabled) {
|
||||
toast.info('Starting CrowdSec... This may take up to 30 seconds')
|
||||
const result = await startCrowdsec()
|
||||
|
||||
// VERIFY: Check if it actually started
|
||||
const status = await statusCrowdsec()
|
||||
if (!status.running) {
|
||||
// Revert the setting since process didn't start
|
||||
await updateSetting('security.crowdsec.enabled', 'false', 'security', 'bool')
|
||||
throw new Error('CrowdSec process failed to start. Check server logs for details.')
|
||||
}
|
||||
|
||||
return result
|
||||
} else {
|
||||
await stopCrowdsec()
|
||||
|
||||
// VERIFY: Check if it actually stopped (with brief delay for cleanup)
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
const status = await statusCrowdsec()
|
||||
if (status.running) {
|
||||
throw new Error('CrowdSec process still running. Check server logs for details.')
|
||||
}
|
||||
|
||||
return { enabled: false }
|
||||
}
|
||||
},
|
||||
// NO optimistic updates - wait for actual confirmation
|
||||
onError: (err: unknown, enabled: boolean) => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(enabled ? `Failed to start CrowdSec: ${msg}` : `Failed to stop CrowdSec: ${msg}`)
|
||||
// Force refresh status from backend to ensure UI matches reality
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
fetchCrowdsecStatus()
|
||||
},
|
||||
onSuccess: async (result: { lapi_ready?: boolean; enabled?: boolean } | boolean) => {
|
||||
// Refresh all related queries to ensure consistency
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] }),
|
||||
fetchCrowdsecStatus(),
|
||||
])
|
||||
|
||||
if (typeof result === 'object' && result.lapi_ready === true) {
|
||||
toast.success('CrowdSec started and LAPI is ready')
|
||||
} else if (typeof result === 'object' && result.lapi_ready === false) {
|
||||
toast.warning('CrowdSec started but LAPI is still initializing. Please wait before enrolling.')
|
||||
} else if (typeof result === 'object' && result.enabled === false) {
|
||||
toast.success('CrowdSec stopped')
|
||||
} else {
|
||||
toast.success('CrowdSec started')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Determine if any security operation is in progress
|
||||
const isApplyingConfig =
|
||||
toggleServiceMutation.isPending ||
|
||||
updateSecurityConfigMutation.isPending ||
|
||||
generateBreakGlassMutation.isPending ||
|
||||
crowdsecPowerMutation.isPending
|
||||
|
||||
// Determine contextual message
|
||||
const getMessage = () => {
|
||||
if (toggleServiceMutation.isPending) {
|
||||
return { message: t('security.threeHeadsTurn'), submessage: t('security.cerberusConfigUpdating') }
|
||||
}
|
||||
if (crowdsecPowerMutation.isPending) {
|
||||
return crowdsecPowerMutation.variables
|
||||
? { message: t('security.summoningGuardian'), submessage: t('security.crowdsecStarting') }
|
||||
: { message: t('security.guardianRests'), submessage: t('security.crowdsecStopping') }
|
||||
}
|
||||
return { message: t('security.strengtheningGuard'), submessage: t('security.wardsActivating') }
|
||||
}
|
||||
|
||||
const { message, submessage } = getMessage()
|
||||
|
||||
if (isLoading) {
|
||||
return <SecurityPageSkeleton t={t} />
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<PageShell
|
||||
title={t('security.title')}
|
||||
description={t('security.description')}
|
||||
>
|
||||
<Alert variant="error" title={t('common.error')}>
|
||||
{t('security.failedToLoadConfiguration')}
|
||||
</Alert>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const cerberusDisabled = !status.cerberus?.enabled
|
||||
const crowdsecToggleDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
|
||||
const crowdsecControlsDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
|
||||
|
||||
// Header actions
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowNotificationSettings(true)}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t('security.notifications')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
{t('common.docs')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{isApplyingConfig && (
|
||||
<ConfigReloadOverlay
|
||||
message={message}
|
||||
submessage={submessage}
|
||||
type="cerberus"
|
||||
/>
|
||||
)}
|
||||
<PageShell
|
||||
title={t('security.title')}
|
||||
description={t('security.description')}
|
||||
actions={headerActions}
|
||||
>
|
||||
{/* Cerberus Status Header */}
|
||||
<Card className="flex items-center gap-4 p-6">
|
||||
<div className={`p-3 rounded-lg ${status.cerberus?.enabled ? 'bg-success/10' : 'bg-surface-muted'}`}>
|
||||
<ShieldCheck className={`w-8 h-8 ${status.cerberus?.enabled ? 'text-success' : 'text-content-muted'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-content-primary">{t('security.cerberusDashboard')}</h2>
|
||||
<Badge variant={status.cerberus?.enabled ? 'success' : 'default'}>
|
||||
{status.cerberus?.enabled ? t('security.cerberusActive') : t('security.cerberusDisabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-content-secondary mt-1">
|
||||
{status.cerberus?.enabled
|
||||
? t('security.cerberusReadyMessage')
|
||||
: t('security.cerberusDisabledMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Cerberus Disabled Alert */}
|
||||
{!status.cerberus?.enabled && (
|
||||
<Alert variant="warning" title={t('security.featuresUnavailable')}>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t('security.featuresUnavailableMessage')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
|
||||
className="mt-2"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1.5" />
|
||||
{t('security.learnMore')}
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Admin Whitelist Section */}
|
||||
{status.cerberus?.enabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('security.adminWhitelist')}</CardTitle>
|
||||
<CardDescription>{t('security.adminWhitelistDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<label className="text-sm text-content-secondary">{t('security.commaSeparatedCIDR')}</label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
className="flex-1 px-3 py-2 rounded-md border border-border bg-surface-elevated text-content-primary focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
value={adminWhitelist}
|
||||
onChange={(e) => setAdminWhitelist(e.target.value)}
|
||||
placeholder="192.168.1.0/24, 10.0.0.1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => updateSecurityConfigMutation.mutate({ name: 'default', admin_whitelist: adminWhitelist })}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => generateBreakGlassMutation.mutate()}
|
||||
>
|
||||
{t('security.generateToken')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('security.generateTokenTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Outlet />
|
||||
|
||||
{/* Security Layer Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* CrowdSec - Layer 1: IP Reputation */}
|
||||
<Card variant="interactive" className="flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" size="sm">{t('security.layer1')}</Badge>
|
||||
<Badge variant="primary" size="sm">{t('security.ids')}</Badge>
|
||||
</div>
|
||||
<Badge variant={(crowdsecStatus?.running ?? status.crowdsec.enabled) ? 'success' : 'default'}>
|
||||
{(crowdsecStatus?.running ?? status.crowdsec.enabled) ? t('common.enabled') : t('common.disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div className={`p-2 rounded-lg ${(crowdsecStatus?.running ?? status.crowdsec.enabled) ? 'bg-success/10' : 'bg-surface-muted'}`}>
|
||||
<ShieldAlert className={`w-5 h-5 ${(crowdsecStatus?.running ?? status.crowdsec.enabled) ? 'text-success' : 'text-content-muted'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('security.crowdsec')}</CardTitle>
|
||||
<CardDescription>{t('security.crowdsecDescription')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className="text-sm text-content-muted">
|
||||
{(crowdsecStatus?.running ?? status.crowdsec.enabled)
|
||||
? t('security.crowdsecProtects')
|
||||
: t('security.crowdsecDisabledDescription')}
|
||||
</p>
|
||||
{crowdsecStatus && (
|
||||
<p className="text-xs text-content-muted mt-2">
|
||||
{crowdsecStatus.running ? t('security.runningPid', { pid: crowdsecStatus.pid }) : t('security.processStopped')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between pt-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={crowdsecStatus?.running ?? status.crowdsec.enabled}
|
||||
disabled={crowdsecToggleDisabled}
|
||||
onChange={(e) => crowdsecPowerMutation.mutate(e.target.checked)}
|
||||
data-testid="toggle-crowdsec"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleCrowdsec')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/security/crowdsec')}
|
||||
disabled={crowdsecControlsDisabled}
|
||||
>
|
||||
{t('common.configure')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* ACL - Layer 2: Access Control */}
|
||||
<Card variant="interactive" className="flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" size="sm">{t('security.layer2')}</Badge>
|
||||
<Badge variant="primary" size="sm">{t('security.acl')}</Badge>
|
||||
</div>
|
||||
<Badge variant={status.acl.enabled ? 'success' : 'default'}>
|
||||
{status.acl.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div className={`p-2 rounded-lg ${status.acl.enabled ? 'bg-success/10' : 'bg-surface-muted'}`}>
|
||||
<Lock className={`w-5 h-5 ${status.acl.enabled ? 'text-success' : 'text-content-muted'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('security.accessControl')}</CardTitle>
|
||||
<CardDescription>{t('security.aclDescription')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('security.aclProtects')}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between pt-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={status.acl.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.acl.enabled', enabled: e.target.checked })}
|
||||
data-testid="toggle-acl"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleAcl')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/security/access-lists')}
|
||||
>
|
||||
{status.acl.enabled ? t('security.manageLists') : t('common.configure')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Coraza - Layer 3: Request Inspection */}
|
||||
<Card variant="interactive" className="flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" size="sm">{t('security.layer3')}</Badge>
|
||||
<Badge variant="primary" size="sm">{t('security.waf')}</Badge>
|
||||
</div>
|
||||
<Badge variant={status.waf.enabled ? 'success' : 'default'}>
|
||||
{status.waf.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div className={`p-2 rounded-lg ${status.waf.enabled ? 'bg-success/10' : 'bg-surface-muted'}`}>
|
||||
<Shield className={`w-5 h-5 ${status.waf.enabled ? 'text-success' : 'text-content-muted'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('security.corazaWaf')}</CardTitle>
|
||||
<CardDescription>{t('security.wafDescription')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className="text-sm text-content-muted">
|
||||
{status.waf.enabled
|
||||
? t('security.wafProtects')
|
||||
: t('security.wafDisabledDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between pt-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={status.waf.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.waf.enabled', enabled: e.target.checked })}
|
||||
data-testid="toggle-waf"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleWaf')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/security/waf')}
|
||||
>
|
||||
{t('common.configure')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Rate Limiting - Layer 4: Volume Control */}
|
||||
<Card variant="interactive" className="flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" size="sm">{t('security.layer4')}</Badge>
|
||||
<Badge variant="primary" size="sm">{t('security.rate')}</Badge>
|
||||
</div>
|
||||
<Badge variant={status.rate_limit.enabled ? 'success' : 'default'}>
|
||||
{status.rate_limit.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div className={`p-2 rounded-lg ${status.rate_limit.enabled ? 'bg-success/10' : 'bg-surface-muted'}`}>
|
||||
<Activity className={`w-5 h-5 ${status.rate_limit.enabled ? 'text-success' : 'text-content-muted'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('security.rateLimiting')}</CardTitle>
|
||||
<CardDescription>{t('security.rateLimitDescription')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('security.rateLimitProtects')}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between pt-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={status.rate_limit.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.rate_limit.enabled', enabled: e.target.checked })}
|
||||
data-testid="toggle-rate-limit"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{cerberusDisabled ? t('security.enableCerberusFirst') : t('security.toggleRateLimit')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/security/rate-limiting')}
|
||||
>
|
||||
{t('common.configure')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Live Activity Section */}
|
||||
{status.cerberus?.enabled && (
|
||||
<LiveLogViewer mode="security" securityFilters={emptySecurityFilters} className="w-full" />
|
||||
)}
|
||||
|
||||
{/* Notification Settings Modal */}
|
||||
<SecurityNotificationSettingsModal
|
||||
isOpen={showNotificationSettings}
|
||||
onClose={() => setShowNotificationSettings(false)}
|
||||
/>
|
||||
</PageShell>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Shield, Copy, Eye, Info } from 'lucide-react';
|
||||
import {
|
||||
useSecurityHeaderProfiles,
|
||||
useCreateSecurityHeaderProfile,
|
||||
useUpdateSecurityHeaderProfile,
|
||||
useDeleteSecurityHeaderProfile,
|
||||
} from '../hooks/useSecurityHeaders';
|
||||
import { SecurityHeaderProfileForm } from '../components/SecurityHeaderProfileForm';
|
||||
import { SecurityScoreDisplay } from '../components/SecurityScoreDisplay';
|
||||
import type { SecurityHeaderProfile, CreateProfileRequest } from '../api/securityHeaders';
|
||||
import { createBackup } from '../api/backups';
|
||||
import toast from 'react-hot-toast';
|
||||
import { PageShell } from '../components/layout/PageShell';
|
||||
import {
|
||||
Button,
|
||||
Alert,
|
||||
Card,
|
||||
EmptyState,
|
||||
SkeletonTable,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from '../components/ui';
|
||||
|
||||
export default function SecurityHeaders() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles, isLoading } = useSecurityHeaderProfiles();
|
||||
const createMutation = useCreateSecurityHeaderProfile();
|
||||
const updateMutation = useUpdateSecurityHeaderProfile();
|
||||
const deleteMutation = useDeleteSecurityHeaderProfile();
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<SecurityHeaderProfile | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<SecurityHeaderProfile | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleCreate = (data: CreateProfileRequest) => {
|
||||
createMutation.mutate(data, {
|
||||
onSuccess: () => setShowCreateForm(false),
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdate = (data: CreateProfileRequest) => {
|
||||
if (!editingProfile) return;
|
||||
updateMutation.mutate(
|
||||
{ id: editingProfile.id, data },
|
||||
{
|
||||
onSuccess: () => setEditingProfile(null),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteWithBackup = async (profile: SecurityHeaderProfile) => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
toast.loading(t('securityHeaders.creatingBackup'), { id: 'backup-toast' });
|
||||
await createBackup();
|
||||
toast.success(t('securityHeaders.backupCreated'), { id: 'backup-toast' });
|
||||
|
||||
deleteMutation.mutate(profile.id, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteConfirm(null);
|
||||
setEditingProfile(null);
|
||||
toast.success(t('securityHeaders.deleteSuccess', { name: profile.name }));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('securityHeaders.deleteFailed', { error: error.message }));
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsDeleting(false);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error(t('securityHeaders.backupFailed'), { id: 'backup-toast' });
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneProfile = (profile: SecurityHeaderProfile) => {
|
||||
const clonedData: CreateProfileRequest = {
|
||||
name: `${profile.name} (Copy)`,
|
||||
description: profile.description,
|
||||
hsts_enabled: profile.hsts_enabled,
|
||||
hsts_max_age: profile.hsts_max_age,
|
||||
hsts_include_subdomains: profile.hsts_include_subdomains,
|
||||
hsts_preload: profile.hsts_preload,
|
||||
csp_enabled: profile.csp_enabled,
|
||||
csp_directives: profile.csp_directives,
|
||||
csp_report_only: profile.csp_report_only,
|
||||
csp_report_uri: profile.csp_report_uri,
|
||||
x_frame_options: profile.x_frame_options,
|
||||
x_content_type_options: profile.x_content_type_options,
|
||||
referrer_policy: profile.referrer_policy,
|
||||
permissions_policy: profile.permissions_policy,
|
||||
cross_origin_opener_policy: profile.cross_origin_opener_policy,
|
||||
cross_origin_resource_policy: profile.cross_origin_resource_policy,
|
||||
cross_origin_embedder_policy: profile.cross_origin_embedder_policy,
|
||||
xss_protection: profile.xss_protection,
|
||||
cache_control_no_store: profile.cache_control_no_store,
|
||||
};
|
||||
|
||||
createMutation.mutate(clonedData);
|
||||
};
|
||||
|
||||
const customProfiles = profiles?.filter((p: SecurityHeaderProfile) => !p.is_preset) || [];
|
||||
const presetProfiles = (profiles?.filter((p: SecurityHeaderProfile) => p.is_preset) || [])
|
||||
.sort((a, b) => a.security_score - b.security_score);
|
||||
|
||||
// Get tooltip content for preset types
|
||||
const getPresetTooltip = (presetType: string): string => {
|
||||
switch (presetType) {
|
||||
case 'basic':
|
||||
return t('securityHeaders.presets.basic');
|
||||
case 'api-friendly':
|
||||
return t('securityHeaders.presets.apiFriendly');
|
||||
case 'strict':
|
||||
return t('securityHeaders.presets.strict');
|
||||
case 'paranoid':
|
||||
return t('securityHeaders.presets.paranoid');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('securityHeaders.title')}
|
||||
description={t('securityHeaders.description')}
|
||||
actions={
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('securityHeaders.createProfile')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* Info Alert */}
|
||||
<Alert variant="info" className="mb-6">
|
||||
<Shield className="w-4 h-4" />
|
||||
<div>
|
||||
<p className="font-semibold">{t('securityHeaders.alertTitle')}</p>
|
||||
<p className="text-sm mt-1">
|
||||
{t('securityHeaders.alertDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
{/* Quick Presets (Read-Only) */}
|
||||
{presetProfiles.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('securityHeaders.systemProfiles')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('securityHeaders.systemProfilesDescription')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<TooltipProvider>
|
||||
{presetProfiles.map((profile: SecurityHeaderProfile) => (
|
||||
<Card key={profile.id} className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{profile.name}</h3>
|
||||
{profile.preset_type && getPresetTooltip(profile.preset_type) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs whitespace-pre-line text-left">
|
||||
{getPresetTooltip(profile.preset_type)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SecurityScoreDisplay
|
||||
score={profile.security_score}
|
||||
size="sm"
|
||||
showDetails={false}
|
||||
/>
|
||||
</div>
|
||||
{profile.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{profile.description}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingProfile(profile)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" /> {t('securityHeaders.view')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCloneProfile(profile)}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" /> {t('securityHeaders.clone')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Profiles Section */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{t('securityHeaders.customProfiles')}</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<SkeletonTable rows={3} />
|
||||
) : customProfiles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Shield className="w-12 h-12" />}
|
||||
title={t('securityHeaders.noCustomProfiles')}
|
||||
description={t('securityHeaders.noCustomProfilesDescription')}
|
||||
action={{
|
||||
label: t('securityHeaders.createProfile'),
|
||||
onClick: () => setShowCreateForm(true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{customProfiles.map((profile: SecurityHeaderProfile) => (
|
||||
<Card key={profile.id} className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{profile.name}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('securityHeaders.updated')} {new Date(profile.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<SecurityScoreDisplay
|
||||
score={profile.security_score}
|
||||
size="sm"
|
||||
showDetails={false}
|
||||
/>
|
||||
</div>
|
||||
{profile.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{profile.description}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingProfile(profile)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Pencil className="w-3 h-3 mr-1" />
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCloneProfile(profile)}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(profile)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={showCreateForm || editingProfile !== null} onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
setShowCreateForm(false);
|
||||
setEditingProfile(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProfile
|
||||
? (editingProfile.is_preset ? t('securityHeaders.viewProfile') : t('securityHeaders.editProfile'))
|
||||
: t('securityHeaders.createProfileTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SecurityHeaderProfileForm
|
||||
initialData={editingProfile || undefined}
|
||||
onSubmit={editingProfile ? handleUpdate : handleCreate}
|
||||
onCancel={() => {
|
||||
setShowCreateForm(false);
|
||||
setEditingProfile(null);
|
||||
}}
|
||||
onDelete={editingProfile && !editingProfile.is_preset ? () => setShowDeleteConfirm(editingProfile) : undefined}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm !== null} onOpenChange={(open: boolean) => !open && setShowDeleteConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('securityHeaders.confirmDeletion')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('securityHeaders.deleteConfirmMessage', { name: showDeleteConfirm?.name })}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowDeleteConfirm(null)} disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => showDeleteConfirm && handleDeleteWithBackup(showDeleteConfirm)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? t('securityHeaders.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import { cn } from '../utils/cn'
|
||||
import { Settings as SettingsIcon, Server, Mail, User, Bell } from 'lucide-react'
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
const navItems = [
|
||||
{ path: '/settings/system', label: t('settings.system'), icon: Server },
|
||||
{ path: '/settings/notifications', label: t('navigation.notifications'), icon: Bell },
|
||||
{ path: '/settings/smtp', label: t('settings.smtp'), icon: Mail },
|
||||
{ path: '/settings/account', label: t('settings.account'), icon: User },
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t('settings.title')}
|
||||
description={t('settings.description')}
|
||||
actions={
|
||||
<div className="flex items-center gap-2 text-content-muted">
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Tab Navigation */}
|
||||
<nav className="flex items-center gap-1 p-1 bg-surface-subtle rounded-lg w-fit">
|
||||
{navItems.map(({ path, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all duration-fast',
|
||||
isActive(path)
|
||||
? 'bg-surface-elevated text-content-primary shadow-sm'
|
||||
: 'text-content-secondary hover:text-content-primary hover:bg-surface-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="bg-surface-elevated border border-border rounded-lg p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect, type FormEvent, type FC } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getSetupStatus, performSetup, SetupRequest } from '../api/setup';
|
||||
import client from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter';
|
||||
import { isValidEmail } from '../utils/validation';
|
||||
|
||||
const Setup: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const [formData, setFormData] = useState<SetupRequest>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailValid, setEmailValid] = useState<boolean | null>(null);
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.email) {
|
||||
setEmailValid(isValidEmail(formData.email));
|
||||
} else {
|
||||
setEmailValid(null);
|
||||
}
|
||||
}, [formData.email]);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for setup status to load
|
||||
if (statusLoading) return;
|
||||
|
||||
// If setup is required, stay on this page (ignore stale auth)
|
||||
if (status?.setupRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If setup is NOT required, redirect based on auth
|
||||
if (isAuthenticated) {
|
||||
navigate('/');
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [status, statusLoading, isAuthenticated, navigate]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: SetupRequest) => {
|
||||
// 1. Perform Setup
|
||||
await performSetup(data);
|
||||
// 2. Auto Login
|
||||
await client.post('/auth/login', { email: data.email, password: data.password });
|
||||
// 3. Update Auth Context
|
||||
await login();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['setupStatus'] });
|
||||
navigate('/');
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message || t('setup.setupFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate(formData);
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-blue-500">{t('common.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.setupRequired) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
|
||||
<div className="flex flex-col items-center">
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
|
||||
|
||||
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{t('setup.welcomeTitle')}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('setup.welcomeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
label={t('setup.nameLabel')}
|
||||
type="text"
|
||||
required
|
||||
placeholder={t('setup.namePlaceholder')}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
label={t('setup.emailLabel')}
|
||||
type="email"
|
||||
required
|
||||
placeholder={t('setup.emailPlaceholder')}
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className={emailValid === false ? 'border-red-500 focus:ring-red-500' : emailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{emailValid === false && (
|
||||
<p className="mt-1 text-xs text-red-500">{t('setup.invalidEmail')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
label={t('setup.passwordLabel')}
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordStrengthMeter password={formData.password} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={mutation.isPending}
|
||||
>
|
||||
{t('setup.createAdminButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
@@ -0,0 +1,563 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { Label } from '../components/ui/Label'
|
||||
import { Alert, AlertDescription } from '../components/ui/Alert'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
import { Skeleton } from '../components/ui/Skeleton'
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '../components/ui/Select'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../components/ui/Tooltip'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getSettings, updateSetting, testPublicURL } from '../api/settings'
|
||||
import { getFeatureFlags, updateFeatureFlags } from '../api/featureFlags'
|
||||
import client from '../api/client'
|
||||
import { Server, RefreshCw, Save, Activity, Info, ExternalLink, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
import { WebSocketStatusCard } from '../components/WebSocketStatusCard'
|
||||
import { LanguageSelector } from '../components/LanguageSelector'
|
||||
import { cn } from '../utils/cn'
|
||||
|
||||
interface HealthResponse {
|
||||
status: string
|
||||
service: string
|
||||
version: string
|
||||
git_commit: string
|
||||
build_time: string
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
current_version: string
|
||||
latest_version: string
|
||||
update_available: boolean
|
||||
release_url?: string
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [caddyAdminAPI, setCaddyAdminAPI] = useState('http://localhost:2019')
|
||||
const [sslProvider, setSslProvider] = useState('auto')
|
||||
const [domainLinkBehavior, setDomainLinkBehavior] = useState('new_tab')
|
||||
const [publicURL, setPublicURL] = useState('')
|
||||
const [publicURLValid, setPublicURLValid] = useState<boolean | null>(null)
|
||||
const [publicURLSaving, setPublicURLSaving] = useState(false)
|
||||
|
||||
// Fetch Settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Update local state when settings load
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
if (settings['caddy.admin_api']) setCaddyAdminAPI(settings['caddy.admin_api'])
|
||||
// Default to 'auto' if empty or invalid value
|
||||
if (settings['caddy.ssl_provider']) {
|
||||
const validProviders = ['auto', 'letsencrypt-staging', 'letsencrypt-prod', 'zerossl']
|
||||
const provider = settings['caddy.ssl_provider']
|
||||
setSslProvider(validProviders.includes(provider) ? provider : 'auto')
|
||||
}
|
||||
if (settings['ui.domain_link_behavior']) setDomainLinkBehavior(settings['ui.domain_link_behavior'])
|
||||
if (settings['app.public_url']) setPublicURL(settings['app.public_url'])
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
// Validate Public URL with debouncing
|
||||
const validatePublicURL = async (url: string) => {
|
||||
if (!url) {
|
||||
setPublicURLValid(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await client.post('/settings/validate-url', { url })
|
||||
setPublicURLValid(response.data.valid)
|
||||
} catch {
|
||||
setPublicURLValid(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce validation
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (publicURL) {
|
||||
validatePublicURL(publicURL)
|
||||
}
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [publicURL])
|
||||
|
||||
// Fetch Health/System Status
|
||||
const { data: health, isLoading: isLoadingHealth } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async (): Promise<HealthResponse> => {
|
||||
const response = await client.get<HealthResponse>('/health')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
// Test Public URL - Server-side connectivity test with SSRF protection
|
||||
const testPublicURLHandler = async () => {
|
||||
if (!publicURL) {
|
||||
toast.error(t('systemSettings.applicationUrl.invalidUrl'))
|
||||
return
|
||||
}
|
||||
setPublicURLSaving(true)
|
||||
try {
|
||||
const result = await testPublicURL(publicURL)
|
||||
if (result.reachable) {
|
||||
toast.success(
|
||||
result.message || `URL reachable (${result.latency?.toFixed(0)}ms)`
|
||||
)
|
||||
} else {
|
||||
toast.error(result.error || 'URL not reachable')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Test failed')
|
||||
} finally {
|
||||
setPublicURLSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Updates
|
||||
const {
|
||||
data: updateInfo,
|
||||
refetch: checkUpdates,
|
||||
isFetching: isCheckingUpdates,
|
||||
} = useQuery({
|
||||
queryKey: ['updates'],
|
||||
queryFn: async (): Promise<UpdateInfo> => {
|
||||
const response = await client.get<UpdateInfo>('/system/updates')
|
||||
return response.data
|
||||
},
|
||||
enabled: false, // Manual trigger
|
||||
})
|
||||
|
||||
const saveSettingsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateSetting('caddy.admin_api', caddyAdminAPI, 'caddy', 'string')
|
||||
await updateSetting('caddy.ssl_provider', sslProvider, 'caddy', 'string')
|
||||
await updateSetting('ui.domain_link_behavior', domainLinkBehavior, 'ui', 'string')
|
||||
await updateSetting('app.public_url', publicURL, 'general', 'string')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success(t('systemSettings.settingsSaved'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('systemSettings.settingsFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
// Feature Flags
|
||||
const { data: featureFlags, refetch: refetchFlags } = useQuery({
|
||||
queryKey: ['feature-flags'],
|
||||
queryFn: getFeatureFlags,
|
||||
})
|
||||
|
||||
const featureToggles = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'feature.cerberus.enabled',
|
||||
label: t('systemSettings.features.cerberus'),
|
||||
tooltip: t('systemSettings.features.cerberusTooltip'),
|
||||
},
|
||||
{
|
||||
key: 'feature.crowdsec.console_enrollment',
|
||||
label: t('systemSettings.features.crowdsecConsole'),
|
||||
tooltip: t('systemSettings.features.crowdsecConsoleTooltip'),
|
||||
},
|
||||
{
|
||||
key: 'feature.uptime.enabled',
|
||||
label: t('systemSettings.features.uptimeMonitoring'),
|
||||
tooltip: t('systemSettings.features.uptimeMonitoringTooltip'),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const updateFlagMutation = useMutation({
|
||||
mutationFn: async (payload: Record<string, boolean>) => updateFeatureFlags(payload),
|
||||
onSuccess: () => {
|
||||
refetchFlags()
|
||||
toast.success(t('systemSettings.featureFlagUpdated'))
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t('systemSettings.featureFlagFailed', { error: msg }))
|
||||
},
|
||||
})
|
||||
|
||||
// CrowdSec control
|
||||
|
||||
// Determine loading message
|
||||
const { message, submessage } = updateFlagMutation.isPending
|
||||
? { message: t('systemSettings.updatingFeatures'), submessage: t('systemSettings.applyingChanges') }
|
||||
: { message: t('common.loading'), submessage: t('systemSettings.pleaseWait') }
|
||||
|
||||
// Loading skeleton for settings
|
||||
const SettingsSkeleton = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="p-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Show skeleton while loading initial data
|
||||
if (!settings && !featureFlags) {
|
||||
return <SettingsSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{updateFlagMutation.isPending && (
|
||||
<ConfigReloadOverlay
|
||||
message={message}
|
||||
submessage={submessage}
|
||||
type="charon"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-500/10 rounded-lg">
|
||||
<Server className="h-6 w-6 text-brand-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-content-primary">{t('systemSettings.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('systemSettings.features.title')}</CardTitle>
|
||||
<CardDescription>{t('systemSettings.features.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{featureFlags ? (
|
||||
featureToggles.map(({ key, label, tooltip }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-4 bg-surface-subtle rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-medium cursor-default">{label}</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="text-content-muted hover:text-content-primary">
|
||||
<Info className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={`${label} toggle`}
|
||||
checked={!!featureFlags[key]}
|
||||
disabled={updateFlagMutation.isPending}
|
||||
onChange={(e) => updateFlagMutation.mutate({ [key]: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 space-y-3">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* General Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('systemSettings.general.title')}</CardTitle>
|
||||
<CardDescription>{t('systemSettings.general.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="caddy-api">{t('systemSettings.general.caddyAdminApi')}</Label>
|
||||
<Input
|
||||
id="caddy-api"
|
||||
type="text"
|
||||
value={caddyAdminAPI}
|
||||
onChange={(e) => setCaddyAdminAPI(e.target.value)}
|
||||
placeholder="http://localhost:2019"
|
||||
helperText={t('systemSettings.general.caddyAdminApiHelper')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssl-provider">{t('systemSettings.general.sslProvider')}</Label>
|
||||
<Select value={sslProvider} onValueChange={setSslProvider}>
|
||||
<SelectTrigger id="ssl-provider">
|
||||
<SelectValue placeholder={t('systemSettings.general.selectSslProvider')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('systemSettings.general.sslAuto')}</SelectItem>
|
||||
<SelectItem value="letsencrypt-prod">{t('systemSettings.general.sslLetsEncryptProd')}</SelectItem>
|
||||
<SelectItem value="letsencrypt-staging">{t('systemSettings.general.sslLetsEncryptStaging')}</SelectItem>
|
||||
<SelectItem value="zerossl">{t('systemSettings.general.sslZeroSSL')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('systemSettings.general.sslProviderHelper')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain-behavior">{t('systemSettings.general.domainLinkBehavior')}</Label>
|
||||
<Select value={domainLinkBehavior} onValueChange={setDomainLinkBehavior}>
|
||||
<SelectTrigger id="domain-behavior">
|
||||
<SelectValue placeholder={t('systemSettings.general.selectLinkBehavior')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="same_tab">{t('systemSettings.general.sameTab')}</SelectItem>
|
||||
<SelectItem value="new_tab">{t('systemSettings.general.newTab')}</SelectItem>
|
||||
<SelectItem value="new_window">{t('systemSettings.general.newWindow')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('systemSettings.general.domainLinkBehaviorHelper')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">{t('common.language')}</Label>
|
||||
<LanguageSelector />
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('systemSettings.general.languageHelper')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{t('systemSettings.saveSettings')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Application URL */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('systemSettings.applicationUrl.title')}</CardTitle>
|
||||
<CardDescription>{t('systemSettings.applicationUrl.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="info">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t('systemSettings.applicationUrl.infoMessage')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="public-url">{t('systemSettings.applicationUrl.label')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="public-url"
|
||||
type="url"
|
||||
value={publicURL}
|
||||
onChange={(e) => {
|
||||
setPublicURL(e.target.value)
|
||||
}}
|
||||
placeholder="https://charon.example.com"
|
||||
className={cn(
|
||||
publicURLValid === false && 'border-red-500',
|
||||
publicURLValid === true && 'border-green-500'
|
||||
)}
|
||||
/>
|
||||
{publicURLValid !== null && (
|
||||
publicURLValid ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 self-center flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500 self-center flex-shrink-0" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('systemSettings.applicationUrl.helper')}
|
||||
</p>
|
||||
{publicURLValid === false && (
|
||||
<p className="text-sm text-red-500">
|
||||
{t('systemSettings.applicationUrl.invalidUrl')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!publicURL && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t('systemSettings.applicationUrl.notConfiguredWarning')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={testPublicURLHandler}
|
||||
disabled={!publicURL || publicURLSaving}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{t('systemSettings.applicationUrl.testButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{t('systemSettings.saveSettings')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* System Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-success" />
|
||||
<CardTitle>{t('systemSettings.systemStatus.title')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingHealth ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : health ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label variant="muted">{t('systemSettings.systemStatus.service')}</Label>
|
||||
<p className="text-lg font-medium text-content-primary">{health.service}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label variant="muted">{t('common.status')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={health.status === 'healthy' ? 'success' : 'error'}>
|
||||
{health.status === 'healthy' ? t('dashboard.healthy') : t('dashboard.unhealthy')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label variant="muted">{t('systemSettings.systemStatus.version')}</Label>
|
||||
<p className="text-lg font-medium text-content-primary">{health.version}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label variant="muted">{t('systemSettings.systemStatus.buildTime')}</Label>
|
||||
<p className="text-lg font-medium text-content-primary">
|
||||
{health.build_time || t('systemSettings.systemStatus.notAvailable')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label variant="muted">{t('systemSettings.systemStatus.gitCommit')}</Label>
|
||||
<p className="text-sm font-mono text-content-secondary bg-surface-subtle px-3 py-2 rounded-md">
|
||||
{health.git_commit || t('systemSettings.systemStatus.notAvailable')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Alert variant="error">
|
||||
{t('systemSettings.systemStatus.fetchError')}
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Update Check */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('systemSettings.updates.title')}</CardTitle>
|
||||
<CardDescription>{t('systemSettings.updates.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{updateInfo && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label variant="muted">{t('systemSettings.updates.currentVersion')}</Label>
|
||||
<p className="text-lg font-medium text-content-primary">
|
||||
{updateInfo.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label variant="muted">{t('systemSettings.updates.latestVersion')}</Label>
|
||||
<p className="text-lg font-medium text-content-primary">
|
||||
{updateInfo.latest_version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateInfo.update_available ? (
|
||||
<Alert variant="info" title={t('systemSettings.updates.updateAvailable')}>
|
||||
{t('systemSettings.updates.newVersionAvailable')}{' '}
|
||||
{updateInfo.release_url && (
|
||||
<a
|
||||
href={updateInfo.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline font-medium"
|
||||
>
|
||||
{t('systemSettings.updates.viewReleaseNotes')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="success" title={t('systemSettings.updates.upToDate')}>
|
||||
{t('systemSettings.updates.runningLatest')}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={() => checkUpdates()}
|
||||
isLoading={isCheckingUpdates}
|
||||
variant="secondary"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('systemSettings.updates.checkForUpdates')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* WebSocket Connection Status */}
|
||||
<WebSocketStatusCard showDetails={true} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Tasks() {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">{t('tasks.title')}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('tasks.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
to="/tasks/backups"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/tasks/backups')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t('navigation.backups')}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/tasks/logs"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/tasks/logs')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t('navigation.logs')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-gray-200 dark:border-gray-800 rounded-md p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useMemo, useState, type FC, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getMonitors, getMonitorHistory, updateMonitor, deleteMonitor, checkMonitor, UptimeMonitor } from '../api/uptime';
|
||||
import { Activity, ArrowUp, ArrowDown, Settings, X, Pause, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor) => void; t: (key: string, options?: Record<string, unknown>) => string }> = ({ monitor, onEdit, t }) => {
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ['uptimeHistory', monitor.id],
|
||||
queryFn: () => getMonitorHistory(monitor.id, 60),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return await deleteMonitor(id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['monitors'] })
|
||||
toast.success(t('uptime.monitorDeleted'))
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : t('uptime.failedToDeleteMonitor'))
|
||||
}
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
||||
return await updateMonitor(id, { enabled })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['monitors'] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : t('uptime.failedToUpdateMonitor'))
|
||||
}
|
||||
})
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return await checkMonitor(id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('uptime.healthCheckTriggered'))
|
||||
// Refetch monitor and history after a short delay to get updated results
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['monitors'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['uptimeHistory', monitor.id] })
|
||||
}, 2000)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : t('uptime.failedToTriggerCheck'))
|
||||
}
|
||||
})
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
// Determine current status from most recent heartbeat when available
|
||||
const latestBeat = history && history.length > 0
|
||||
? history.reduce((a, b) => new Date(a.created_at) > new Date(b.created_at) ? a : b)
|
||||
: null
|
||||
|
||||
const isUp = latestBeat ? latestBeat.status === 'up' : monitor.status === 'up';
|
||||
const isPaused = monitor.enabled === false;
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 border-l-4 ${isPaused ? 'border-l-yellow-400' : isUp ? 'border-l-green-500' : 'border-l-red-500'}`}>
|
||||
{/* Top Row: Name (left), Badge (center-right), Settings (right) */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex-1 min-w-0 truncate">{monitor.name}</h3>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className={`flex items-center justify-center px-3 py-1 rounded-full text-sm font-medium min-w-[90px] ${
|
||||
isPaused
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: isUp
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{isPaused ? <Pause className="w-4 h-4 mr-1" /> : isUp ? <ArrowUp className="w-4 h-4 mr-1" /> : <ArrowDown className="w-4 h-4 mr-1" />}
|
||||
{isPaused ? t('uptime.paused') : monitor.status.toUpperCase()}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await checkMutation.mutateAsync(monitor.id)
|
||||
} catch {
|
||||
// handled in onError
|
||||
}
|
||||
}}
|
||||
disabled={checkMutation.isPending}
|
||||
className="p-1 text-gray-400 hover:text-blue-400 transition-colors disabled:opacity-50"
|
||||
title={t('uptime.triggerHealthCheck')}
|
||||
>
|
||||
<RefreshCw size={16} className={checkMutation.isPending ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(prev => !prev)}
|
||||
className="p-1 text-gray-400 hover:text-gray-200 transition-colors"
|
||||
title={t('uptime.monitorSettings')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showMenu}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-20">
|
||||
<button
|
||||
onClick={() => { setShowMenu(false); onEdit(monitor) }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
>
|
||||
{t('common.configure')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowMenu(false)
|
||||
try {
|
||||
await toggleMutation.mutateAsync({ id: monitor.id, enabled: !monitor.enabled })
|
||||
toast.success(monitor.enabled ? t('uptime.paused') : t('uptime.unpaused'))
|
||||
} catch {
|
||||
// handled in onError
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-gray-900 flex items-center gap-2"
|
||||
>
|
||||
<Pause className="w-4 h-4 mr-1" />
|
||||
{monitor.enabled ? t('uptime.pause') : t('uptime.unpause')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowMenu(false)
|
||||
const confirmDelete = confirm(t('uptime.deleteConfirmation'))
|
||||
if (!confirmDelete) return
|
||||
try {
|
||||
await deleteMutation.mutateAsync(monitor.id)
|
||||
} catch {
|
||||
// handled in onError
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL and Type */}
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2 mb-4">
|
||||
<a href={monitor.url} target="_blank" rel="noreferrer" className="hover:underline">
|
||||
{monitor.url}
|
||||
</a>
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-xs">
|
||||
{monitor.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('uptime.latency')}</div>
|
||||
<div className="text-lg font-mono font-medium text-gray-900 dark:text-white">
|
||||
{monitor.latency}ms
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('uptime.lastCheck')}</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{monitor.last_check ? formatDistanceToNow(new Date(monitor.last_check), { addSuffix: true }) : t('uptime.never')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Bar (Last 60 checks / 1 Hour) */}
|
||||
<div className="flex gap-[2px] h-8 items-end relative" title={t('uptime.last60Checks')}>
|
||||
{/* Fill with empty bars if not enough history to keep alignment right-aligned */}
|
||||
{Array.from({ length: Math.max(0, 60 - (history?.length || 0)) }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-sm h-full opacity-50" />
|
||||
))}
|
||||
|
||||
{history?.slice().reverse().map((beat: { status: string; created_at: string; latency: number; message: string }, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-sm transition-all duration-200 hover:scale-110 ${
|
||||
beat.status === 'up'
|
||||
? 'bg-green-400 dark:bg-green-500 hover:bg-green-300'
|
||||
: 'bg-red-400 dark:bg-red-500 hover:bg-red-300'
|
||||
}`}
|
||||
style={{ height: '100%' }}
|
||||
title={`${new Date(beat.created_at).toLocaleString()}
|
||||
Status: ${beat.status.toUpperCase()}
|
||||
Latency: ${beat.latency}ms
|
||||
Message: ${beat.message}`}
|
||||
/>
|
||||
))}
|
||||
{(!history || history.length === 0) && (
|
||||
<div className="absolute w-full text-center text-xs text-gray-400">{t('uptime.noHistoryAvailable')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void; t: (key: string) => string }> = ({ monitor, onClose, t }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(monitor.name || '')
|
||||
const [maxRetries, setMaxRetries] = useState(monitor.max_retries || 3);
|
||||
const [interval, setInterval] = useState(monitor.interval || 60);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: Partial<UptimeMonitor>) => updateMonitor(monitor.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['monitors'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({ name, max_retries: maxRetries, interval });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-800 rounded-lg border border-gray-700 max-w-md w-full p-6 shadow-xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-white">{t('uptime.configureMonitor')}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="monitor-name" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{t('common.name')}
|
||||
</label>
|
||||
<input
|
||||
id="monitor-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(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="block text-sm font-medium text-gray-300 mb-1">
|
||||
{t('uptime.maxRetries')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={maxRetries}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value)
|
||||
setMaxRetries(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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('uptime.maxRetriesHelper')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{t('uptime.checkInterval')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
max="3600"
|
||||
value={interval}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value)
|
||||
setInterval(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>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? t('common.saving') : t('uptime.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Uptime: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: monitors, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: getMonitors,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const [editingMonitor, setEditingMonitor] = useState<UptimeMonitor | null>(null);
|
||||
|
||||
// Sort monitors alphabetically by name
|
||||
const sortedMonitors = useMemo(() => {
|
||||
if (!monitors) return [];
|
||||
return [...monitors].sort((a, b) =>
|
||||
(a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase())
|
||||
);
|
||||
}, [monitors]);
|
||||
|
||||
const proxyHostMonitors = useMemo(() => sortedMonitors.filter(m => m.proxy_host_id), [sortedMonitors]);
|
||||
const remoteServerMonitors = useMemo(() => sortedMonitors.filter(m => m.remote_server_id), [sortedMonitors]);
|
||||
const otherMonitors = useMemo(() => sortedMonitors.filter(m => !m.proxy_host_id && !m.remote_server_id), [sortedMonitors]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">{t('uptime.loadingMonitors')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-6 h-6" />
|
||||
{t('uptime.title')}
|
||||
</h1>
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('uptime.autoRefreshing')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedMonitors.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
{t('uptime.noMonitorsFound')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{proxyHostMonitors.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4 border-b border-gray-200 dark:border-gray-700 pb-2">{t('uptime.proxyHosts')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{proxyHostMonitors.map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} onEdit={setEditingMonitor} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteServerMonitors.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4 border-b border-gray-200 dark:border-gray-700 pb-2">{t('uptime.remoteServers')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{remoteServerMonitors.map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} onEdit={setEditingMonitor} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherMonitors.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4 border-b border-gray-200 dark:border-gray-700 pb-2">{t('uptime.otherMonitors')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{otherMonitors.map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} onEdit={setEditingMonitor} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingMonitor && (
|
||||
<EditMonitorModal monitor={editingMonitor} onClose={() => setEditingMonitor(null)} t={t} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Uptime;
|
||||
@@ -0,0 +1,644 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { Alert, AlertDescription } from '../components/ui/Alert'
|
||||
import { Label } from '../components/ui/Label'
|
||||
import { toast } from '../utils/toast'
|
||||
import client from '../api/client'
|
||||
import {
|
||||
listUsers,
|
||||
inviteUser,
|
||||
deleteUser,
|
||||
updateUser,
|
||||
updateUserPermissions,
|
||||
} from '../api/users'
|
||||
import type { User, InviteUserRequest, PermissionMode, UpdateUserPermissionsRequest } from '../api/users'
|
||||
import { getProxyHosts } from '../api/proxyHosts'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Mail,
|
||||
Shield,
|
||||
Trash2,
|
||||
Settings,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface InviteModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
proxyHosts: ProxyHost[]
|
||||
}
|
||||
|
||||
function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<'user' | 'admin'>('user')
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
const [inviteResult, setInviteResult] = useState<{
|
||||
token: string
|
||||
emailSent: boolean
|
||||
expiresAt: string
|
||||
} | null>(null)
|
||||
const [urlPreview, setUrlPreview] = useState<{
|
||||
preview_url: string
|
||||
base_url: string
|
||||
is_configured: boolean
|
||||
warning: boolean
|
||||
warning_message: string
|
||||
} | null>(null)
|
||||
|
||||
// Fetch preview when email changes
|
||||
useEffect(() => {
|
||||
if (email && email.includes('@')) {
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
const response = await client.post('/users/preview-invite-url', { email })
|
||||
setUrlPreview(response.data)
|
||||
} catch {
|
||||
setUrlPreview(null)
|
||||
}
|
||||
}
|
||||
const debounce = setTimeout(fetchPreview, 500)
|
||||
return () => clearTimeout(debounce)
|
||||
} else {
|
||||
setUrlPreview(null)
|
||||
}
|
||||
}, [email])
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const request: InviteUserRequest = {
|
||||
email,
|
||||
role,
|
||||
permission_mode: permissionMode,
|
||||
permitted_hosts: selectedHosts,
|
||||
}
|
||||
return inviteUser(request)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
setInviteResult({
|
||||
token: data.invite_token,
|
||||
emailSent: data.email_sent,
|
||||
expiresAt: data.expires_at,
|
||||
})
|
||||
if (data.email_sent) {
|
||||
toast.success(t('users.inviteSent'))
|
||||
} else {
|
||||
toast.success(t('users.inviteCreated'))
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('users.inviteFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const copyInviteLink = () => {
|
||||
if (inviteResult?.token) {
|
||||
const link = `${window.location.origin}/accept-invite?token=${inviteResult.token}`
|
||||
navigator.clipboard.writeText(link)
|
||||
toast.success(t('users.inviteLinkCopied'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setEmail('')
|
||||
setRole('user')
|
||||
setPermissionMode('allow_all')
|
||||
setSelectedHosts([])
|
||||
setInviteResult(null)
|
||||
setUrlPreview(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const toggleHost = (hostId: number) => {
|
||||
setSelectedHosts((prev) =>
|
||||
prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId]
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
{t('users.inviteUser')}
|
||||
</h3>
|
||||
<button onClick={handleClose} className="text-gray-400 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{inviteResult ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-900/20 border border-green-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-green-400 mb-2">
|
||||
<Check className="h-5 w-5" />
|
||||
<span className="font-medium">{t('users.inviteSuccess')}</span>
|
||||
</div>
|
||||
{inviteResult.emailSent ? (
|
||||
<p className="text-sm text-gray-300">
|
||||
{t('users.inviteEmailSent')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-300">
|
||||
{t('users.inviteEmailNotSent')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!inviteResult.emailSent && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
{t('users.inviteLink')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={`${window.location.origin}/accept-invite?token=${inviteResult.token}`}
|
||||
readOnly
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button onClick={copyInviteLink} aria-label={t('users.copyInviteLink')} title={t('users.copyInviteLink')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('users.expires')}: {new Date(inviteResult.expiresAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
{t('users.done')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label={t('users.emailAddress')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{t('users.role')}
|
||||
</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
|
||||
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="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{role === 'user' && (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{t('users.permissionMode')}
|
||||
</label>
|
||||
<select
|
||||
value={permissionMode}
|
||||
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
|
||||
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="allow_all">{t('users.allowAllBlacklist')}</option>
|
||||
<option value="deny_all">{t('users.denyAllWhitelist')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{permissionMode === 'allow_all'
|
||||
? t('users.allowAllDescription')
|
||||
: t('users.denyAllDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{permissionMode === 'allow_all' ? t('users.blockedHosts') : t('users.allowedHosts')}
|
||||
</label>
|
||||
<div className="max-h-48 overflow-y-auto border border-gray-700 rounded-lg">
|
||||
{proxyHosts.length === 0 ? (
|
||||
<p className="p-3 text-sm text-gray-500">{t('users.noProxyHosts')}</p>
|
||||
) : (
|
||||
proxyHosts.map((host) => (
|
||||
<label
|
||||
key={host.uuid}
|
||||
className="flex items-center gap-3 p-3 hover:bg-gray-800/50 cursor-pointer border-b border-gray-800 last:border-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHosts.includes(
|
||||
parseInt(host.uuid.split('-')[0], 16) || 0
|
||||
)}
|
||||
onChange={() =>
|
||||
toggleHost(parseInt(host.uuid.split('-')[0], 16) || 0)
|
||||
}
|
||||
className="rounded bg-gray-900 border-gray-700 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-white">{host.name || host.domain_names}</p>
|
||||
<p className="text-xs text-gray-500">{host.domain_names}</p>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* URL Preview */}
|
||||
{urlPreview && (
|
||||
<div className="space-y-2 p-4 bg-gray-900/50 rounded-lg border border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4 text-gray-400" />
|
||||
<Label className="text-sm font-medium text-gray-300">
|
||||
{t('users.inviteUrlPreview')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="text-sm font-mono text-gray-400 break-all bg-gray-950 p-2 rounded">
|
||||
{urlPreview.preview_url.replace('SAMPLE_TOKEN_PREVIEW', '...')}
|
||||
</div>
|
||||
{urlPreview.warning && (
|
||||
<Alert variant="warning" className="mt-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{t('users.inviteUrlWarning')}
|
||||
<Link to="/settings/system" className="ml-1 underline">
|
||||
{t('users.configureApplicationUrl')}
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-700">
|
||||
<Button variant="secondary" onClick={handleClose} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => inviteMutation.mutate()}
|
||||
isLoading={inviteMutation.isPending}
|
||||
disabled={!email}
|
||||
className="flex-1"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
{t('users.sendInvite')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PermissionsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user: User | null
|
||||
proxyHosts: ProxyHost[]
|
||||
}
|
||||
|
||||
function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
|
||||
// Update state when user changes
|
||||
useState(() => {
|
||||
if (user) {
|
||||
setPermissionMode(user.permission_mode || 'allow_all')
|
||||
setSelectedHosts(user.permitted_hosts || [])
|
||||
}
|
||||
})
|
||||
|
||||
const updatePermissionsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!user) return
|
||||
const request: UpdateUserPermissionsRequest = {
|
||||
permission_mode: permissionMode,
|
||||
permitted_hosts: selectedHosts,
|
||||
}
|
||||
return updateUserPermissions(user.id, request)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success(t('users.permissionsUpdated'))
|
||||
onClose()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('users.permissionsUpdateFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const toggleHost = (hostId: number) => {
|
||||
setSelectedHosts((prev) =>
|
||||
prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId]
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen || !user) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
{t('users.editPermissions')} - {user.name || user.email}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{t('users.permissionMode')}
|
||||
</label>
|
||||
<select
|
||||
value={permissionMode}
|
||||
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
|
||||
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="allow_all">{t('users.allowAllBlacklist')}</option>
|
||||
<option value="deny_all">{t('users.denyAllWhitelist')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{permissionMode === 'allow_all'
|
||||
? t('users.allowAllDescription')
|
||||
: t('users.denyAllDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{permissionMode === 'allow_all' ? t('users.blockedHosts') : t('users.allowedHosts')}
|
||||
</label>
|
||||
<div className="max-h-64 overflow-y-auto border border-gray-700 rounded-lg">
|
||||
{proxyHosts.length === 0 ? (
|
||||
<p className="p-3 text-sm text-gray-500">{t('users.noProxyHosts')}</p>
|
||||
) : (
|
||||
proxyHosts.map((host) => (
|
||||
<label
|
||||
key={host.uuid}
|
||||
className="flex items-center gap-3 p-3 hover:bg-gray-800/50 cursor-pointer border-b border-gray-800 last:border-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHosts.includes(
|
||||
parseInt(host.uuid.split('-')[0], 16) || 0
|
||||
)}
|
||||
onChange={() =>
|
||||
toggleHost(parseInt(host.uuid.split('-')[0], 16) || 0)
|
||||
}
|
||||
className="rounded bg-gray-900 border-gray-700 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-white">{host.name || host.domain_names}</p>
|
||||
<p className="text-xs text-gray-500">{host.domain_names}</p>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-700">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updatePermissionsMutation.mutate()}
|
||||
isLoading={updatePermissionsMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('users.savePermissions')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [inviteModalOpen, setInviteModalOpen] = useState(false)
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: listUsers,
|
||||
})
|
||||
|
||||
const { data: proxyHosts = [] } = useQuery({
|
||||
queryKey: ['proxyHosts'],
|
||||
queryFn: getProxyHosts,
|
||||
})
|
||||
|
||||
const toggleEnabledMutation = useMutation({
|
||||
mutationFn: async ({ id, enabled }: { id: number; enabled: boolean }) => {
|
||||
return updateUser(id, { enabled })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success(t('users.userUpdated'))
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('users.userUpdateFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success(t('users.userDeleted'))
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('users.userDeleteFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const openPermissions = (user: User) => {
|
||||
setSelectedUser(user)
|
||||
setPermissionsModalOpen(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{t('users.title')}</h1>
|
||||
</div>
|
||||
<Button onClick={() => setInviteModalOpen(true)}>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
{t('users.inviteUser')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('users.columnUser')}</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('users.columnRole')}</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('common.status')}</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('users.columnPermissions')}</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('common.enabled')}</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-400">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map((user) => (
|
||||
<tr key={user.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{user.name || t('users.noName')}</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-900/30 text-purple-400'
|
||||
: 'bg-blue-900/30 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{user.invite_status === 'pending' ? (
|
||||
<span className="inline-flex items-center gap-1 text-yellow-400 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t('users.pendingInvite')}
|
||||
</span>
|
||||
) : user.invite_status === 'expired' ? (
|
||||
<span className="inline-flex items-center gap-1 text-red-400 text-xs">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{t('users.inviteExpired')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-green-400 text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
{t('common.active')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-xs text-gray-400">
|
||||
{user.permission_mode === 'deny_all' ? t('users.whitelist') : t('users.blacklist')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Switch
|
||||
checked={user.enabled}
|
||||
onChange={() =>
|
||||
toggleEnabledMutation.mutate({
|
||||
id: user.id,
|
||||
enabled: !user.enabled,
|
||||
})
|
||||
}
|
||||
disabled={user.role === 'admin'}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{user.role !== 'admin' && (
|
||||
<button
|
||||
onClick={() => openPermissions(user)}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded"
|
||||
title={t('users.editPermissions')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(t('users.deleteConfirm'))) {
|
||||
deleteMutation.mutate(user.id)
|
||||
}
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-gray-800 rounded"
|
||||
title={t('users.deleteUser')}
|
||||
disabled={user.role === 'admin'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<InviteModal
|
||||
isOpen={inviteModalOpen}
|
||||
onClose={() => setInviteModalOpen(false)}
|
||||
proxyHosts={proxyHosts}
|
||||
/>
|
||||
|
||||
<PermissionsModal
|
||||
isOpen={permissionsModalOpen}
|
||||
onClose={() => {
|
||||
setPermissionsModalOpen(false)
|
||||
setSelectedUser(null)
|
||||
}}
|
||||
user={selectedUser}
|
||||
proxyHosts={proxyHosts}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Shield, Plus, Pencil, Trash2, ExternalLink, FileCode2, Sparkles } from 'lucide-react'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { useRuleSets, useUpsertRuleSet, useDeleteRuleSet } from '../hooks/useSecurity'
|
||||
import type { SecurityRuleSet, UpsertRuleSetPayload } from '../api/security'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
/**
|
||||
* WAF Rule Presets for common security configurations
|
||||
*/
|
||||
const WAF_PRESETS = [
|
||||
{
|
||||
name: 'OWASP Core Rule Set',
|
||||
url: 'https://github.com/coreruleset/coreruleset/archive/refs/tags/v3.3.5.tar.gz',
|
||||
content: '',
|
||||
description: 'Industry standard protection against OWASP Top 10 vulnerabilities.',
|
||||
},
|
||||
{
|
||||
name: 'Basic SQL Injection Protection',
|
||||
url: '',
|
||||
content: `SecRule ARGS "@detectSQLi" "id:1001,phase:1,deny,status:403,msg:'SQLi Detected'"
|
||||
SecRule REQUEST_BODY "@detectSQLi" "id:1002,phase:2,deny,status:403,msg:'SQLi in Body'"
|
||||
SecRule REQUEST_COOKIES "@detectSQLi" "id:1003,phase:1,deny,status:403,msg:'SQLi in Cookies'"`,
|
||||
description: 'Simple rules to block common SQL injection patterns.',
|
||||
},
|
||||
{
|
||||
name: 'Basic XSS Protection',
|
||||
url: '',
|
||||
content: `SecRule ARGS "@detectXSS" "id:2001,phase:1,deny,status:403,msg:'XSS Detected'"
|
||||
SecRule REQUEST_BODY "@detectXSS" "id:2002,phase:2,deny,status:403,msg:'XSS in Body'"`,
|
||||
description: 'Rules to block common Cross-Site Scripting (XSS) attacks.',
|
||||
},
|
||||
{
|
||||
name: 'Common Bad Bots',
|
||||
url: '',
|
||||
content: `SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(curl|wget|python|scrapy|httpclient|libwww|nikto|sqlmap)" "id:3001,phase:1,deny,status:403,msg:'Bad Bot Detected'"
|
||||
SecRule REQUEST_HEADERS:User-Agent "@streq -" "id:3002,phase:1,deny,status:403,msg:'Empty User-Agent'"`,
|
||||
description: 'Block known malicious bots and scanners.',
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Confirmation dialog for destructive actions
|
||||
*/
|
||||
function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isLoading,
|
||||
deletingLabel,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel: string
|
||||
cancelLabel: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
isLoading?: boolean
|
||||
deletingLabel: string
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onCancel}
|
||||
data-testid="confirm-dialog-backdrop"
|
||||
>
|
||||
<div
|
||||
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-white mb-2">{title}</h2>
|
||||
<p className="text-gray-400 mb-6">{message}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onCancel} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
data-testid="confirm-delete-btn"
|
||||
>
|
||||
{isLoading ? deletingLabel : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Form for creating/editing a WAF rule set
|
||||
*/
|
||||
function RuleSetForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
t,
|
||||
}: {
|
||||
initialData?: SecurityRuleSet
|
||||
onSubmit: (data: UpsertRuleSetPayload) => void
|
||||
onCancel: () => void
|
||||
isLoading?: boolean
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
const [name, setName] = useState(initialData?.name || '')
|
||||
const [sourceUrl, setSourceUrl] = useState(initialData?.source_url || '')
|
||||
const [content, setContent] = useState(initialData?.content || '')
|
||||
const [mode, setMode] = useState<'blocking' | 'detection'>(
|
||||
initialData?.mode === 'detection' ? 'detection' : 'blocking'
|
||||
)
|
||||
const [selectedPreset, setSelectedPreset] = useState('')
|
||||
|
||||
const handlePresetChange = (presetName: string) => {
|
||||
setSelectedPreset(presetName)
|
||||
if (presetName === '') return
|
||||
|
||||
const preset = WAF_PRESETS.find((p) => p.name === presetName)
|
||||
if (preset) {
|
||||
setName(preset.name)
|
||||
setSourceUrl(preset.url)
|
||||
setContent(preset.content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit({
|
||||
id: initialData?.id,
|
||||
name: name.trim(),
|
||||
source_url: sourceUrl.trim() || undefined,
|
||||
content: content.trim() || undefined,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
const isValid = name.trim().length > 0 && (content.trim().length > 0 || sourceUrl.trim().length > 0)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Presets Dropdown - only show when creating new */}
|
||||
{!initialData && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
<Sparkles className="inline h-4 w-4 mr-1 text-yellow-400" />
|
||||
{t('wafConfig.quickStartPreset')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedPreset}
|
||||
onChange={(e) => handlePresetChange(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 focus:border-blue-500"
|
||||
data-testid="preset-select"
|
||||
>
|
||||
<option value="">{t('wafConfig.choosePreset')}</option>
|
||||
{WAF_PRESETS.map((preset) => (
|
||||
<option key={preset.name} value={preset.name}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPreset && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{WAF_PRESETS.find((p) => p.name === selectedPreset)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t('wafConfig.ruleSetName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('wafConfig.ruleSetNamePlaceholder')}
|
||||
required
|
||||
data-testid="ruleset-name-input"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">{t('wafConfig.mode')}</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="blocking"
|
||||
checked={mode === 'blocking'}
|
||||
onChange={() => setMode('blocking')}
|
||||
className="text-blue-600 focus:ring-blue-500"
|
||||
data-testid="mode-blocking"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">{t('wafConfig.blocking')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="detection"
|
||||
checked={mode === 'detection'}
|
||||
onChange={() => setMode('detection')}
|
||||
className="text-blue-600 focus:ring-blue-500"
|
||||
data-testid="mode-detection"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">{t('wafConfig.detectionOnly')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{mode === 'blocking'
|
||||
? t('wafConfig.blockingDescription')
|
||||
: t('wafConfig.detectionDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label={t('wafConfig.sourceUrl')}
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
placeholder="https://example.com/rules.conf"
|
||||
helperText={t('wafConfig.sourceUrlHelper')}
|
||||
data-testid="ruleset-url-input"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{t('wafConfig.ruleContent')} {!sourceUrl && <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={`SecRule REQUEST_URI "@contains /admin" "id:1000,phase:1,deny,status:403"`}
|
||||
rows={10}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
data-testid="ruleset-content-input"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{t('wafConfig.ruleContentHelper')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!isValid || isLoading} isLoading={isLoading}>
|
||||
{initialData ? t('wafConfig.updateRuleSet') : t('wafConfig.createRuleSet')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* WAF Configuration Page - Manage Coraza rule sets
|
||||
*/
|
||||
export default function WafConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { data: ruleSets, isLoading, error } = useRuleSets()
|
||||
const upsertMutation = useUpsertRuleSet()
|
||||
const deleteMutation = useDeleteRuleSet()
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [editingRuleSet, setEditingRuleSet] = useState<SecurityRuleSet | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<SecurityRuleSet | null>(null)
|
||||
|
||||
// Determine if any security operation is in progress
|
||||
const isApplyingConfig = upsertMutation.isPending || deleteMutation.isPending
|
||||
|
||||
// Determine contextual message based on operation
|
||||
const getMessage = () => {
|
||||
if (upsertMutation.isPending) {
|
||||
return editingRuleSet
|
||||
? { message: t('wafConfig.cerberusAwakens'), submessage: t('wafConfig.guardianStandsWatch') }
|
||||
: { message: t('wafConfig.forgingDefenses'), submessage: t('wafConfig.rulesInscribing') }
|
||||
}
|
||||
if (deleteMutation.isPending) {
|
||||
return { message: t('wafConfig.loweringBarrier'), submessage: t('wafConfig.defenseLayerRemoved') }
|
||||
}
|
||||
return { message: t('wafConfig.cerberusAwakens'), submessage: t('wafConfig.guardianStandsWatch') }
|
||||
}
|
||||
|
||||
const { message, submessage } = getMessage()
|
||||
|
||||
const handleCreate = (data: UpsertRuleSetPayload) => {
|
||||
upsertMutation.mutate(data, {
|
||||
onSuccess: () => setShowCreateForm(false),
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdate = (data: UpsertRuleSetPayload) => {
|
||||
upsertMutation.mutate(data, {
|
||||
onSuccess: () => setEditingRuleSet(null),
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteConfirm) return
|
||||
deleteMutation.mutate(deleteConfirm.id, {
|
||||
onSuccess: () => {
|
||||
setDeleteConfirm(null)
|
||||
setEditingRuleSet(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center text-white" data-testid="waf-loading">
|
||||
{t('wafConfig.loadingConfiguration')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 text-center text-red-400" data-testid="waf-error">
|
||||
{t('wafConfig.failedToLoad')}: {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ruleSetList = ruleSets?.rulesets || []
|
||||
|
||||
return (
|
||||
<>
|
||||
{isApplyingConfig && (
|
||||
<ConfigReloadOverlay
|
||||
message={message}
|
||||
submessage={submessage}
|
||||
type="cerberus"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Shield className="w-7 h-7 text-blue-400" />
|
||||
{t('wafConfig.title')}
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
{t('wafConfig.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open('https://coraza.io/docs/seclang/directives/', '_blank')
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{t('wafConfig.ruleSyntax')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateForm(true)} data-testid="create-ruleset-btn">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('wafConfig.addRuleSet')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileCode2 className="h-5 w-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-300 mb-1">
|
||||
{t('wafConfig.aboutTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-200/90">
|
||||
{t('wafConfig.aboutDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">{t('wafConfig.createRuleSet')}</h2>
|
||||
<RuleSetForm
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
isLoading={upsertMutation.isPending}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Form */}
|
||||
{editingRuleSet && (
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-white">{t('wafConfig.editRuleSet')}</h2>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirm(editingRuleSet)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<RuleSetForm
|
||||
initialData={editingRuleSet}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={() => setEditingRuleSet(null)}
|
||||
isLoading={upsertMutation.isPending}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm !== null}
|
||||
title={t('wafConfig.deleteRuleSet')}
|
||||
message={t('wafConfig.deleteConfirmation', { name: deleteConfirm?.name })}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
deletingLabel={t('common.deleting')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Empty State */}
|
||||
{ruleSetList.length === 0 && !showCreateForm && !editingRuleSet && (
|
||||
<div
|
||||
className="bg-dark-card border border-gray-800 rounded-lg p-12 text-center"
|
||||
data-testid="waf-empty-state"
|
||||
>
|
||||
<div className="text-gray-500 mb-4 text-4xl">🛡️</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{t('wafConfig.noRuleSets')}</h3>
|
||||
<p className="text-gray-400 mb-4">
|
||||
{t('wafConfig.noRuleSetsDescription')}
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('wafConfig.createRuleSet')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rule Sets Table */}
|
||||
{ruleSetList.length > 0 && !showCreateForm && !editingRuleSet && (
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full" data-testid="rulesets-table">
|
||||
<thead className="bg-gray-900/50 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">
|
||||
{t('common.name')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">
|
||||
{t('wafConfig.mode')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">
|
||||
{t('wafConfig.source')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">
|
||||
{t('wafConfig.lastUpdated')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">
|
||||
{t('common.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{ruleSetList.map((rs) => (
|
||||
<tr key={rs.id} className="hover:bg-gray-900/30">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-white">{rs.name}</p>
|
||||
{rs.content && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('wafConfig.ruleCount', { count: rs.content.split('\n').filter((l) => l.trim()).length })}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
rs.mode === 'blocking'
|
||||
? 'bg-red-900/30 text-red-300'
|
||||
: 'bg-yellow-900/30 text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{rs.mode === 'blocking' ? t('wafConfig.blocking') : t('wafConfig.detection')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{rs.source_url ? (
|
||||
<a
|
||||
href={rs.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{t('wafConfig.url')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">{t('wafConfig.inline')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-400">
|
||||
{rs.last_updated
|
||||
? new Date(rs.last_updated).toLocaleDateString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setEditingRuleSet(rs)}
|
||||
className="text-gray-400 hover:text-blue-400"
|
||||
title={t('common.edit')}
|
||||
data-testid={`edit-ruleset-${rs.id}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(rs)}
|
||||
className="text-gray-400 hover:text-red-400"
|
||||
title={t('common.delete')}
|
||||
data-testid={`delete-ruleset-${rs.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import AcceptInvite from '../AcceptInvite'
|
||||
import * as usersApi from '../../api/users'
|
||||
|
||||
// Mock APIs
|
||||
vi.mock('../../api/users', () => ({
|
||||
validateInvite: vi.fn(),
|
||||
acceptInvite: vi.fn(),
|
||||
listUsers: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
inviteUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
updateUserPermissions: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock react-router-dom navigate
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (initialRoute: string = '/accept-invite?token=test-token') => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<Routes>
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AcceptInvite', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows invalid link message when no token provided', async () => {
|
||||
renderWithProviders('/accept-invite')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid Link')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/This invitation link is invalid/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows validating state initially', () => {
|
||||
vi.mocked(usersApi.validateInvite).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
expect(screen.getByText('Validating invitation...')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows error for invalid token', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockRejectedValue({
|
||||
response: { data: { error: 'Token expired' } },
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invitation Invalid')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders accept form for valid token', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/been invited/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/invited@example.com/)).toBeTruthy()
|
||||
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
|
||||
// Password and confirm password have same placeholder
|
||||
expect(screen.getAllByPlaceholderText('••••••••').length).toBe(2)
|
||||
})
|
||||
|
||||
it('shows password mismatch error', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
|
||||
await user.type(passwordInput, 'password123')
|
||||
await user.type(confirmInput, 'differentpassword')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Passwords do not match')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits form and shows success', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
vi.mocked(usersApi.acceptInvite).mockResolvedValue({
|
||||
message: 'Success',
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe')
|
||||
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
|
||||
await user.type(passwordInput, 'securepassword123')
|
||||
await user.type(confirmInput, 'securepassword123')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.acceptInvite).toHaveBeenCalledWith({
|
||||
token: 'test-token',
|
||||
name: 'John Doe',
|
||||
password: 'securepassword123',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Account Created!')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error on submit failure', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
vi.mocked(usersApi.acceptInvite).mockRejectedValue({
|
||||
response: { data: { error: 'Token has expired' } },
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe')
|
||||
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
|
||||
await user.type(passwordInput, 'securepassword123')
|
||||
await user.type(confirmInput, 'securepassword123')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.acceptInvite).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The toast should show error but we don't need to test toast specifically
|
||||
})
|
||||
|
||||
it('navigates to login after clicking Go to Login button', async () => {
|
||||
renderWithProviders('/accept-invite')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid Link')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Go to Login' }))
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,544 @@
|
||||
import { AxiosError } from 'axios'
|
||||
import { screen, waitFor, act, cleanup, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
|
||||
import { renderWithQueryClient, createTestQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import { toast } from '../../utils/toast'
|
||||
import * as exportUtils from '../../utils/crowdsecExport'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/presets')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../utils/crowdsecExport', () => ({
|
||||
buildCrowdsecExportFilename: vi.fn(() => 'crowdsec-default.tar.gz'),
|
||||
promptCrowdsecFilename: vi.fn(() => 'crowdsec.tar.gz'),
|
||||
downloadCrowdsecExport: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const baseStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
|
||||
waf: { enabled: true, mode: 'enabled' as const },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
const disabledStatus = {
|
||||
...baseStatus,
|
||||
crowdsec: { ...baseStatus.crowdsec, enabled: true, mode: 'disabled' as const },
|
||||
}
|
||||
|
||||
const presetFromCatalog = CROWDSEC_PRESETS[0]
|
||||
|
||||
const axiosError = (status: number, message: string, data?: Record<string, unknown>) =>
|
||||
new AxiosError(message, undefined, undefined, undefined, {
|
||||
status,
|
||||
statusText: String(status),
|
||||
headers: {},
|
||||
config: {},
|
||||
data: data ?? { error: message },
|
||||
} as never)
|
||||
|
||||
const defaultFileList = ['acquis.yaml', 'collections.yaml']
|
||||
|
||||
const renderPage = async (client?: QueryClient) => {
|
||||
const result = renderWithQueryClient(<CrowdSecConfig />, { client })
|
||||
await waitFor(() => screen.getByText('CrowdSec Configuration'))
|
||||
return result
|
||||
}
|
||||
|
||||
describe('CrowdSecConfig coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(baseStatus)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: defaultFileList })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'file-content' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue(undefined)
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
|
||||
vi.mocked(crowdsecApi.banIP).mockResolvedValue(undefined)
|
||||
vi.mocked(crowdsecApi.unbanIP).mockResolvedValue(undefined)
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue(undefined)
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
|
||||
presets: [
|
||||
{
|
||||
slug: presetFromCatalog.slug,
|
||||
title: presetFromCatalog.title,
|
||||
summary: presetFromCatalog.description,
|
||||
source: 'hub',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
cache_key: 'cache-123',
|
||||
etag: 'etag-123',
|
||||
retrieved_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: presetFromCatalog.slug,
|
||||
preview: presetFromCatalog.content,
|
||||
cache_key: 'cache-123',
|
||||
etag: 'etag-123',
|
||||
retrieved_at: '2024-01-01T00:00:00Z',
|
||||
source: 'hub',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
|
||||
status: 'applied',
|
||||
backup: '/tmp/backup.tar.gz',
|
||||
reload_hint: true,
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: presetFromCatalog.slug,
|
||||
})
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({
|
||||
preview: 'cached-preview',
|
||||
cache_key: 'cache-123',
|
||||
etag: 'etag-123',
|
||||
})
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
})
|
||||
|
||||
it('renders loading and error boundaries', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
renderWithQueryClient(<CrowdSecConfig />)
|
||||
expect(await screen.findByText('Loading CrowdSec configuration...')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('boom'))
|
||||
renderWithQueryClient(<CrowdSecConfig />)
|
||||
expect(await screen.findByText(/Error loading CrowdSec status/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles missing status and missing crowdsec sections', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValueOnce(new Error('data is undefined'))
|
||||
renderWithQueryClient(<CrowdSecConfig />)
|
||||
expect(await screen.findByText(/Error loading CrowdSec status/)).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValueOnce({ cerberus: { enabled: true } } as never)
|
||||
renderWithQueryClient(<CrowdSecConfig />)
|
||||
expect(await screen.findByText('CrowdSec configuration not found in security status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders disabled mode message and bans control disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus)
|
||||
await renderPage(createTestQueryClient())
|
||||
expect(screen.getByText('Enable CrowdSec to manage banned IPs')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Ban IP/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows info banner directing to Security Dashboard', async () => {
|
||||
await renderPage()
|
||||
expect(screen.getByText(/CrowdSec is controlled via the toggle on the/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Security/i })).toHaveAttribute('href', '/security')
|
||||
})
|
||||
|
||||
it('guards import without a file and shows error on import failure', async () => {
|
||||
await renderPage()
|
||||
const importBtn = screen.getByTestId('import-btn')
|
||||
await userEvent.click(importBtn)
|
||||
expect(backupsApi.createBackup).not.toHaveBeenCalled()
|
||||
|
||||
const fileInput = screen.getByTestId('import-file') as HTMLInputElement
|
||||
const file = new File(['data'], 'cfg.tar.gz')
|
||||
await userEvent.upload(fileInput, file)
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockRejectedValueOnce(new Error('bad import'))
|
||||
await userEvent.click(importBtn)
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('bad import'))
|
||||
})
|
||||
|
||||
it('imports configuration after creating a backup', async () => {
|
||||
await renderPage()
|
||||
const fileInput = screen.getByTestId('import-file') as HTMLInputElement
|
||||
await userEvent.upload(fileInput, new File(['data'], 'cfg.tar.gz'))
|
||||
await userEvent.click(screen.getByTestId('import-btn'))
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('exports configuration success and failure', async () => {
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
expect(exportUtils.downloadCrowdsecExport).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
|
||||
|
||||
vi.mocked(exportUtils.promptCrowdsecFilename).mockReturnValueOnce('crowdsec.tar.gz')
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockRejectedValueOnce(new Error('fail'))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to export CrowdSec configuration'))
|
||||
})
|
||||
|
||||
it('auto-selects first preset and pulls preview', async () => {
|
||||
await renderPage()
|
||||
// Component auto-selects first preset from the list on render
|
||||
await waitFor(() => expect(presetsApi.pullCrowdsecPreset).toHaveBeenCalledWith(presetFromCatalog.slug))
|
||||
const previewText = screen.getByTestId('preset-preview').textContent?.replace(/\s+/g, ' ')
|
||||
expect(previewText).toContain('crowdsecurity/http-cve')
|
||||
expect(screen.getByTestId('preset-meta')).toHaveTextContent('cache-123')
|
||||
})
|
||||
|
||||
it('handles pull validation, hub unavailable, and generic errors', async () => {
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(400, 'slug invalid', { error: 'slug invalid' }))
|
||||
await renderPage()
|
||||
expect(await screen.findByTestId('preset-validation-error')).toHaveTextContent('slug invalid')
|
||||
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub down', { error: 'hub down' }))
|
||||
await userEvent.click(screen.getByText('Pull Preview'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
|
||||
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'boom', { error: 'boom' }))
|
||||
await userEvent.click(screen.getByText('Pull Preview'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-status')).toHaveTextContent('boom'))
|
||||
})
|
||||
|
||||
it('loads cached preview and reports cache errors', async () => {
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
|
||||
presets: [
|
||||
{
|
||||
slug: presetFromCatalog.slug,
|
||||
title: presetFromCatalog.title,
|
||||
summary: presetFromCatalog.description,
|
||||
source: 'hub',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: true,
|
||||
cache_key: 'cache-123',
|
||||
etag: 'etag-123',
|
||||
retrieved_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getByText('Pull Preview'))
|
||||
await waitFor(() => {
|
||||
const preview = screen.getByTestId('preset-preview').textContent?.replace(/\s+/g, ' ')
|
||||
expect(preview).toContain('crowdsecurity/http-cve')
|
||||
})
|
||||
await userEvent.click(screen.getByText('Load cached preview'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview'))
|
||||
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockRejectedValueOnce(axiosError(500, 'cache-miss'))
|
||||
await userEvent.click(screen.getByText('Load cached preview'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('cache-miss'))
|
||||
})
|
||||
|
||||
it('sets apply info on backend success', async () => {
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getByTestId('apply-preset-btn'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Backup: /tmp/backup.tar.gz'))
|
||||
})
|
||||
|
||||
it('falls back to local apply on 501 and covers validation/hub/offline branches', async () => {
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented'))
|
||||
await renderPage()
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('Preset apply is not available on the server; applying locally instead'))
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalled())
|
||||
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(400, 'bad', { error: 'validation failed' }))
|
||||
await userEvent.click(applyBtn)
|
||||
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('validation failed'))
|
||||
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub'))
|
||||
await userEvent.click(applyBtn)
|
||||
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
|
||||
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'not cached', { error: 'Pull the preset first' }))
|
||||
await userEvent.click(applyBtn)
|
||||
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('Preset must be pulled'))
|
||||
})
|
||||
|
||||
it('records backup info on apply failure and generic errors', async () => {
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'failed', { error: 'boom', backup: '/tmp/backup' }))
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getByTestId('apply-preset-btn'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/backup'))
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(new Error('unexpected'))
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getByTestId('apply-preset-btn'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to apply preset'))
|
||||
})
|
||||
|
||||
it('disables apply when hub is unavailable for hub-only preset', async () => {
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
|
||||
presets: [
|
||||
{
|
||||
slug: 'hub-only',
|
||||
title: 'Hub Only',
|
||||
summary: 'needs hub',
|
||||
source: 'hub',
|
||||
requires_hub: true,
|
||||
available: true,
|
||||
cached: true,
|
||||
cache_key: 'cache-hub',
|
||||
etag: 'etag-hub',
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub'))
|
||||
await renderPage()
|
||||
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
|
||||
expect((screen.getByTestId('apply-preset-btn') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('guards local apply prerequisites and succeeds when content exists', async () => {
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValueOnce({ files: [] })
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented'))
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getByTestId('apply-preset-btn'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Select a configuration file to apply the preset'))
|
||||
|
||||
cleanup()
|
||||
vi.mocked(toast.error).mockClear()
|
||||
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
|
||||
presets: [
|
||||
{
|
||||
slug: 'custom-empty',
|
||||
title: 'Empty',
|
||||
summary: 'empty preset',
|
||||
source: 'hub',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
cache_key: 'cache-empty',
|
||||
etag: 'etag-empty',
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'custom-empty',
|
||||
preview: '',
|
||||
cache_key: 'cache-empty',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented'))
|
||||
await renderPage()
|
||||
await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml')
|
||||
await userEvent.click(screen.getByTestId('apply-preset-btn'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Preset preview is unavailable; retry pulling before applying'))
|
||||
|
||||
cleanup()
|
||||
vi.mocked(toast.error).mockClear()
|
||||
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: presetFromCatalog.slug,
|
||||
preview: 'content',
|
||||
cache_key: 'cache-123',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented'))
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
|
||||
await renderPage()
|
||||
await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml')
|
||||
await userEvent.click(screen.getByTestId('apply-preset-btn'))
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('reads, edits, saves, and closes files', async () => {
|
||||
await renderPage()
|
||||
await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml')
|
||||
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('acquis.yaml'))
|
||||
// Use getAllByRole and filter for textarea (not the search input)
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('file-content')
|
||||
await userEvent.clear(textarea)
|
||||
await userEvent.type(textarea, 'updated')
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', 'updated'))
|
||||
|
||||
await userEvent.click(screen.getByText('Close'))
|
||||
expect((screen.getByTestId('crowdsec-file-select') as HTMLSelectElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('shows decisions table, handles loading/error/empty states, and unban errors', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus)
|
||||
await renderPage()
|
||||
expect(screen.getByText('Enable CrowdSec to manage banned IPs')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(baseStatus)
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockReturnValue(new Promise(() => {}))
|
||||
await renderPage()
|
||||
expect(screen.getByText('Loading banned IPs...')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockRejectedValueOnce(new Error('decisions'))
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Failed to load banned IPs')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({ decisions: [] })
|
||||
await renderPage()
|
||||
expect(await screen.findByText('No banned IPs')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({
|
||||
decisions: [
|
||||
{ id: '1', ip: '1.1.1.1', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' },
|
||||
],
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByText('1.1.1.1')).toBeInTheDocument()
|
||||
|
||||
vi.mocked(crowdsecApi.unbanIP).mockRejectedValueOnce(new Error('unban fail'))
|
||||
await userEvent.click(screen.getAllByText('Unban')[0])
|
||||
const confirmModal = screen.getByText('Confirm Unban').closest('div') as HTMLElement
|
||||
await userEvent.click(within(confirmModal).getByRole('button', { name: 'Unban' }))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('unban fail'))
|
||||
})
|
||||
|
||||
it('bans and unbans IPs with overlay messaging', async () => {
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({
|
||||
decisions: [
|
||||
{ id: '1', ip: '1.1.1.1', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' },
|
||||
],
|
||||
})
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getByRole('button', { name: /Ban IP/ }))
|
||||
const banModal = screen.getByText('Ban IP Address').closest('div') as HTMLElement
|
||||
const ipInput = within(banModal).getByPlaceholderText('192.168.1.100') as HTMLInputElement
|
||||
await userEvent.type(ipInput, '2.2.2.2')
|
||||
await userEvent.click(within(banModal).getByRole('button', { name: 'Ban IP' }))
|
||||
await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('2.2.2.2', '24h', ''))
|
||||
|
||||
// keep ban pending to assert overlay message
|
||||
let resolveBan: (() => void) | undefined
|
||||
vi.mocked(crowdsecApi.banIP).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBan = () => resolve()
|
||||
}),
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /Ban IP/ }))
|
||||
const banModalSecond = screen.getByText('Ban IP Address').closest('div') as HTMLElement
|
||||
await userEvent.type(within(banModalSecond).getByPlaceholderText('192.168.1.100'), '3.3.3.3')
|
||||
await userEvent.click(within(banModalSecond).getByRole('button', { name: 'Ban IP' }))
|
||||
expect(await screen.findByText('Guardian raises shield...')).toBeInTheDocument()
|
||||
resolveBan?.()
|
||||
|
||||
vi.mocked(crowdsecApi.unbanIP).mockImplementationOnce(() => new Promise(() => {}))
|
||||
const unbanButtons = await screen.findAllByText('Unban')
|
||||
await userEvent.click(unbanButtons[0])
|
||||
const confirmDialog = screen.getByText('Confirm Unban').closest('div') as HTMLElement
|
||||
await userEvent.click(within(confirmDialog).getByRole('button', { name: 'Unban' }))
|
||||
expect(await screen.findByText('Guardian lowers shield...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows overlay messaging for preset pull, apply, import, write, and mode updates', async () => {
|
||||
// pull pending
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockImplementation(() => new Promise(() => {}))
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getByText('Pull Preview'))
|
||||
expect(await screen.findByText('Fetching preset...')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockReset()
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: presetFromCatalog.slug,
|
||||
preview: presetFromCatalog.content,
|
||||
cache_key: 'cache-123',
|
||||
})
|
||||
|
||||
// apply pending
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValueOnce({
|
||||
status: 'pulled',
|
||||
slug: presetFromCatalog.slug,
|
||||
preview: presetFromCatalog.content,
|
||||
cache_key: 'cache-123',
|
||||
})
|
||||
let resolveApply: (() => void) | undefined
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveApply = () => resolve({ status: 'applied', cache_key: 'cache-123' } as never)
|
||||
}),
|
||||
)
|
||||
await renderPage()
|
||||
await userEvent.click(screen.getAllByTestId('apply-preset-btn')[0])
|
||||
expect(await screen.findByText('Loading preset...')).toBeInTheDocument()
|
||||
resolveApply?.()
|
||||
|
||||
cleanup()
|
||||
|
||||
// import pending
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValueOnce({
|
||||
status: 'pulled',
|
||||
slug: presetFromCatalog.slug,
|
||||
preview: presetFromCatalog.content,
|
||||
cache_key: 'cache-123',
|
||||
})
|
||||
let resolveImport: (() => void) | undefined
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveImport = () => resolve({})
|
||||
}),
|
||||
)
|
||||
const { queryClient } = await renderPage(createTestQueryClient())
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toBeInTheDocument())
|
||||
const fileInput = screen.getByTestId('import-file') as HTMLInputElement
|
||||
await userEvent.upload(fileInput, new File(['data'], 'cfg.tar.gz'))
|
||||
await userEvent.click(screen.getByTestId('import-btn'))
|
||||
expect(await screen.findByText('Summoning the guardian...')).toBeInTheDocument()
|
||||
resolveImport?.()
|
||||
await act(async () => queryClient.cancelQueries())
|
||||
|
||||
cleanup()
|
||||
|
||||
// write pending shows loading overlay
|
||||
let resolveWrite: (() => void) | undefined
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveWrite = () => resolve({})
|
||||
}),
|
||||
)
|
||||
await renderPage()
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toBeInTheDocument())
|
||||
await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml')
|
||||
// Use getAllByRole and filter for textarea (not the search input)
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea') as HTMLTextAreaElement
|
||||
await userEvent.type(textarea, 'x')
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
expect(await screen.findByText('Guardian inscribes...')).toBeInTheDocument()
|
||||
resolveWrite?.()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,391 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AxiosError, AxiosResponse } from 'axios'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as api from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
import * as consoleApi from '../../api/consoleEnrollment'
|
||||
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/presets')
|
||||
vi.mock('../../api/featureFlags')
|
||||
vi.mock('../../api/consoleEnrollment')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
|
||||
presets: CROWDSEC_PRESETS.map((preset) => ({
|
||||
slug: preset.slug,
|
||||
title: preset.title,
|
||||
summary: preset.description,
|
||||
source: 'charon',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
})),
|
||||
})
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
preview: CROWDSEC_PRESETS[0].content,
|
||||
cache_key: 'cache-123',
|
||||
etag: 'etag-123',
|
||||
retrieved_at: '2024-01-01T00:00:00Z',
|
||||
source: 'hub',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
|
||||
status: 'applied',
|
||||
backup: '/tmp/backup.tar.gz',
|
||||
reload_hint: true,
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
})
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached', cache_key: 'cache-123', etag: 'etag-123' })
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
})
|
||||
vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'not_enrolled', key_present: false })
|
||||
vi.mocked(consoleApi.enrollConsole).mockResolvedValue({ status: 'enrolling', key_present: true })
|
||||
})
|
||||
|
||||
it('exports config when clicking Export', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
await userEvent.click(exportBtn)
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('uploads a file and calls import on Import (backup before save)', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({ status: 'imported' })
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const input = screen.getByTestId('import-file') as HTMLInputElement
|
||||
const file = new File(['dummy'], 'cfg.tar.gz')
|
||||
await userEvent.upload(input, file)
|
||||
const btn = screen.getByTestId('import-btn')
|
||||
await userEvent.click(btn)
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('hides console enrollment when feature flag is off', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('console-enrollment-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows console enrollment form when feature flag is on', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-enrollment-card')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('console-enrollment-token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates required console enrollment fields and acknowledgement', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const enrollBtn = await screen.findByTestId('console-enroll-btn')
|
||||
|
||||
// Button should be disabled when enrollment token is empty
|
||||
expect(enrollBtn).toBeDisabled()
|
||||
|
||||
// Type only token (missing agent name, tenant, and ack)
|
||||
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'token-123')
|
||||
|
||||
// Now button should be enabled, click it
|
||||
await waitFor(() => expect(enrollBtn).not.toBeDisabled())
|
||||
await userEvent.click(enrollBtn)
|
||||
|
||||
// Should show validation errors for missing fields
|
||||
const errors = await screen.findAllByTestId('console-enroll-error')
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(consoleApi.enrollConsole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits console enrollment payload with snake_case fields', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(consoleApi.enrollConsole).mockResolvedValue({ status: 'enrolled', key_present: true, agent_name: 'agent-one', tenant: 'tenant-inc' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-enrollment-card')).toBeInTheDocument())
|
||||
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'secret-1234567890')
|
||||
await userEvent.clear(screen.getByTestId('console-agent-name'))
|
||||
await userEvent.type(screen.getByTestId('console-agent-name'), 'agent-one')
|
||||
await userEvent.type(screen.getByTestId('console-tenant'), 'tenant-inc')
|
||||
await userEvent.click(screen.getByTestId('console-ack-checkbox'))
|
||||
await userEvent.click(screen.getByTestId('console-enroll-btn'))
|
||||
|
||||
await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith({
|
||||
enrollment_key: 'secret-1234567890',
|
||||
agent_name: 'agent-one',
|
||||
tenant: 'tenant-inc',
|
||||
force: false,
|
||||
}))
|
||||
|
||||
expect((screen.getByTestId('console-enrollment-token') as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('renders masked key state in console status', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'enrolled', key_present: true, agent_name: 'a1', tenant: 't1', last_heartbeat_at: '2024-01-01T00:00:00Z' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-token-state')).toHaveTextContent('Stored (masked)'))
|
||||
})
|
||||
|
||||
it('retries degraded enrollment and rotates key when enrolled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(consoleApi.getConsoleStatus).mockResolvedValueOnce({ status: 'failed', key_present: true, last_error: 'network' })
|
||||
vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'enrolled', key_present: true })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-ack-checkbox')).toBeInTheDocument())
|
||||
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'another-secret-123456')
|
||||
await userEvent.click(screen.getByTestId('console-ack-checkbox'))
|
||||
await userEvent.click(screen.getByTestId('console-retry-btn'))
|
||||
await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith(expect.objectContaining({ force: true })))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-rotate-btn')).not.toBeDisabled())
|
||||
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'rotate-token-987654321')
|
||||
await userEvent.click(screen.getByTestId('console-rotate-btn'))
|
||||
await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enrollment_key: 'rotate-token-987654321',
|
||||
force: true,
|
||||
})))
|
||||
})
|
||||
|
||||
it('lists files, reads file content and can save edits (backup before save)', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['conf.d/a.conf', 'b.conf'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'rule1' })
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
// wait for file list
|
||||
await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument())
|
||||
const select = screen.getByTestId('crowdsec-file-select')
|
||||
await userEvent.selectOptions(select, 'conf.d/a.conf')
|
||||
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
|
||||
// ensure textarea populated - use getAllByRole and filter for textarea (not the search input)
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea')!
|
||||
expect(textarea).toHaveValue('rule1')
|
||||
// edit and save
|
||||
await userEvent.clear(textarea)
|
||||
await userEvent.type(textarea, 'updated')
|
||||
const saveBtn = screen.getByText('Save')
|
||||
await userEvent.click(saveBtn)
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf', 'updated'))
|
||||
})
|
||||
|
||||
it('shows info banner directing to Security Dashboard for mode control', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
expect(screen.getByText(/CrowdSec is controlled via the toggle on the/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Security/i })).toHaveAttribute('href', '/security')
|
||||
})
|
||||
|
||||
it('renders preset preview and applies with backup when backend apply is unavailable', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
const presetContent = CROWDSEC_PRESETS.find((preset) => preset.slug === 'bot-mitigation-essentials')?.content || ''
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' })
|
||||
const axiosError = new AxiosError('not implemented', undefined, undefined, undefined, {
|
||||
status: 501,
|
||||
statusText: 'Not Implemented',
|
||||
headers: {},
|
||||
config: { headers: {} },
|
||||
data: {},
|
||||
} as AxiosResponse)
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValue(axiosError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('configs:'))
|
||||
const fileSelect = screen.getByTestId('crowdsec-file-select')
|
||||
await userEvent.selectOptions(fileSelect, 'acquis.yaml')
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(presetsApi.applyCrowdsecPreset).toHaveBeenCalledWith({ slug: 'bot-mitigation-essentials', cache_key: 'cache-123' }))
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', presetContent))
|
||||
})
|
||||
|
||||
it('surfaces validation error when slug is invalid', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
const validationError = new AxiosError('invalid', undefined, undefined, undefined, {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {},
|
||||
config: { headers: {} },
|
||||
data: { error: 'slug invalid' },
|
||||
} as AxiosResponse)
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(validationError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('slug invalid'))
|
||||
})
|
||||
|
||||
it('disables apply and offers cached preview when hub is unavailable', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
|
||||
presets: [
|
||||
{
|
||||
slug: 'hub-only',
|
||||
title: 'Hub Only',
|
||||
summary: 'Needs hub',
|
||||
source: 'hub',
|
||||
requires_hub: true,
|
||||
available: true,
|
||||
cached: true,
|
||||
cache_key: 'cache-hub',
|
||||
etag: 'etag-hub',
|
||||
},
|
||||
],
|
||||
})
|
||||
const hubError = new AxiosError('unavailable', undefined, undefined, undefined, {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {},
|
||||
config: { headers: {} },
|
||||
data: { error: 'hub service unavailable' },
|
||||
} as AxiosResponse)
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValue(hubError)
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached-preview', cache_key: 'cache-hub', etag: 'etag-hub' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
// Wait for presets to load and click on the preset card
|
||||
const presetCard = await screen.findByText('Hub Only')
|
||||
await userEvent.click(presetCard)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
|
||||
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn') as HTMLButtonElement
|
||||
expect(applyBtn.disabled).toBe(true)
|
||||
|
||||
await userEvent.click(screen.getByText('Use Cached'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview'))
|
||||
})
|
||||
|
||||
it('shows apply response metadata including backup path', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValueOnce({
|
||||
status: 'applied',
|
||||
backup: '/tmp/crowdsec-backup',
|
||||
reload_hint: true,
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
})
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const applyBtn = await screen.findByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/crowdsec-backup'))
|
||||
expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Status: applied')
|
||||
expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Method: cscli')
|
||||
// reloadHint is a boolean and renders as empty/true - just verify the info section exists
|
||||
})
|
||||
|
||||
it('shows improved error message when preset is not cached', async () => {
|
||||
const axiosError = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 500,
|
||||
data: {
|
||||
error: 'CrowdSec preset not cached. Pull the preset first by clicking \'Pull Preview\', then try applying again.',
|
||||
},
|
||||
},
|
||||
message: 'Request failed',
|
||||
} as AxiosError
|
||||
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const applyBtn = await screen.findByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('Preset must be pulled before applying')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
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 { MemoryRouter } from 'react-router-dom'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/presets')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
const createClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = () => {
|
||||
const queryClient = createClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CrowdSecConfig />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled', enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
})
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] })
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
preview: 'configs: {}',
|
||||
cache_key: 'cache-123',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', backup: '/tmp/backup.tar.gz', cache_key: 'cache-123' })
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'configs: {}', cache_key: 'cache-123' })
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
window.URL.createObjectURL = vi.fn(() => 'blob:url')
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('shows info banner directing to Security Dashboard', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByText(/CrowdSec is controlled via the toggle on the/i))
|
||||
expect(screen.getByRole('link', { name: /Security/i })).toHaveAttribute('href', '/security')
|
||||
})
|
||||
|
||||
it('exports configuration packages with prompted filename', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
|
||||
const exportButton = screen.getByRole('button', { name: /Export/i })
|
||||
|
||||
await userEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Configuration Packages heading', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByText('Configuration Packages'))
|
||||
|
||||
expect(screen.getByText('Configuration Packages')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import Dashboard from '../Dashboard'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
|
||||
vi.mock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: () => ({
|
||||
hosts: [
|
||||
{ id: 1, enabled: true, ssl_forced: false, domain_names: 'test.com' },
|
||||
{ id: 2, enabled: false, ssl_forced: false, domain_names: 'test2.com' },
|
||||
],
|
||||
loading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: () => ({
|
||||
servers: [
|
||||
{ id: 1, enabled: true },
|
||||
{ id: 2, enabled: true },
|
||||
],
|
||||
loading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: () => ({
|
||||
certificates: [
|
||||
{ id: 1, status: 'valid', domain: 'test.com' },
|
||||
{ id: 2, status: 'expired', domain: 'expired.com' },
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useAccessLists', () => ({
|
||||
useAccessLists: () => ({
|
||||
data: [{ id: 1, enabled: true }],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/health', () => ({
|
||||
checkHealth: vi.fn().mockResolvedValue({ status: 'ok', version: '1.0.0' }),
|
||||
}))
|
||||
|
||||
// Mock UptimeWidget to avoid complex dependencies
|
||||
vi.mock('../../components/UptimeWidget', () => ({
|
||||
default: () => <div data-testid="uptime-widget">Uptime Widget</div>,
|
||||
}))
|
||||
|
||||
describe('Dashboard page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders counts and health status', async () => {
|
||||
renderWithQueryClient(<Dashboard />)
|
||||
|
||||
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
|
||||
expect(await screen.findByText('1 enabled')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 enabled')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 valid')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Healthy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state when health check fails', async () => {
|
||||
const { checkHealth } = await import('../../api/health')
|
||||
vi.mocked(checkHealth).mockResolvedValueOnce({ status: 'fail', version: '1.0.0' } as never)
|
||||
|
||||
renderWithQueryClient(<Dashboard />)
|
||||
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ImportCrowdSec from '../ImportCrowdSec'
|
||||
import * as api from '../../api/crowdsec'
|
||||
import * as backups from '../../api/backups'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ImportCrowdSec page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('creates a backup then imports crowdsec', async () => {
|
||||
const file = new File(['fake'], 'crowdsec.zip', { type: 'application/zip' })
|
||||
vi.mocked(backups.createBackup).mockResolvedValue({ filename: 'b1' })
|
||||
vi.mocked(api.importCrowdsecConfig).mockResolvedValue({ success: true })
|
||||
|
||||
renderWithProviders(<ImportCrowdSec />)
|
||||
const fileInput = document.querySelector('input[type="file"]')
|
||||
expect(fileInput).toBeTruthy()
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
const importBtn = screen.getByText('Import')
|
||||
const user = userEvent.setup()
|
||||
await user.click(importBtn)
|
||||
|
||||
await waitFor(() => expect(backups.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(api.importCrowdsecConfig).toHaveBeenCalledWith(file))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import ImportCrowdSec from '../ImportCrowdSec'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ImportCrowdSec', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
|
||||
})
|
||||
|
||||
const renderPage = () => {
|
||||
const qc = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<ImportCrowdSec />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('renders configuration packages heading', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => screen.getByText('CrowdSec Configuration Packages'))
|
||||
expect(screen.getByText('CrowdSec Configuration Packages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a backup before importing selected package', async () => {
|
||||
renderPage()
|
||||
|
||||
const fileInput = screen.getByTestId('crowdsec-import-file') as HTMLInputElement
|
||||
const file = new File(['config'], 'config.tar.gz', { type: 'application/gzip' })
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.upload(fileInput, file)
|
||||
|
||||
const importButton = screen.getByRole('button', { name: /Import/i })
|
||||
await user.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled()
|
||||
expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalledWith(file)
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec config imported')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
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 { MemoryRouter } from 'react-router-dom'
|
||||
import Login from '../Login'
|
||||
import * as authHook from '../../hooks/useAuth'
|
||||
import client from '../../api/client'
|
||||
import * as setupApi from '../../api/setup'
|
||||
|
||||
// Mock modules
|
||||
vi.mock('../../api/client')
|
||||
vi.mock('../../hooks/useAuth')
|
||||
vi.mock('../../api/setup')
|
||||
|
||||
const mockLogin = vi.fn()
|
||||
vi.mocked(authHook.useAuth).mockReturnValue({
|
||||
user: null,
|
||||
login: mockLogin,
|
||||
logout: vi.fn(),
|
||||
loading: false,
|
||||
} as unknown as ReturnType<typeof authHook.useAuth>)
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Login - Coin Overlay Security Audit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Mock setup status to resolve immediately with no setup required
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false })
|
||||
})
|
||||
|
||||
it('shows coin-themed overlay during login', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Coin-themed overlay should appear
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
|
||||
|
||||
// Verify coin theme (gold/amber) - use querySelector to find actual overlay container
|
||||
const overlay = document.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
})
|
||||
|
||||
it('ATTACK: rapid fire login attempts are blocked by overlay', async () => {
|
||||
let resolveCount = 0
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolveCount++
|
||||
resolve({ data: {} })
|
||||
}, 200)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
|
||||
// Click multiple times rapidly
|
||||
await userEvent.click(submitButton)
|
||||
await userEvent.click(submitButton)
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should block subsequent clicks (form is disabled)
|
||||
expect(emailInput).toBeDisabled()
|
||||
expect(passwordInput).toBeDisabled()
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 300 })
|
||||
|
||||
// Should only execute once
|
||||
expect(resolveCount).toBe(1)
|
||||
})
|
||||
|
||||
it('clears overlay on login error', async () => {
|
||||
// Use delayed rejection so overlay has time to appear
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise((_, reject) => {
|
||||
setTimeout(() => reject({ response: { data: { error: 'Invalid credentials' } } }), 100)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'wrong@example.com')
|
||||
await userEvent.type(passwordInput, 'wrong')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay appears
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
// Overlay clears after error
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 300 })
|
||||
|
||||
// Form should be re-enabled
|
||||
expect(emailInput).not.toBeDisabled()
|
||||
expect(passwordInput).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('ATTACK: XSS in login credentials does not break overlay', async () => {
|
||||
// Use delayed promise so we can catch the overlay
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
// Use valid email format with XSS-like characters in password
|
||||
await userEvent.type(emailInput, 'test@example.com')
|
||||
await userEvent.type(passwordInput, '<img src=x onerror=alert(1)>')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should still work
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 300 })
|
||||
})
|
||||
|
||||
it('ATTACK: network timeout does not leave overlay stuck', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Network timeout')), 100)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
// Overlay should clear after error
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
})
|
||||
|
||||
it('overlay has correct z-index hierarchy', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should be z-50
|
||||
const overlay = document.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('overlay renders CharonCoinLoader component', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// CharonCoinLoader has aria-label="Authenticating"
|
||||
expect(screen.getByLabelText('Authenticating')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
// Mock react-router-dom useNavigate at module level
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Login from '../Login'
|
||||
import * as setupApi from '../../api/setup'
|
||||
import client from '../../api/client'
|
||||
import * as authHook from '../../hooks/useAuth'
|
||||
import type { AuthContextType } from '../../context/AuthContextValue'
|
||||
import { toast } from '../../utils/toast'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
vi.mock('../../api/setup')
|
||||
vi.mock('../../hooks/useAuth')
|
||||
|
||||
describe('<Login />', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => (
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: vi.fn() } as unknown as AuthContextType)
|
||||
})
|
||||
|
||||
it('navigates to /setup when setup is required', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: true })
|
||||
renderWithProviders(<Login />)
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/setup')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast when login fails', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false })
|
||||
const postSpy = vi.spyOn(client, 'post').mockRejectedValueOnce({ response: { data: { error: 'Bad creds' } } })
|
||||
const toastSpy = vi.spyOn(toast, 'error')
|
||||
renderWithProviders(<Login />)
|
||||
// Fill and submit
|
||||
const email = screen.getByPlaceholderText(/admin@example.com/i)
|
||||
const pass = screen.getByPlaceholderText(/••••••••/i)
|
||||
fireEvent.change(email, { target: { value: 'a@b.com' } })
|
||||
fireEvent.change(pass, { target: { value: 'pw' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign In/i }))
|
||||
// Wait for the promise chain
|
||||
await waitFor(() => expect(postSpy).toHaveBeenCalled())
|
||||
expect(toastSpy).toHaveBeenCalledWith('Bad creds')
|
||||
})
|
||||
|
||||
it('uses returned token when cookie is unavailable', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false })
|
||||
const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { token: 'bearer-token' } })
|
||||
const loginFn = vi.fn().mockResolvedValue(undefined)
|
||||
vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: loginFn } as unknown as AuthContextType)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
const email = screen.getByPlaceholderText(/admin@example.com/i)
|
||||
const pass = screen.getByPlaceholderText(/••••••••/i)
|
||||
fireEvent.change(email, { target: { value: 'a@b.com' } })
|
||||
fireEvent.change(pass, { target: { value: 'pw' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign In/i }))
|
||||
|
||||
await waitFor(() => expect(postSpy).toHaveBeenCalled())
|
||||
expect(loginFn).toHaveBeenCalledWith('bearer-token')
|
||||
})
|
||||
|
||||
it('has proper autocomplete attributes for password managers', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false })
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText(/admin@example.com/i))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/admin@example.com/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/••••••••/i)
|
||||
|
||||
expect(emailInput).toHaveAttribute('autocomplete', 'email')
|
||||
expect(passwordInput).toHaveAttribute('autocomplete', 'current-password')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,581 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock API modules
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn(),
|
||||
getBackups: vi.fn(),
|
||||
restoreBackup: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
getCertificates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/accessLists', () => ({
|
||||
accessListsApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
testIP: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api/settings', () => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
|
||||
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
|
||||
];
|
||||
|
||||
const mockAccessLists = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'acl-1',
|
||||
name: 'Admin Only',
|
||||
description: 'Admin access',
|
||||
type: 'whitelist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'acl-2',
|
||||
name: 'Local Network',
|
||||
description: 'Local network only',
|
||||
type: 'whitelist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: true,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: 'acl-3',
|
||||
name: 'Disabled ACL',
|
||||
description: 'This is disabled',
|
||||
type: 'blacklist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk ACL Modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mocks
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue(mockAccessLists);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('renders Manage ACL button when hosts are selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using the select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Manage ACL button should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens bulk ACL modal when Manage ACL is clicked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click Manage ACL
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Modal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Apply Access List')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Apply ACL and Remove ACL toggle buttons', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Should show toggle buttons
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Apply ACL' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Remove ACL' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only enabled access lists in the selection', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Should show enabled ACLs
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
expect(screen.getByText('Local Network')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should NOT show disabled ACL
|
||||
expect(screen.queryByText('Disabled ACL')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows ACL type alongside name', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Should show type - the modal should display ACL types
|
||||
await waitFor(() => {
|
||||
// Check that the ACL list is rendered with names
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
expect(screen.getByText('Local Network')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('has Apply button disabled when no ACL is selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for modal to open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Apply Access List')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Apply action button should be disabled (the one with bg-blue-600 class, not the toggle)
|
||||
// The action button text is "Apply" or "Apply (N)"
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const applyButton = buttons.find(btn => {
|
||||
const text = btn.textContent?.trim() || '';
|
||||
// Match "Apply" exactly but not "Apply ACL" (which is the toggle)
|
||||
const isApplyAction = text === 'Apply' || /^Apply \(\d+\)$/.test(text);
|
||||
return isApplyAction;
|
||||
});
|
||||
expect(applyButton).toBeTruthy();
|
||||
expect((applyButton as HTMLButtonElement)?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('enables Apply button when ACL is selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
const user = userEvent.setup()
|
||||
await user.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await user.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for ACL list
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select an ACL
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
// Find the checkbox for Admin Only (should be after the host selection checkboxes)
|
||||
const aclCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
if (aclCheckbox) {
|
||||
await userEvent.click(aclCheckbox);
|
||||
}
|
||||
|
||||
// Apply button should be enabled and show count
|
||||
await waitFor(() => {
|
||||
const applyButton = screen.getByRole('button', { name: /Apply \(1\)/ });
|
||||
expect(applyButton).toBeTruthy();
|
||||
expect(applyButton).toHaveProperty('disabled', false);
|
||||
});
|
||||
});
|
||||
|
||||
it('can select multiple ACLs', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for ACL list
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select multiple ACLs
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
const adminCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
const localCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Local Network')
|
||||
);
|
||||
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox);
|
||||
if (localCheckbox) await userEvent.click(localCheckbox);
|
||||
|
||||
// Apply button should show count of 2
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply \(2\)/ })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies ACL to selected hosts successfully', async () => {
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({
|
||||
updated: 2,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for ACL list and select one
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
const adminCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox);
|
||||
|
||||
// Click Apply
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ }));
|
||||
|
||||
// Should call API
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith(
|
||||
['host-1', 'host-2'],
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
// Should show success toast
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Updated successfully');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Remove ACL confirmation when Remove is selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for modal and find Remove ACL toggle (it's a button with flex-1 class)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Apply Access List')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find the toggle button by looking for flex-1 class
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const removeToggle = buttons.find(btn =>
|
||||
btn.textContent === 'Remove ACL' && btn.className.includes('flex-1')
|
||||
);
|
||||
expect(removeToggle).toBeTruthy();
|
||||
if (removeToggle) await userEvent.click(removeToggle);
|
||||
|
||||
// Should show warning message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/will become publicly accessible/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal on Cancel', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Modal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Apply Access List')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click Cancel
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Apply Access List')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears selection and closes modal after successful apply', async () => {
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({
|
||||
updated: 2,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Select ACL and apply
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
const adminCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ }));
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Apply Access List')).toBeNull();
|
||||
});
|
||||
|
||||
// Selection should be cleared (Manage ACL button should be gone)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Manage ACL')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast on API failure', async () => {
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({
|
||||
updated: 1,
|
||||
errors: [{ uuid: 'host-2', error: 'Failed' }],
|
||||
});
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Select ACL and apply
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
const adminCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ }));
|
||||
|
||||
// Should show error toast
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to update');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }));
|
||||
vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost: vi.fn(), updateProxyHost: vi.fn(), deleteProxyHost: vi.fn(), bulkUpdateACL: vi.fn(), testProxyHostConnection: vi.fn() }));
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
|
||||
|
||||
const hosts = [
|
||||
createMockProxyHost({ uuid: 'h1', name: 'Host 1', domain_names: 'one.example.com' }),
|
||||
createMockProxyHost({ uuid: 'h2', name: 'Host 2', domain_names: 'two.example.com' }),
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Apply all settings coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(hosts as ProxyHost[]);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
|
||||
});
|
||||
|
||||
it('renders all bulk apply setting labels and allows toggling', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Host 1')).toBeTruthy());
|
||||
|
||||
// select all
|
||||
const headerCheckbox = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(headerCheckbox);
|
||||
|
||||
// open Bulk Apply
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
const labels = [
|
||||
'Force SSL',
|
||||
'HTTP/2 Support',
|
||||
'HSTS Enabled',
|
||||
'HSTS Subdomains',
|
||||
'Block Exploits',
|
||||
'Websockets Support',
|
||||
];
|
||||
|
||||
const { within } = await import('@testing-library/react');
|
||||
|
||||
for (const lbl of labels) {
|
||||
expect(screen.getByText(lbl)).toBeTruthy();
|
||||
// Find the setting row and click the Radix Checkbox (role="checkbox")
|
||||
const labelEl = screen.getByText(lbl) as HTMLElement;
|
||||
const row = labelEl.closest('.p-3') as HTMLElement;
|
||||
const checkboxes = within(row).getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
}
|
||||
|
||||
// After toggling at least one, Apply should be enabled
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyBtn = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
expect(applyBtn).toBeTruthy();
|
||||
// Cancel to close
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: /Cancel/i }));
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost: vi.fn(), updateProxyHost: vi.fn(), deleteProxyHost: vi.fn(), bulkUpdateACL: vi.fn(), testProxyHostConnection: vi.fn() }))
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
|
||||
const hosts = [
|
||||
createMockProxyHost({ uuid: 'p1', name: 'Progress 1', domain_names: 'p1.example.com' }),
|
||||
createMockProxyHost({ uuid: 'p2', name: 'Progress 2', domain_names: 'p2.example.com' }),
|
||||
]
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProxyHosts - Bulk Apply progress UI', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(hosts as ProxyHost[])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>)
|
||||
})
|
||||
|
||||
it('shows applying progress while updateProxyHost resolves', async () => {
|
||||
// Make updateProxyHost return controllable promises so we can assert the progress UI
|
||||
const updateMock = vi.mocked(proxyHostsApi.updateProxyHost)
|
||||
const resolvers: Array<(v: ProxyHost) => void> = []
|
||||
updateMock.mockImplementation(() => new Promise((res: (v: ProxyHost) => void) => { resolvers.push(res) }))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Progress 1')).toBeTruthy())
|
||||
|
||||
// Select all
|
||||
const selectAll = screen.getByLabelText('Select all rows')
|
||||
await userEvent.click(selectAll)
|
||||
|
||||
// Open Bulk Apply
|
||||
await userEvent.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
|
||||
// Enable one setting (Force SSL) - use Radix Checkbox (role="checkbox") in the row
|
||||
const forceLabel = screen.getByText(/Force SSL/i) as HTMLElement
|
||||
const forceRow = forceLabel.closest('.p-3') as HTMLElement
|
||||
const { within } = await import('@testing-library/react')
|
||||
const forceCheckbox = within(forceRow).getAllByRole('checkbox')[0]
|
||||
await userEvent.click(forceCheckbox)
|
||||
|
||||
// Click Apply and assert progress UI appears
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i })
|
||||
await userEvent.click(applyButton)
|
||||
|
||||
// During the small delay the progress text should appear (there are two matching nodes)
|
||||
await waitFor(() => expect(screen.getAllByText(/Applying settings/i).length).toBeGreaterThan(0))
|
||||
|
||||
// Resolve both pending update promises to finish the operation
|
||||
resolvers.forEach(r => r(hosts[0]))
|
||||
// Ensure subsequent tests aren't blocked by the special mock: make updateProxyHost resolve normally
|
||||
updateMock.mockImplementation(() => Promise.resolve(hosts[0] as ProxyHost))
|
||||
|
||||
// Wait for updates to complete
|
||||
await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
|
||||
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Apply Settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts as ProxyHost[]);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
|
||||
});
|
||||
|
||||
it('shows Bulk Apply button when hosts selected and opens modal', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select first host using select-all checkbox
|
||||
const selectAll = screen.getAllByRole('checkbox')[0];
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
// Bulk Apply button should appear
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
|
||||
// Open modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('applies selected settings to all selected hosts by calling updateProxyHost merged payload', async () => {
|
||||
const updateMock = vi.mocked(proxyHostsApi.updateProxyHost);
|
||||
updateMock.mockResolvedValue(mockProxyHosts[0] as ProxyHost);
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
|
||||
// Open Bulk Apply modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
// Enable first setting checkbox (Force SSL) - find the row by text and then get the Radix Checkbox (role="checkbox")
|
||||
const forceLabel = screen.getByText(/Force SSL/i) as HTMLElement;
|
||||
const forceRow = forceLabel.closest('.p-3') as HTMLElement;
|
||||
const { within } = await import('@testing-library/react');
|
||||
// The Radix Checkbox has role="checkbox"
|
||||
const forceCheckbox = within(forceRow).getAllByRole('checkbox')[0];
|
||||
await userEvent.click(forceCheckbox);
|
||||
|
||||
// Click Apply (find the dialog and get the button from the footer)
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Should call updateProxyHost for each selected host with merged payload containing ssl_forced
|
||||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalled();
|
||||
const calls = updateMock.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
expect(calls[0][1]).toHaveProperty('ssl_forced');
|
||||
expect(calls[1][1]).toHaveProperty('ssl_forced');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels bulk apply modal when Cancel clicked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
const selectAll = screen.getAllByRole('checkbox')[0];
|
||||
await userEvent.click(selectAll);
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as backupsApi from '../../api/backups';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock API modules
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateProxyHostACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn(),
|
||||
getBackups: vi.fn(),
|
||||
restoreBackup: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
getCertificates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/accessLists', () => ({
|
||||
accessListsApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
testIP: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api/settings', () => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
|
||||
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
|
||||
createMockProxyHost({ uuid: 'host-3', name: 'Test Host 3', domain_names: 'test3.example.com', forward_host: '192.168.1.30' }),
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Delete with Backup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mocks
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({});
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({
|
||||
filename: 'backup-2024-01-01-12-00-00.db',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders bulk delete button when hosts are selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using the select-all checkbox (checkboxes[0])
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Bulk delete button should appear in the selection bar
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows confirmation modal when delete button is clicked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Modal should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should list hosts to be deleted (hosts appear in both table and modal)
|
||||
expect(screen.getAllByText('Test Host 1').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Test Host 2').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Test Host 3').length).toBeGreaterThan(0);
|
||||
|
||||
// Should mention automatic backup
|
||||
expect(screen.getByText(/automatic backup/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates backup before deleting hosts', async () => {
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons and click delete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click confirm delete
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Should create backup first
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show loading toast
|
||||
expect(toast.loading).toHaveBeenCalledWith('Creating backup before deletion...');
|
||||
|
||||
// Should show success toast with backup filename
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Backup created: backup-2024-01-01-12-00-00.db');
|
||||
});
|
||||
|
||||
// Should then delete the hosts
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes multiple selected hosts after backup', async () => {
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click confirm delete
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Should create backup first
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should delete all selected hosts
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-1');
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-2');
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-3');
|
||||
});
|
||||
|
||||
// Should show success message
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'Successfully deleted 3 host(s). Backup available for restore.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports partial success when some deletions fail', async () => {
|
||||
// Make second deletion fail
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost)
|
||||
.mockResolvedValueOnce() // host-1 succeeds
|
||||
.mockRejectedValueOnce(new Error('Network error')) // host-2 fails
|
||||
.mockResolvedValueOnce(); // host-3 succeeds
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal and confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Wait for backup
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show partial success
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Deleted 2 host(s), 1 failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles backup creation failure', async () => {
|
||||
vi.mocked(backupsApi.createBackup).mockRejectedValue(new Error('Backup failed'));
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal and confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Should show error
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Backup failed');
|
||||
});
|
||||
|
||||
// Should NOT delete hosts if backup fails
|
||||
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes modal after successful deletion', async () => {
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click confirm delete
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Delete 3 Proxy Hosts?/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears selection after successful deletion', async () => {
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Should show selection count
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/selected/)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click bulk delete button and confirm (find it via Manage ACL sibling)
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Selection should be cleared - bulk action buttons should disappear
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Manage ACL')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables confirm button while creating backup', async () => {
|
||||
// Make backup creation take time
|
||||
vi.mocked(backupsApi.createBackup).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ filename: 'backup.db' }), 100))
|
||||
);
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click confirm delete
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Backup should be called (confirms the button works and backup process starts)
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for deletion to complete to prevent test pollution
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('can cancel deletion from modal', async () => {
|
||||
// Clear mocks to ensure no pollution from previous tests
|
||||
vi.mocked(backupsApi.createBackup).mockClear();
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockClear();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click cancel
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Delete 3 Proxy Hosts?/i)).toBeNull();
|
||||
});
|
||||
|
||||
// Should NOT create backup or delete
|
||||
expect(backupsApi.createBackup).not.toHaveBeenCalled();
|
||||
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled();
|
||||
|
||||
// Selection should remain
|
||||
expect(screen.getByText(/selected/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows (all) indicator when all hosts selected for deletion', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using the select-all checkbox
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
// Should show "(all)" indicator - format is "<strong>3</strong> host(s) selected (all)"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/host\(s\) selected/)).toBeTruthy();
|
||||
expect(screen.getByText(/\(all\)/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { ProxyHost, Certificate } from '../../api/proxyHosts'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as uptimeApi from '../../api/uptime'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
getCertificates: vi.fn(),
|
||||
deleteCertificate: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
|
||||
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false }
|
||||
}
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const baseHost = (overrides: Partial<ProxyHost> = {}) => createMockProxyHost(overrides)
|
||||
|
||||
describe('ProxyHosts - Certificate Cleanup Prompts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([])
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.db' })
|
||||
})
|
||||
|
||||
it('prompts to delete certificate when deleting proxy host with unique custom cert', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'CustomCert',
|
||||
domains: 'test.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({
|
||||
uuid: 'h1',
|
||||
name: 'Host1',
|
||||
certificate_id: 1,
|
||||
certificate: cert
|
||||
})
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.deleteCertificate).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Click row delete button
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// First dialog appears - "Delete Proxy Host?" confirmation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete Proxy Host?')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Click "Delete" in the confirmation dialog to proceed
|
||||
const confirmDelete = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDelete[confirmDelete.length - 1])
|
||||
|
||||
// Now Certificate cleanup dialog should appear (custom modal, not Radix)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/orphaned certificate/i)).toBeTruthy()
|
||||
expect(screen.getByText('CustomCert')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Find the native checkbox by id="delete_certs"
|
||||
const checkbox = document.getElementById('delete_certs') as HTMLInputElement
|
||||
expect(checkbox).toBeTruthy()
|
||||
expect(checkbox.checked).toBe(false)
|
||||
|
||||
// Check the checkbox to delete certificate
|
||||
await userEvent.click(checkbox)
|
||||
|
||||
// Confirm deletion in the CertificateCleanupDialog
|
||||
const submitButton = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(submitButton[submitButton.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT prompt for certificate deletion when cert is shared by multiple hosts', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'SharedCert',
|
||||
domains: 'shared.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteButtons[0])
|
||||
|
||||
// Should show standard confirmation dialog (not cert cleanup)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Proxy Host\?/)).toBeTruthy()
|
||||
})
|
||||
|
||||
// There should NOT be an orphaned certificate checkbox since cert is still used by Host2
|
||||
expect(screen.queryByText(/orphaned certificate/i)).toBeNull()
|
||||
|
||||
// Click Delete to confirm
|
||||
const confirmDeleteBtn = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDeleteBtn[confirmDeleteBtn.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
})
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT prompt for production Let\'s Encrypt certificates', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'letsencrypt',
|
||||
name: 'LE Prod',
|
||||
domains: 'prod.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Should show standard confirmation dialog (not cert cleanup with orphan checkbox)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Proxy Host\?/)).toBeTruthy()
|
||||
})
|
||||
|
||||
// There should NOT be an orphaned certificate option for production Let's Encrypt
|
||||
expect(screen.queryByText(/orphaned certificate/i)).toBeNull()
|
||||
|
||||
// Click Delete to confirm
|
||||
const confirmDeleteBtn = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDeleteBtn[confirmDeleteBtn.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
})
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prompts for staging certificates', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'letsencrypt-staging',
|
||||
name: 'Staging Cert',
|
||||
domains: 'staging.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Click row delete button
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// First dialog appears - "Delete Proxy Host?" confirmation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete Proxy Host?')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Click "Delete" in the confirmation dialog to proceed
|
||||
const confirmDelete = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDelete[confirmDelete.length - 1])
|
||||
|
||||
// Certificate cleanup dialog should appear for staging certs
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/orphaned certificate/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
// Decline certificate deletion (click Delete without checking the box)
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles certificate deletion failure gracefully', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'CustomCert',
|
||||
domains: 'custom.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.deleteCertificate).mockRejectedValue(
|
||||
new Error('Certificate is still in use')
|
||||
)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Click row delete button
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// First dialog appears
|
||||
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
|
||||
|
||||
// Click "Delete" in the confirmation dialog
|
||||
const confirmDelete = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDelete[confirmDelete.length - 1])
|
||||
|
||||
// Certificate cleanup dialog should appear
|
||||
await waitFor(() => expect(screen.getByText(/orphaned certificate/i)).toBeTruthy())
|
||||
|
||||
// Check the certificate deletion checkbox
|
||||
const checkbox = document.getElementById('delete_certs') as HTMLInputElement
|
||||
await userEvent.click(checkbox)
|
||||
|
||||
// Confirm deletion
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
// Toast should show error about certificate but host was deleted
|
||||
const toast = await import('react-hot-toast')
|
||||
await waitFor(() => {
|
||||
expect(toast.toast.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to delete certificate')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('bulk delete prompts for orphaned certificates', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'BulkCert',
|
||||
domains: 'bulk.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.deleteCertificate).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Select all hosts
|
||||
const selectAllCheckbox = screen.getByLabelText('Select all rows')
|
||||
await userEvent.click(selectAllCheckbox)
|
||||
|
||||
// Click bulk delete button (the delete button in the toolbar, after Manage ACL)
|
||||
await waitFor(() => expect(screen.getByText('Manage ACL')).toBeTruthy())
|
||||
const manageACLButton = screen.getByText('Manage ACL')
|
||||
const bulkDeleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement
|
||||
await userEvent.click(bulkDeleteButton)
|
||||
|
||||
// Confirm in bulk delete modal - text uses pluralized form "Proxy Host(s)"
|
||||
await waitFor(() => expect(screen.getByText(/Delete 2 Proxy Host/)).toBeTruthy())
|
||||
const deletePermBtn = screen.getByRole('button', { name: /Delete Permanently/i })
|
||||
await userEvent.click(deletePermBtn)
|
||||
|
||||
// Should show certificate cleanup dialog (both hosts use same cert, deleting both = orphaned)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/orphaned certificate/i)).toBeTruthy()
|
||||
expect(screen.getByText('BulkCert')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Check the certificate deletion checkbox
|
||||
const certCheckbox = document.getElementById('delete_certs') as HTMLInputElement
|
||||
await userEvent.click(certCheckbox)
|
||||
|
||||
// Confirm
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2')
|
||||
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('bulk delete does NOT prompt when certificate is still used by other hosts', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'SharedCert',
|
||||
domains: 'shared.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
|
||||
const host3 = baseHost({ uuid: 'h3', name: 'Host3', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2, host3])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Select only host1 and host2 (host3 still uses the cert)
|
||||
const host1Row = screen.getByText('Host1').closest('tr') as HTMLTableRowElement
|
||||
const host2Row = screen.getByText('Host2').closest('tr') as HTMLTableRowElement
|
||||
// Get the Radix Checkbox in each row (first checkbox, not the Switch which is input[type=checkbox].sr-only)
|
||||
const host1Checkbox = within(host1Row).getByLabelText(/Select row h1/)
|
||||
const host2Checkbox = within(host2Row).getByLabelText(/Select row h2/)
|
||||
|
||||
await userEvent.click(host1Checkbox)
|
||||
await userEvent.click(host2Checkbox)
|
||||
|
||||
// Wait for bulk operations to be available
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy())
|
||||
|
||||
// Click bulk delete - find the delete button in the toolbar (after Manage ACL)
|
||||
const manageACLButton = screen.getByText('Manage ACL')
|
||||
const bulkDeleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement
|
||||
await userEvent.click(bulkDeleteButton)
|
||||
|
||||
// Confirm in modal - text uses pluralized form "Proxy Host(s)"
|
||||
await waitFor(() => expect(screen.getByText(/Delete 2 Proxy Host/)).toBeTruthy())
|
||||
const deletePermBtn = screen.getByRole('button', { name: /Delete Permanently/i })
|
||||
await userEvent.click(deletePermBtn)
|
||||
|
||||
// Should NOT show certificate cleanup dialog (host3 still uses it)
|
||||
// It will directly delete without showing the orphaned cert dialog
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2')
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('allows cancelling certificate cleanup dialog', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'CustomCert',
|
||||
domains: 'test.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Certificate cleanup dialog appears
|
||||
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
|
||||
|
||||
// Click Cancel
|
||||
const cancelBtn = screen.getByRole('button', { name: 'Cancel' })
|
||||
await userEvent.click(cancelBtn)
|
||||
|
||||
// Dialog should close, nothing deleted
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Proxy Host?')).toBeFalsy()
|
||||
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled()
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('default state is unchecked for certificate deletion (conservative)', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'CustomCert',
|
||||
domains: 'test.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Click row delete button
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// First dialog appears
|
||||
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
|
||||
|
||||
// Click "Delete" in the confirmation dialog
|
||||
const confirmDelete = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDelete[confirmDelete.length - 1])
|
||||
|
||||
// Certificate cleanup dialog should appear
|
||||
await waitFor(() => expect(screen.getByText(/orphaned certificate/i)).toBeTruthy())
|
||||
|
||||
// Checkbox should be unchecked by default
|
||||
const checkbox = document.getElementById('delete_certs') as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
|
||||
// Confirm deletion without checking the box
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
// We'll use per-test module mocks via `vi.doMock` and dynamic imports to avoid
|
||||
// leaking mocks into other tests. Each test creates its own QueryClient.
|
||||
|
||||
describe('ProxyHosts page - coverage targets (isolated)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const renderPage = async () => {
|
||||
// Dynamic mocks
|
||||
const mockUpdateHost = vi.fn()
|
||||
|
||||
vi.doMock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: vi.fn(() => ({
|
||||
hosts: [
|
||||
{
|
||||
uuid: 'host-1',
|
||||
name: 'StagingHost',
|
||||
domain_names: 'staging.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '10.0.0.1',
|
||||
forward_port: 80,
|
||||
ssl_forced: true,
|
||||
websocket_support: true,
|
||||
certificate: undefined,
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
uuid: 'host-2',
|
||||
name: 'CustomCertHost',
|
||||
domain_names: 'custom.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '10.0.0.2',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
certificate: { provider: 'custom', name: 'ACME-CUSTOM' },
|
||||
enabled: false,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
],
|
||||
loading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
createHost: vi.fn(),
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => mockUpdateHost(uuid, data),
|
||||
deleteHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
isBulkUpdating: false,
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.doMock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [
|
||||
{ id: 1, name: 'StagingCert', domain: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' }
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({ 'ui.domain_link_behavior': 'new_window' })) }))
|
||||
|
||||
// Import page after mocks are in place
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = (ui: React.ReactNode) => (
|
||||
<QueryClientProvider client={qc}>{ui}</QueryClientProvider>
|
||||
)
|
||||
|
||||
return { ProxyHosts, mockUpdateHost, wrapper }
|
||||
}
|
||||
|
||||
it('renders SSL staging badge, websocket badge', async () => {
|
||||
const { ProxyHosts } = await renderPage()
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('StagingHost')).toBeInTheDocument())
|
||||
|
||||
// Staging badge shows "Staging" text
|
||||
expect(screen.getByText('Staging')).toBeInTheDocument()
|
||||
// Websocket badge shows "WS"
|
||||
expect(screen.getByText('WS')).toBeInTheDocument()
|
||||
// Custom cert hosts don't show the cert name in the table - just check the host is shown
|
||||
expect(screen.getByText('CustomCertHost')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens domain link in new window when linkBehavior is new_window', async () => {
|
||||
const { ProxyHosts } = await renderPage()
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('staging.example.com')).toBeInTheDocument())
|
||||
const link = screen.getByText('staging.example.com').closest('a') as HTMLAnchorElement
|
||||
await act(async () => {
|
||||
await userEvent.click(link!)
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('bulk apply merges host data and calls updateHost', async () => {
|
||||
const { ProxyHosts, mockUpdateHost } = await renderPage()
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('StagingHost')).toBeInTheDocument())
|
||||
|
||||
// Select hosts by finding rows and clicking first checkbox (selection)
|
||||
const row1 = screen.getByText('StagingHost').closest('tr') as HTMLTableRowElement
|
||||
const row2 = screen.getByText('CustomCertHost').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row1).getAllByRole('checkbox')[0])
|
||||
await userEvent.click(within(row2).getAllByRole('checkbox')[0])
|
||||
|
||||
const bulkBtn = screen.getByText('Bulk Apply')
|
||||
await userEvent.click(bulkBtn)
|
||||
|
||||
// Find the modal dialog
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeInTheDocument())
|
||||
|
||||
// The bulk apply modal has checkboxes for each setting - find them by role
|
||||
const modalCheckboxes = screen.getAllByRole('checkbox').filter(
|
||||
cb => cb.closest('[role="dialog"]') !== null
|
||||
)
|
||||
expect(modalCheckboxes.length).toBeGreaterThan(0)
|
||||
// Click the first setting checkbox to enable it
|
||||
await userEvent.click(modalCheckboxes[0])
|
||||
|
||||
const applyBtn = screen.getByRole('button', { name: /Apply/ })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateHost).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const calls = vi.mocked(mockUpdateHost).mock.calls
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1)
|
||||
const [calledUuid, calledData] = calls[0]
|
||||
expect(typeof calledUuid).toBe('string')
|
||||
expect(Object.prototype.hasOwnProperty.call(calledData, 'ssl_forced')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,996 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import { formatSettingLabel, settingHelpText, settingKeyToField, applyBulkSettingsToHosts } from '../../utils/proxyHostsHelpers'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as uptimeApi from '../../api/uptime'
|
||||
// Certificate type not required in this spec
|
||||
import type { UptimeMonitor } from '../../api/uptime'
|
||||
// toast is mocked in other tests; not used here
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
|
||||
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
|
||||
|
||||
const baseHost = (overrides: Partial<ProxyHost> = {}) => createMockProxyHost(overrides)
|
||||
|
||||
describe('ProxyHosts - Coverage enhancements', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('shows empty message when no hosts', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Create your first proxy host/)).toBeTruthy())
|
||||
})
|
||||
|
||||
it('creates a proxy host via Add Host form submit', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.createProxyHost).mockResolvedValue({
|
||||
uuid: 'new1',
|
||||
name: 'NewHost',
|
||||
domain_names: 'new.example.com',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 8080,
|
||||
forward_scheme: 'http',
|
||||
enabled: true,
|
||||
ssl_forced: false,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
websocket_support: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
certificate: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as ProxyHost)
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText(/Create your first proxy host/)).toBeTruthy())
|
||||
const user = userEvent.setup()
|
||||
// Click the first Add Proxy Host button (in empty state)
|
||||
await user.click(screen.getAllByRole('button', { name: 'Add Proxy Host' })[0])
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Proxy Host' })).toBeTruthy())
|
||||
// Fill name
|
||||
const nameInput = screen.getByLabelText('Name *') as HTMLInputElement
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'NewHost')
|
||||
const domainInput = screen.getByLabelText('Domain Names (comma-separated)') as HTMLInputElement
|
||||
await user.clear(domainInput)
|
||||
await user.type(domainInput, 'new.example.com')
|
||||
// Fill forward host/port to satisfy required fields and save
|
||||
const forwardHost = screen.getByLabelText('Host') as HTMLInputElement
|
||||
await user.clear(forwardHost)
|
||||
await user.type(forwardHost, '127.0.0.1')
|
||||
const forwardPort = screen.getByLabelText('Port') as HTMLInputElement
|
||||
await user.clear(forwardPort)
|
||||
await user.type(forwardPort, '8080')
|
||||
// Save
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(proxyHostsApi.createProxyHost).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('handles equal sort values gracefully', async () => {
|
||||
const host1 = baseHost({ uuid: 'e1', name: 'Same', domain_names: 'a.example.com' })
|
||||
const host2 = baseHost({ uuid: 'e2', name: 'Same', domain_names: 'b.example.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getAllByText('Same').length).toBeGreaterThanOrEqual(2))
|
||||
// Sort by name (they are equal) should not throw and maintain rows
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByText('Name'))
|
||||
await waitFor(() => expect(screen.getAllByText('Same').length).toBeGreaterThanOrEqual(2))
|
||||
})
|
||||
|
||||
it('toggle select-all deselects when clicked twice', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
// Click select all header checkbox (has aria-label="Select all rows")
|
||||
const user = userEvent.setup()
|
||||
const selectAllBtn = screen.getByLabelText('Select all rows')
|
||||
await user.click(selectAllBtn)
|
||||
// Wait for selection UI to appear - text format includes "<strong>2</strong> host(s) selected (all)"
|
||||
await waitFor(() => expect(screen.getByText(/host\(s\) selected/)).toBeTruthy())
|
||||
// Also check for "(all)" indicator
|
||||
expect(screen.getByText(/\(all\)/)).toBeTruthy()
|
||||
// Click again to deselect
|
||||
await user.click(selectAllBtn)
|
||||
await waitFor(() => expect(screen.queryByText(/\(all\)/)).toBeNull())
|
||||
})
|
||||
|
||||
it('bulk update ACL reject triggers error toast', async () => {
|
||||
const host = baseHost({ uuid: 'b1', name: 'BHost' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(new Error('Bad things'))
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('BHost')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(chk)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
const label = screen.getByText('List1').closest('label') as HTMLElement
|
||||
// Radix Checkbox - query by role, not native input
|
||||
const checkbox = within(label).getByRole('checkbox')
|
||||
await user.click(checkbox)
|
||||
const applyBtn = await screen.findByRole('button', { name: /Apply\s*\(/i })
|
||||
await act(async () => {
|
||||
await user.click(applyBtn)
|
||||
})
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('switch toggles from disabled to enabled and calls API', async () => {
|
||||
const host = baseHost({ uuid: 'sw1', name: 'SwitchHost', enabled: false })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue({ ...host, enabled: true })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('SwitchHost')).toBeTruthy())
|
||||
const row = screen.getByText('SwitchHost').closest('tr') as HTMLTableRowElement
|
||||
// Switch component uses a label wrapping a hidden checkbox - find the label and click it
|
||||
const switchLabel = row.querySelector('label.cursor-pointer') as HTMLElement
|
||||
const user = userEvent.setup()
|
||||
await user.click(switchLabel)
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalledWith('sw1', { enabled: true }))
|
||||
})
|
||||
|
||||
it('sorts hosts by column and toggles order indicator', async () => {
|
||||
const h1 = baseHost({ uuid: '1', name: 'aaa', domain_names: 'b.com' })
|
||||
const h2 = baseHost({ uuid: '2', name: 'zzz', domain_names: 'a.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([h1, h2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('aaa')).toBeTruthy())
|
||||
|
||||
// Check both hosts are rendered
|
||||
expect(screen.getByText('aaa')).toBeTruthy()
|
||||
expect(screen.getByText('zzz')).toBeTruthy()
|
||||
|
||||
// Click domain header - should show sorting indicator
|
||||
const domainHeader = screen.getByText('Domain')
|
||||
const user = userEvent.setup()
|
||||
await user.click(domainHeader)
|
||||
|
||||
// After clicking domain header, the header should have aria-sort attribute
|
||||
await waitFor(() => {
|
||||
const th = domainHeader.closest('th')
|
||||
expect(th?.getAttribute('aria-sort')).toBe('ascending')
|
||||
})
|
||||
|
||||
// Click again to toggle to descending
|
||||
await user.click(domainHeader)
|
||||
await waitFor(() => {
|
||||
const th = domainHeader.closest('th')
|
||||
expect(th?.getAttribute('aria-sort')).toBe('descending')
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles row selection checkbox and shows checked state', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
|
||||
const row = screen.getByText('S1').closest('tr') as HTMLTableRowElement
|
||||
const selectBtn = within(row).getAllByRole('checkbox')[0]
|
||||
// Initially unchecked
|
||||
expect(selectBtn.getAttribute('aria-checked')).toBe('false')
|
||||
const user = userEvent.setup()
|
||||
await user.click(selectBtn)
|
||||
await waitFor(() => expect(selectBtn.getAttribute('aria-checked')).toBe('true'))
|
||||
await user.click(selectBtn)
|
||||
await waitFor(() => expect(selectBtn.getAttribute('aria-checked')).toBe('false'))
|
||||
})
|
||||
|
||||
it('closes bulk ACL modal when clicking backdrop', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist', enabled: true, ip_rules: '[]', country_codes: '', local_network_only: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('Apply Access List')).toBeTruthy())
|
||||
|
||||
// click backdrop (outer overlay) to close
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
if (overlay) await user.click(overlay)
|
||||
await waitFor(() => expect(screen.queryByText('Apply Access List')).toBeNull())
|
||||
})
|
||||
|
||||
it('unchecks ACL via onChange (delete path)', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
const label = screen.getByText('List1').closest('label') as HTMLLabelElement
|
||||
// Radix Checkbox - query by role, not native input
|
||||
const checkbox = within(label).getByRole('checkbox')
|
||||
// initially unchecked via clear, click to check
|
||||
await user.click(checkbox)
|
||||
await waitFor(() => expect(checkbox.getAttribute('aria-checked')).toBe('true'))
|
||||
// click again to uncheck and hit delete path in onChange
|
||||
await user.click(checkbox)
|
||||
await waitFor(() => expect(checkbox.getAttribute('aria-checked')).toBe('false'))
|
||||
})
|
||||
|
||||
it('remove action triggers handleBulkApplyACL and shows removed toast', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 2, errors: [] })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(chk)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
// Toggle to Remove ACL
|
||||
await user.click(screen.getByText('Remove ACL'))
|
||||
// Click the action button (Remove ACL) - it's the primary action (bg-red)
|
||||
const actionBtn = screen.getAllByRole('button', { name: 'Remove ACL' }).pop()
|
||||
if (actionBtn) await user.click(actionBtn)
|
||||
await waitFor(() => expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith(['s1', 's2'], null))
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('toggle action remove -> apply then back', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(chk)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('Apply ACL')).toBeTruthy())
|
||||
// Click Remove, then Apply to hit setBulkACLAction('apply')
|
||||
// Toggle Remove (header toggle) and back to Apply (header toggle)
|
||||
const headerToggles = screen.getAllByRole('button')
|
||||
const removeToggle = headerToggles.find(btn => btn.textContent === 'Remove ACL' && btn.className.includes('flex-1'))
|
||||
const applyToggle = headerToggles.find(btn => btn.textContent === 'Apply ACL' && btn.className.includes('flex-1'))
|
||||
if (removeToggle) await user.click(removeToggle)
|
||||
await waitFor(() => expect(removeToggle).toBeTruthy())
|
||||
if (applyToggle) await user.click(applyToggle)
|
||||
await waitFor(() => expect(applyToggle).toBeTruthy())
|
||||
})
|
||||
|
||||
it('remove action shows partial failure toast on API error result', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 1, errors: [{ uuid: 's2', error: 'Bad' }] })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(chk)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
await userEvent.click(screen.getByText('Remove ACL'))
|
||||
const actionBtn = screen.getAllByRole('button', { name: 'Remove ACL' }).pop()
|
||||
if (actionBtn) await userEvent.click(actionBtn)
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('remove action reject triggers error toast', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(new Error('Bulk fail'))
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
await userEvent.click(chk)
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
// Toggle Remove mode
|
||||
await userEvent.click(screen.getByText('Remove ACL'))
|
||||
const actionBtn = screen.getAllByRole('button', { name: 'Remove ACL' }).pop()
|
||||
if (actionBtn) await userEvent.click(actionBtn)
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('close bulk delete modal by clicking backdrop', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getByLabelText('Select all rows')
|
||||
await userEvent.click(headerCheckbox)
|
||||
// Wait for selection bar to appear and find the delete button - text format is "host(s) selected"
|
||||
await waitFor(() => expect(screen.getByText(/host\(s\) selected/)).toBeTruthy())
|
||||
// Click the bulk Delete button (with bg-error class) - there are multiple Delete buttons, get the one in selection bar
|
||||
const deleteButtons = screen.getAllByRole('button', { name: /Delete/ })
|
||||
// The bulk delete button has bg-error class
|
||||
const bulkDeleteBtn = deleteButtons.find(btn => btn.classList.contains('bg-error'))
|
||||
await userEvent.click(bulkDeleteBtn!)
|
||||
await waitFor(() => expect(screen.getByText(/Delete 2 Proxy Hosts?/i)).toBeTruthy())
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
if (overlay) await userEvent.click(overlay)
|
||||
await waitFor(() => expect(screen.queryByText(/Delete 2 Proxy Hosts?/i)).toBeNull())
|
||||
})
|
||||
|
||||
it('calls window.open when settings link behavior new_window', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([baseHost({ uuid: '1', name: 'One' })])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({ 'ui.domain_link_behavior': 'new_window' })
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
||||
const anchor = screen.getByRole('link', { name: /(test1\.example\.com|example\.com|One)/i })
|
||||
await userEvent.click(anchor)
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses same_tab target for domain links when configured', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([baseHost({ uuid: '1', name: 'One' })])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({ 'ui.domain_link_behavior': 'same_tab' })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
||||
const anchor = screen.getByRole('link', { name: /(example\.com|One)/i })
|
||||
// Anchor should render with target _self when same_tab
|
||||
expect(anchor.getAttribute('target')).toBe('_self')
|
||||
})
|
||||
|
||||
it('renders SSL states: custom, staging, letsencrypt variations', async () => {
|
||||
const hostCustom = baseHost({ uuid: 'c1', name: 'CustomHost', domain_names: 'custom.com', ssl_forced: true, certificate: { id: 123, uuid: 'cert-1', name: 'CustomCert', provider: 'custom', domains: 'custom.com', expires_at: '2026-01-01' } })
|
||||
const hostStaging = baseHost({ uuid: 's1', name: 'StagingHost', domain_names: 'staging.com', ssl_forced: true })
|
||||
const hostAuto = baseHost({ uuid: 'a1', name: 'AutoHost', domain_names: 'auto.com', ssl_forced: true })
|
||||
const hostLets = baseHost({ uuid: 'l1', name: 'LetsHost', domain_names: 'lets.com', ssl_forced: true })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostCustom, hostStaging, hostAuto, hostLets])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([
|
||||
{ domain: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
|
||||
{ domain: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
|
||||
])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('CustomHost')).toBeTruthy())
|
||||
|
||||
// Custom Cert - just verify the host renders
|
||||
expect(screen.getByText('CustomHost')).toBeTruthy()
|
||||
|
||||
// Staging host should show staging badge text (just "Staging" in Badge)
|
||||
expect(screen.getByText('StagingHost')).toBeTruthy()
|
||||
// The SSL badge for staging hosts shows "Staging" text
|
||||
const stagingBadges = screen.getAllByText('Staging')
|
||||
expect(stagingBadges.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// SSL badges are shown for valid certs
|
||||
const sslBadges = screen.getAllByText('SSL')
|
||||
expect(sslBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders multiple domains and websocket label', async () => {
|
||||
const host = baseHost({ uuid: 'multi1', name: 'Multi', domain_names: 'one.com,two.com,three.com', websocket_support: true })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Multi')).toBeTruthy())
|
||||
// Check multiple domain anchors; parse anchor hrefs instead of substring checks
|
||||
const anchors = screen.getAllByRole('link')
|
||||
const anchorHasHost = (el: Element | null, host: string) => {
|
||||
if (!el) return false
|
||||
const href = el.getAttribute('href') || ''
|
||||
try {
|
||||
// Use base to resolve relative URLs
|
||||
const parsed = new URL(href, 'http://localhost')
|
||||
return parsed.host === host
|
||||
} catch {
|
||||
return el.textContent?.includes(host) ?? false
|
||||
}
|
||||
}
|
||||
expect(anchors.some(a => anchorHasHost(a, 'one.com'))).toBeTruthy()
|
||||
expect(anchors.some(a => anchorHasHost(a, 'two.com'))).toBeTruthy()
|
||||
expect(anchors.some(a => anchorHasHost(a, 'three.com'))).toBeTruthy()
|
||||
// Check websocket label exists since websocket_support true
|
||||
expect(screen.getByText('WS')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('handles delete confirmation for a single host', async () => {
|
||||
const host = baseHost({ uuid: 'del1', name: 'Del', domain_names: 'del.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Del')).toBeTruthy())
|
||||
// Click Delete button in the row
|
||||
const editButton = screen.getByText('Edit')
|
||||
const row = editButton.closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Confirm in dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeTruthy())
|
||||
const confirmBtn = screen.getAllByRole('button', { name: 'Delete' }).pop()!
|
||||
await userEvent.click(confirmBtn)
|
||||
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del1'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('deletes associated uptime monitors when confirmed', async () => {
|
||||
const host = baseHost({ uuid: 'del2', name: 'Del2', forward_host: '127.0.0.5' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// uptime monitors associated with host
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([{ id: 'm1', name: 'm1', url: 'http://example', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, upstream_host: '127.0.0.5' } as UptimeMonitor])
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Del2')).toBeTruthy())
|
||||
const row = screen.getByText('Del2').closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Confirm in dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeTruthy())
|
||||
const confirmBtn = screen.getAllByRole('button', { name: 'Delete' }).pop()!
|
||||
await userEvent.click(confirmBtn)
|
||||
// Should call delete with deleteUptime true
|
||||
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del2', true))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores uptime API errors and deletes host without deleting uptime', async () => {
|
||||
const host = baseHost({ uuid: 'del3', name: 'Del3', forward_host: '127.0.0.6' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// Make getMonitors throw
|
||||
vi.mocked(uptimeApi.getMonitors).mockRejectedValue(new Error('OOPS'))
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Del3')).toBeTruthy())
|
||||
const row = screen.getByText('Del3').closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Confirm in dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeTruthy())
|
||||
const confirmBtn = screen.getAllByRole('button', { name: 'Delete' }).pop()!
|
||||
await userEvent.click(confirmBtn)
|
||||
// Should call delete without second param
|
||||
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del3'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('applies bulk settings sequentially with progress and updates hosts', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 'host-1', name: 'H1' }),
|
||||
baseHost({ uuid: 'host-2', name: 'H2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue({} as ProxyHost)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
|
||||
// Select both hosts
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
await userEvent.click(headerCheckbox)
|
||||
|
||||
// Open Bulk Apply modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
|
||||
// In the modal, find Force SSL row and enable apply and set value true
|
||||
const forceLabel = screen.getByText('Force SSL')
|
||||
// The row has class p-3 not p-2, and we need to get the parent flex container
|
||||
const rowEl = forceLabel.closest('.p-3') as HTMLElement || forceLabel.closest('div')?.parentElement as HTMLElement
|
||||
// Find the Radix checkbox (has role="checkbox" and is a button) and the switch (label with input)
|
||||
const allCheckboxes = within(rowEl).getAllByRole('checkbox')
|
||||
// First checkbox is the Radix Checkbox for "apply"
|
||||
const applyCheckbox = allCheckboxes[0]
|
||||
await userEvent.click(applyCheckbox)
|
||||
|
||||
// Click Apply in the modal - find button within the dialog
|
||||
const applyBtn = screen.getByRole('button', { name: /^Apply$/i })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
// Expect updateProxyHost called for each host with ssl_forced true included in payload
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalledTimes(2))
|
||||
const calls = vi.mocked(proxyHostsApi.updateProxyHost).mock.calls
|
||||
expect(calls.some(call => call[1] && (call[1] as Partial<ProxyHost>).ssl_forced === true)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows Unnamed when name missing', async () => {
|
||||
const hostNoName = baseHost({ uuid: 'n1', name: '', domain_names: 'no-name.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostNoName])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Unnamed')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('toggles host enable state via Switch', async () => {
|
||||
const host = baseHost({ uuid: 't1', name: 'Toggle', enabled: true })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue(baseHost({ uuid: 't1', name: 'Toggle', enabled: true }))
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Toggle')).toBeTruthy())
|
||||
// Locate the row and toggle the enabled switch - it's inside a label with cursor-pointer class
|
||||
const row = screen.getByText('Toggle').closest('tr') as HTMLTableRowElement
|
||||
// Switch component uses a label wrapping a hidden checkbox
|
||||
const switchLabel = row.querySelector('label.cursor-pointer') as HTMLElement
|
||||
expect(switchLabel).toBeTruthy()
|
||||
await userEvent.click(switchLabel)
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('opens add form and cancels', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Create your first proxy host/)).toBeTruthy())
|
||||
// Click the first Add Proxy Host button (in empty state)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Add Proxy Host' })[0])
|
||||
// Form should open with Add Proxy Host header
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Proxy Host' })).toBeTruthy())
|
||||
// Click Cancel should close the form
|
||||
const cancelButton = screen.getByText('Cancel')
|
||||
await userEvent.click(cancelButton)
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Add Proxy Host' })).toBeNull())
|
||||
})
|
||||
|
||||
it('opens edit form and submits update', async () => {
|
||||
const host = baseHost({ uuid: 'edit1', name: 'EditMe' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue({ ...host, name: 'Edited' })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('EditMe')).toBeTruthy())
|
||||
const editBtn = screen.getByText('Edit')
|
||||
await userEvent.click(editBtn)
|
||||
|
||||
// Form header should show Edit Proxy Host
|
||||
await waitFor(() => expect(screen.getByText('Edit Proxy Host')).toBeTruthy())
|
||||
// Change name and click Save
|
||||
const nameInput = screen.getByLabelText('Name *') as HTMLInputElement
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'Edited')
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('alerts on delete when API fails', async () => {
|
||||
const host = baseHost({ uuid: 'delerr', name: 'DelErr' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockRejectedValue(new Error('Boom'))
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('DelErr')).toBeTruthy())
|
||||
const row = screen.getByText('DelErr').closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Confirm in dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeTruthy())
|
||||
const confirmBtn = screen.getAllByRole('button', { name: 'Delete' }).pop()!
|
||||
await userEvent.click(confirmBtn)
|
||||
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sorts by domain and forward columns', async () => {
|
||||
const h1 = baseHost({ uuid: 'd1', name: 'A', domain_names: 'b.com', forward_host: 'foo' , forward_port: 8080 })
|
||||
const h2 = baseHost({ uuid: 'd2', name: 'B', domain_names: 'a.com', forward_host: 'bar' , forward_port: 80 })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([h1, h2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('A')).toBeTruthy())
|
||||
|
||||
// Domain sort
|
||||
await userEvent.click(screen.getByText('Domain'))
|
||||
await waitFor(() => expect(screen.getByText('B')).toBeTruthy()) // domain 'a.com' should appear first
|
||||
|
||||
// Forward sort: toggle to change order
|
||||
await userEvent.click(screen.getByText('Forward To'))
|
||||
await waitFor(() => expect(screen.getByText('A')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('applies multiple ACLs sequentially with progress', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 'host-1', name: 'H1' }),
|
||||
baseHost({ uuid: 'host-2', name: 'H2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-a1', name: 'A1', description: 'A1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-a2', name: 'A2', description: 'A2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 2, errors: [] })
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await userEvent.click(checkboxes[0])
|
||||
|
||||
// Open Manage ACL
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('A1')).toBeTruthy())
|
||||
|
||||
// Select both ACLs
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox')
|
||||
const checkA1 = aclCheckboxes.find(cb => cb.closest('label')?.textContent?.includes('A1'))
|
||||
const checkA2 = aclCheckboxes.find(cb => cb.closest('label')?.textContent?.includes('A2'))
|
||||
if (checkA1) await userEvent.click(checkA1)
|
||||
if (checkA2) await userEvent.click(checkA2)
|
||||
|
||||
// Click Apply
|
||||
const applyBtn = screen.getByRole('button', { name: /Apply \(2\)/i })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
// Should call bulkUpdateACL twice and show success
|
||||
await waitFor(() => expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('select all / clear header selects and clears ACLs', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-2', name: 'List2', description: 'List 2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await userEvent.click(checkboxes[0])
|
||||
await waitFor(() => expect(screen.getByText('Manage ACL')).toBeTruthy())
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
|
||||
// Click Select All in modal
|
||||
const selectAllBtn = await screen.findByText('Select All')
|
||||
await userEvent.click(selectAllBtn)
|
||||
// All ACL checkboxes (Radix Checkbox) inside labels should be checked - check via aria-checked
|
||||
const labelEl1 = screen.getByText('List1').closest('label') as HTMLElement
|
||||
const labelEl2 = screen.getByText('List2').closest('label') as HTMLElement
|
||||
const checkbox1 = within(labelEl1).getByRole('checkbox')
|
||||
const checkbox2 = within(labelEl2).getByRole('checkbox')
|
||||
expect(checkbox1.getAttribute('aria-checked')).toBe('true')
|
||||
expect(checkbox2.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
// Click Clear
|
||||
const clearBtn = await screen.findByText('Clear')
|
||||
await userEvent.click(clearBtn)
|
||||
expect(checkbox1.getAttribute('aria-checked')).toBe('false')
|
||||
expect(checkbox2.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows no enabled access lists message when none are enabled', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' })
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-disable1', name: 'Disabled1', description: 'Disabled 1', type: 'blacklist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-disable2', name: 'Disabled2', description: 'Disabled 2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await userEvent.click(checkboxes[0])
|
||||
await waitFor(() => expect(screen.getByText('Manage ACL')).toBeTruthy())
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
|
||||
// Should show the 'No enabled access lists available' message
|
||||
await waitFor(() => expect(screen.getByText('No enabled access lists available')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('formatSettingLabel, settingHelpText and settingKeyToField return expected values and defaults', () => {
|
||||
expect(formatSettingLabel('ssl_forced')).toBe('Force SSL')
|
||||
expect(formatSettingLabel('http2_support')).toBe('HTTP/2 Support')
|
||||
expect(formatSettingLabel('hsts_enabled')).toBe('HSTS Enabled')
|
||||
expect(formatSettingLabel('hsts_subdomains')).toBe('HSTS Subdomains')
|
||||
expect(formatSettingLabel('block_exploits')).toBe('Block Exploits')
|
||||
expect(formatSettingLabel('websocket_support')).toBe('Websockets Support')
|
||||
expect(formatSettingLabel('unknown_key')).toBe('unknown_key')
|
||||
|
||||
expect(settingHelpText('ssl_forced')).toContain('Redirect all HTTP traffic')
|
||||
expect(settingHelpText('http2_support')).toContain('Enable HTTP/2')
|
||||
expect(settingHelpText('hsts_enabled')).toContain('Send HSTS header')
|
||||
expect(settingHelpText('hsts_subdomains')).toContain('Include subdomains')
|
||||
expect(settingHelpText('block_exploits')).toContain('Add common exploit-mitigation')
|
||||
expect(settingHelpText('websocket_support')).toContain('Enable websocket proxying')
|
||||
expect(settingHelpText('unknown_key')).toBe('')
|
||||
|
||||
expect(settingKeyToField('ssl_forced')).toBe('ssl_forced')
|
||||
expect(settingKeyToField('http2_support')).toBe('http2_support')
|
||||
expect(settingKeyToField('hsts_enabled')).toBe('hsts_enabled')
|
||||
expect(settingKeyToField('hsts_subdomains')).toBe('hsts_subdomains')
|
||||
expect(settingKeyToField('block_exploits')).toBe('block_exploits')
|
||||
expect(settingKeyToField('websocket_support')).toBe('websocket_support')
|
||||
expect(settingKeyToField('unknown_key')).toBe('unknown_key')
|
||||
})
|
||||
|
||||
it('closes bulk apply modal when clicking backdrop', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
await user.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
// click backdrop
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
if (overlay) await user.click(overlay)
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull())
|
||||
})
|
||||
|
||||
it('shows toast error when updateHost rejects during bulk apply', async () => {
|
||||
const h1 = baseHost({ uuid: 'host-1', name: 'H1' })
|
||||
const h2 = baseHost({ uuid: 'host-2', name: 'H2' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([h1, h2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// mock updateProxyHost to fail for host-2
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockImplementation(async (uuid: string) => {
|
||||
if (uuid === 'host-2') throw new Error('update fail')
|
||||
const result = baseHost({ uuid })
|
||||
return result
|
||||
})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
// select both
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
// Open Bulk Apply
|
||||
await user.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
// enable Force SSL apply + set switch
|
||||
const forceLabel = screen.getByText('Force SSL')
|
||||
// The row has class p-3 not p-2, and we need to get the parent flex container
|
||||
const rowEl = forceLabel.closest('.p-3') as HTMLElement || forceLabel.closest('div')?.parentElement as HTMLElement
|
||||
// Find the Radix checkbox (has role="checkbox" and is a button) and the switch (label with input)
|
||||
const allCheckboxes = within(rowEl).getAllByRole('checkbox')
|
||||
// First checkbox is the Radix Checkbox for "apply", second is the switch's internal checkbox
|
||||
const applyCheckbox = allCheckboxes[0]
|
||||
await user.click(applyCheckbox)
|
||||
// Toggle the switch - click the label containing the checkbox
|
||||
const switchLabel = rowEl.querySelector('label.relative') as HTMLElement
|
||||
if (switchLabel) await user.click(switchLabel)
|
||||
// click Apply - find button within the dialog
|
||||
const applyBtn = screen.getByRole('button', { name: /^Apply$/i })
|
||||
await user.click(applyBtn)
|
||||
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('applyBulkSettingsToHosts returns error when host is not found and reports progress', async () => {
|
||||
const hosts: ProxyHost[] = [] // no hosts
|
||||
const hostUUIDs = ['missing-1']
|
||||
const keysToApply = ['ssl_forced']
|
||||
const bulkApplySettings: Record<string, { apply: boolean; value: boolean }> = { ssl_forced: { apply: true, value: true } }
|
||||
const updateHost = vi.fn().mockResolvedValue({})
|
||||
const setApplyProgress = vi.fn()
|
||||
|
||||
const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress })
|
||||
expect(result.errors).toBe(1)
|
||||
expect(setApplyProgress).toHaveBeenCalled()
|
||||
expect(updateHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applyBulkSettingsToHosts handles updateHost rejection and counts errors', async () => {
|
||||
const h1 = baseHost({ uuid: 'h1', name: 'H1' })
|
||||
const hosts = [h1]
|
||||
const hostUUIDs = ['h1']
|
||||
const keysToApply = ['ssl_forced']
|
||||
const bulkApplySettings: Record<string, { apply: boolean; value: boolean }> = { ssl_forced: { apply: true, value: true } }
|
||||
const updateHost = vi.fn().mockRejectedValue(new Error('fail'))
|
||||
const setApplyProgress = vi.fn()
|
||||
|
||||
const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress })
|
||||
expect(result.errors).toBe(1)
|
||||
expect(updateHost).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
// Helper to create QueryClient provider wrapper
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
const sampleHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
|
||||
uuid: 'h1',
|
||||
name: 'A Name',
|
||||
domain_names: 'a.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
enabled: true,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
certificate: null,
|
||||
certificate_id: null,
|
||||
access_list_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ProxyHosts page extra tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows "No proxy hosts configured" when no hosts', async () => {
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Create your first proxy host/)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('sort toggles by header click', async () => {
|
||||
const h1 = sampleHost({ uuid: 'a', name: 'Alpha' })
|
||||
const h2 = sampleHost({ uuid: 'b', name: 'Beta' })
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [h2, h1], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
// hosts are sorted by name by default (Alpha before Beta) by the component
|
||||
await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument())
|
||||
|
||||
const nameHeader = screen.getByText('Name')
|
||||
// Click header - this only toggles the sort indicator icon, not actual data order
|
||||
// since the component pre-sorts data before passing to DataTable
|
||||
await userEvent.click(nameHeader)
|
||||
|
||||
// Verify that both hosts are still displayed (basic sanity check)
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
|
||||
// Verify the sort indicator changes (chevron icon should toggle)
|
||||
// The table header should have aria-sort attribute
|
||||
const table = screen.getByRole('table')
|
||||
expect(table).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('delete with associated monitors prompts and deletes with deleteUptime true', async () => {
|
||||
const host = sampleHost({ uuid: 'delete-1', name: 'DelHost', forward_host: 'upstream-1' })
|
||||
const deleteHostMock = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: deleteHostMock, bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
vi.doMock('../../api/uptime', () => ({ getMonitors: vi.fn(() => Promise.resolve([{ id: 1, upstream_host: 'upstream-1', proxy_host_id: null }])) }))
|
||||
|
||||
const confirmMock = vi.spyOn(window, 'confirm')
|
||||
// first confirm 'Are you sure' -> true, second confirm 'Delete monitors as well' -> true
|
||||
confirmMock.mockImplementation(() => true)
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('DelHost')).toBeInTheDocument())
|
||||
const deleteBtn = screen.getByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Confirm deletion in the dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeInTheDocument())
|
||||
const confirmDeleteBtn = screen.getByRole('button', { name: /^Delete$/ })
|
||||
await userEvent.click(confirmDeleteBtn)
|
||||
|
||||
await waitFor(() => expect(deleteHostMock).toHaveBeenCalled())
|
||||
|
||||
// Should have been called with both uuid and deleteUptime true (because monitors exist and second confirm true)
|
||||
expect(deleteHostMock).toHaveBeenCalledWith('delete-1', true)
|
||||
confirmMock.mockRestore()
|
||||
})
|
||||
|
||||
it('renders SSL badges for SSL-enabled hosts', async () => {
|
||||
const hostValid = sampleHost({ uuid: 'v1', name: 'ValidHost', domain_names: 'valid.example.com', ssl_forced: true })
|
||||
const hostAuto = sampleHost({ uuid: 'a1', name: 'AutoHost', domain_names: 'auto.example.com', ssl_forced: true })
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [hostValid, hostAuto], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [{ id: 1, name: 'LE', domain: 'valid.example.com', status: 'valid', provider: 'letsencrypt' }], isLoading: false, error: null })) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ValidHost')).toBeInTheDocument())
|
||||
// Check that SSL badges are rendered (text removed for better spacing)
|
||||
const sslBadges = screen.getAllByText('SSL')
|
||||
expect(sslBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows error banner when hook returns an error', async () => {
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [], loading: false, isFetching: false, error: 'Failed to load', createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to load')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('select all shows (all) selected in summary', async () => {
|
||||
const h1 = sampleHost({ uuid: 'x', name: 'XHost' })
|
||||
const h2 = sampleHost({ uuid: 'y', name: 'YHost' })
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [h1, h2], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('XHost')).toBeInTheDocument())
|
||||
const selectAllBtn = screen.getByRole('checkbox', { name: /Select all/i })
|
||||
// fallback, find by title
|
||||
if (!selectAllBtn) {
|
||||
await userEvent.click(screen.getByTitle('Select all'))
|
||||
} else {
|
||||
await userEvent.click(selectAllBtn)
|
||||
}
|
||||
|
||||
// Text is split across elements: "<strong>2</strong> host(s) selected (all)"
|
||||
// Check for presence of both parts separately
|
||||
await waitFor(() => expect(screen.getByText(/host\(s\) selected/)).toBeInTheDocument())
|
||||
expect(screen.getByText(/\(all\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loader when fetching', async () => {
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [sampleHost()], loading: false, isFetching: true, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
const { container } = renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(container.querySelector('.animate-spin')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('handles domain link behavior new_window', async () => {
|
||||
const host = sampleHost({ uuid: 'link-h1', domain_names: 'link.example.com', ssl_forced: true })
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({ 'ui.domain_link_behavior': 'new_window' })) }))
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('link.example.com')).toBeInTheDocument())
|
||||
const link = screen.getByRole('link', { name: /link.example.com/ })
|
||||
await userEvent.click(link)
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows WS and ACL badges when appropriate', async () => {
|
||||
const host = sampleHost({ uuid: 'x2', name: 'XHost2', websocket_support: true, access_list_id: 5 })
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('XHost2')).toBeInTheDocument())
|
||||
expect(screen.getByText('WS')).toBeInTheDocument()
|
||||
expect(screen.getByText('ACL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk ACL remove shows the confirmation card and Apply label updates when selecting ACLs', async () => {
|
||||
const host = sampleHost({ uuid: 'acl-1', name: 'AclHost' })
|
||||
const acl = { id: 1, name: 'MyACL', enabled: true }
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(() => Promise.resolve({ updated: 1, errors: [] })), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [acl] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('AclHost')).toBeInTheDocument())
|
||||
// Select host using checkbox - find row first, then first checkbox (selection) within
|
||||
const row = screen.getByText('AclHost').closest('tr') as HTMLTableRowElement
|
||||
const selectBtn = within(row).getAllByRole('checkbox')[0]
|
||||
await userEvent.click(selectBtn)
|
||||
|
||||
// Open Manage ACL modal
|
||||
const manageBtn = screen.getByText('Manage ACL')
|
||||
await userEvent.click(manageBtn)
|
||||
|
||||
// Switch to Remove ACL action
|
||||
const removeBtn = screen.getByText('Remove ACL')
|
||||
await userEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/This will remove the access list from all 1 selected host/i)).toBeInTheDocument())
|
||||
|
||||
// Switch back to Apply ACL and select the ACL
|
||||
const applyBtn = screen.getByText('Apply ACL')
|
||||
await userEvent.click(applyBtn)
|
||||
const selectAll = screen.getByText('Select All')
|
||||
await userEvent.click(selectAll)
|
||||
await waitFor(() => expect(screen.getByText('Apply (1)')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('bulk ACL remove action calls bulkUpdateACL with null and shows removed toast', async () => {
|
||||
const host = sampleHost({ uuid: 'acl-2', name: 'AclHost2' })
|
||||
const bulkUpdateACLMock = vi.fn(async () => ({ updated: 1, errors: [] }))
|
||||
const toastSuccess = vi.fn()
|
||||
vi.doMock('react-hot-toast', () => ({ toast: { success: toastSuccess, error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: bulkUpdateACLMock, isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [{ id: 1, name: 'MyACL', enabled: true }] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('AclHost2')).toBeInTheDocument())
|
||||
const row = screen.getByText('AclHost2').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row).getAllByRole('checkbox')[0])
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
await userEvent.click(screen.getByText('Remove ACL'))
|
||||
// Click Remove ACL confirm button (bottom) - choose the confirmation button rather than the header action
|
||||
const removeButtons = screen.getAllByRole('button', { name: 'Remove ACL' })
|
||||
await userEvent.click(removeButtons[removeButtons.length - 1])
|
||||
|
||||
await waitFor(() => expect(bulkUpdateACLMock).toHaveBeenCalledWith(['acl-2'], null))
|
||||
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('removed'))
|
||||
})
|
||||
|
||||
it('shows no enabled access lists available when none exist', async () => {
|
||||
const host = sampleHost({ uuid: 'acl-3', name: 'AclHost3' })
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('AclHost3')).toBeInTheDocument())
|
||||
const row = screen.getByText('AclHost3').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row).getAllByRole('checkbox')[0])
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No enabled access lists available')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('bulk delete modal lists hosts to be deleted', async () => {
|
||||
const host = sampleHost({ uuid: 'd2', name: 'DeleteMe2' })
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
vi.doMock('../../api/backups', () => ({ createBackup: vi.fn(async () => ({ filename: 'backup-2' })) }))
|
||||
|
||||
const toastSuccess = vi.fn()
|
||||
vi.doMock('react-hot-toast', () => ({ toast: { success: toastSuccess, error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
const confirmMock = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('DeleteMe2')).toBeInTheDocument())
|
||||
const row = screen.getByText('DeleteMe2').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row).getAllByRole('checkbox')[0])
|
||||
const deleteButtons = screen.getAllByText('Delete')
|
||||
const toolbarBtn = deleteButtons.map((btn: Element) => btn.closest('button') as HTMLButtonElement | null).find((b) => b && b.className.includes('bg-error')) as HTMLButtonElement | undefined
|
||||
if (!toolbarBtn) throw new Error('Toolbar delete button not found')
|
||||
await userEvent.click(toolbarBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete 1 Proxy Host/i })).toBeInTheDocument())
|
||||
// Ensure the modal lists the host by scoping to the modal content
|
||||
const listHeader = screen.getByText('Hosts to be deleted:')
|
||||
const modalRoot = listHeader.closest('div')
|
||||
expect(modalRoot).toBeTruthy()
|
||||
if (modalRoot) {
|
||||
const { getByText: getByTextWithin } = within(modalRoot)
|
||||
expect(getByTextWithin('DeleteMe2')).toBeInTheDocument()
|
||||
expect(getByTextWithin('(a.example.com)')).toBeInTheDocument()
|
||||
}
|
||||
// Confirm delete
|
||||
await userEvent.click(screen.getByRole('button', { name: /Delete Permanently/i }))
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Backup created')))
|
||||
confirmMock.mockRestore()
|
||||
})
|
||||
|
||||
it('bulk apply modal returns early when no keys selected (no-op)', async () => {
|
||||
const host = sampleHost({ uuid: 'b1', name: 'BlankHost' })
|
||||
const updateHost = vi.fn()
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost, deleteHost: vi.fn(), bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('BlankHost')).toBeInTheDocument())
|
||||
// Select host
|
||||
const row = screen.getByText('BlankHost').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row).getAllByRole('checkbox')[0])
|
||||
// Open Bulk Apply modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'))
|
||||
const applyBtn = screen.getByRole('button', { name: 'Apply' })
|
||||
// Remove disabled to trigger the no-op branch
|
||||
applyBtn.removeAttribute('disabled')
|
||||
await userEvent.click(applyBtn)
|
||||
// No calls to updateHost should be made
|
||||
expect(updateHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bulk delete creates backup and shows toast success', async () => {
|
||||
const host = sampleHost({ uuid: 'd1', name: 'DeleteMe' })
|
||||
const deleteHostMock = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn(() => ({ hosts: [host], loading: false, isFetching: false, error: null, createHost: vi.fn(), updateHost: vi.fn(), deleteHost: deleteHostMock, bulkUpdateACL: vi.fn(), isBulkUpdating: false })) }))
|
||||
vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })) }))
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({})) }))
|
||||
vi.doMock('../../api/backups', () => ({ createBackup: vi.fn(async () => ({ filename: 'backup-1' })) }))
|
||||
|
||||
const toastSuccess = vi.fn()
|
||||
vi.doMock('react-hot-toast', () => ({ toast: { success: toastSuccess, error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
const confirmMock = vi.spyOn(window, 'confirm')
|
||||
// First confirm to delete overall, returned true for deletion
|
||||
confirmMock.mockImplementation(() => true)
|
||||
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('DeleteMe')).toBeInTheDocument())
|
||||
// Select host
|
||||
const row = screen.getByText('DeleteMe').closest('tr') as HTMLTableRowElement
|
||||
const selectBtn = within(row).getAllByRole('checkbox')[0]
|
||||
await userEvent.click(selectBtn)
|
||||
|
||||
// Open Bulk Delete modal - find the toolbar Delete button near the header
|
||||
const deleteButtons = screen.getAllByText('Delete')
|
||||
const toolbarBtn = deleteButtons.map((btn: Element) => btn.closest('button') as HTMLButtonElement | null).find((b) => b && b.className.includes('bg-error')) as HTMLButtonElement | undefined
|
||||
if (!toolbarBtn) throw new Error('Toolbar delete button not found')
|
||||
await userEvent.click(toolbarBtn)
|
||||
|
||||
// Confirm Delete in modal
|
||||
await userEvent.click(screen.getByRole('button', { name: /Delete Permanently/i }))
|
||||
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Backup created')))
|
||||
confirmMock.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { ProxyHost, BulkUpdateACLResponse } from '../../api/proxyHosts'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const baseHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
|
||||
uuid: 'host-1',
|
||||
name: 'Host',
|
||||
domain_names: 'example.com',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 8080,
|
||||
forward_scheme: 'http' as const,
|
||||
enabled: true,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
certificate: null,
|
||||
certificate_id: null,
|
||||
access_list_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ProxyHosts progress apply', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('shows progress when applying multiple ACLs', async () => {
|
||||
const host1 = baseHost({ uuid: 'h1', name: 'H1' })
|
||||
const host2 = baseHost({ uuid: 'h2', name: 'H2' })
|
||||
const acls = [
|
||||
{ id: 1, uuid: 'acl-1', name: 'ACL1', description: 'Test ACL1', enabled: true, type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-2', name: 'ACL2', description: 'Test ACL2', enabled: true, type: 'blacklist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
]
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue(acls)
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
// Create controllable promises for bulkUpdateACL invocations
|
||||
const resolvers: Array<(value: BulkUpdateACLResponse) => void> = []
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockImplementation((...args: unknown[]) => {
|
||||
const [_hostUUIDs, _aclId] = args
|
||||
void _hostUUIDs; void _aclId
|
||||
return new Promise((resolve: (v: BulkUpdateACLResponse) => void) => { resolvers.push(resolve); })
|
||||
})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
|
||||
// Select both hosts via select-all
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await userEvent.click(checkboxes[0])
|
||||
|
||||
// Open bulk ACL modal
|
||||
await waitFor(() => expect(screen.getByText('Manage ACL')).toBeTruthy())
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
|
||||
// Wait for ACL list
|
||||
await waitFor(() => expect(screen.getByText('ACL1')).toBeTruthy())
|
||||
|
||||
// Select both ACLs
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox')
|
||||
const adminCheckbox = aclCheckboxes.find(cb => cb.closest('label')?.textContent?.includes('ACL1'))
|
||||
const localCheckbox = aclCheckboxes.find(cb => cb.closest('label')?.textContent?.includes('ACL2'))
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox)
|
||||
if (localCheckbox) await userEvent.click(localCheckbox)
|
||||
|
||||
// Click Apply; should start progress (total 2)
|
||||
const applyBtn = await screen.findByRole('button', { name: /Apply\s*\(2\)/i })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
// Progress indicator should appear
|
||||
await waitFor(() => expect(screen.getByText(/Applying ACLs/)).toBeTruthy())
|
||||
// After the first bulk operation starts, we should have a resolver
|
||||
await waitFor(() => expect(resolvers.length).toBeGreaterThanOrEqual(1))
|
||||
|
||||
// Resolve first bulk operation to allow the sequential loop to continue
|
||||
resolvers[0]({ updated: 2, errors: [] })
|
||||
|
||||
// Wait for the second bulk operation to start and create its resolver
|
||||
await waitFor(() => expect(resolvers.length).toBeGreaterThanOrEqual(2))
|
||||
// Resolve second operation
|
||||
resolvers[1]({ updated: 2, errors: [] })
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('does not open window for same_tab link behavior', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([baseHost({ uuid: '1', name: 'One' })])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({ 'ui.domain_link_behavior': 'same_tab' })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
||||
const anchor = screen.getByRole('link', { name: /example\.com/i })
|
||||
expect(anchor.getAttribute('target')).toBe('_self')
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import type { Certificate } from '../../api/certificates';
|
||||
import type { ProxyHost } from '../../api/proxyHosts';
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import type { AccessList } from '../../api/accessLists';
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import * as securityHeadersApi from '../../api/securityHeaders';
|
||||
import type { SecurityHeaderProfile } from '../../api/securityHeaders';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
bulkUpdateSecurityHeaders: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
|
||||
vi.mock('../../api/securityHeaders', () => ({
|
||||
securityHeadersApi: {
|
||||
listProfiles: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({
|
||||
uuid: 'host-1',
|
||||
name: 'Test Host 1',
|
||||
domain_names: 'test1.example.com',
|
||||
forward_host: '192.168.1.10',
|
||||
}),
|
||||
createMockProxyHost({
|
||||
uuid: 'host-2',
|
||||
name: 'Test Host 2',
|
||||
domain_names: 'test2.example.com',
|
||||
forward_host: '192.168.1.20',
|
||||
}),
|
||||
];
|
||||
|
||||
const mockSecurityProfiles: SecurityHeaderProfile[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'profile-1',
|
||||
name: 'Strict Security',
|
||||
description: 'Maximum security headers',
|
||||
security_score: 95,
|
||||
is_preset: true,
|
||||
preset_type: 'strict',
|
||||
hsts_enabled: true,
|
||||
hsts_max_age: 31536000,
|
||||
hsts_include_subdomains: true,
|
||||
hsts_preload: true,
|
||||
x_frame_options: 'DENY',
|
||||
x_content_type_options: true,
|
||||
xss_protection: true,
|
||||
referrer_policy: 'no-referrer',
|
||||
permissions_policy: '',
|
||||
csp_enabled: false,
|
||||
csp_directives: '',
|
||||
csp_report_only: false,
|
||||
csp_report_uri: '',
|
||||
cross_origin_opener_policy: 'same-origin',
|
||||
cross_origin_resource_policy: 'same-origin',
|
||||
cross_origin_embedder_policy: '',
|
||||
cache_control_no_store: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'profile-2',
|
||||
name: 'Moderate Security',
|
||||
description: 'Balanced security headers',
|
||||
security_score: 75,
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
hsts_enabled: true,
|
||||
hsts_max_age: 31536000,
|
||||
hsts_include_subdomains: false,
|
||||
hsts_preload: false,
|
||||
x_frame_options: 'SAMEORIGIN',
|
||||
x_content_type_options: true,
|
||||
xss_protection: true,
|
||||
referrer_policy: 'strict-origin-when-cross-origin',
|
||||
permissions_policy: '',
|
||||
csp_enabled: false,
|
||||
csp_directives: '',
|
||||
csp_report_only: false,
|
||||
csp_report_uri: '',
|
||||
cross_origin_opener_policy: 'same-origin',
|
||||
cross_origin_resource_policy: 'same-origin',
|
||||
cross_origin_embedder_policy: '',
|
||||
cache_control_no_store: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: 'profile-3',
|
||||
name: 'Custom Profile',
|
||||
description: 'My custom headers',
|
||||
security_score: 60,
|
||||
is_preset: false,
|
||||
preset_type: '',
|
||||
hsts_enabled: false,
|
||||
hsts_max_age: 0,
|
||||
hsts_include_subdomains: false,
|
||||
hsts_preload: false,
|
||||
x_frame_options: 'SAMEORIGIN',
|
||||
x_content_type_options: true,
|
||||
xss_protection: true,
|
||||
referrer_policy: 'same-origin',
|
||||
permissions_policy: '',
|
||||
csp_enabled: false,
|
||||
csp_directives: '',
|
||||
csp_report_only: false,
|
||||
csp_report_uri: '',
|
||||
cross_origin_opener_policy: 'same-origin',
|
||||
cross_origin_resource_policy: 'same-origin',
|
||||
cross_origin_embedder_policy: '',
|
||||
cache_control_no_store: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Apply Security Headers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts as ProxyHost[]);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
|
||||
vi.mocked(securityHeadersApi.securityHeadersApi.listProfiles).mockResolvedValue(
|
||||
mockSecurityProfiles
|
||||
);
|
||||
});
|
||||
|
||||
it('shows security header profile option in bulk apply modal', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
// Open Bulk Apply modal
|
||||
const bulkApplyButton = screen.getByText('Bulk Apply');
|
||||
await userEvent.click(bulkApplyButton);
|
||||
|
||||
// Check for security header profile section
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Header Profile')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('Apply a security header profile to all selected hosts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables profile selection when checkbox is checked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
// Find security header checkbox
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
|
||||
// Dropdown should not be visible initially
|
||||
expect(screen.queryByRole('combobox')).toBeNull();
|
||||
|
||||
// Click checkbox to enable
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Dropdown should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('lists all available profiles in dropdown grouped correctly', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Check dropdown options
|
||||
await waitFor(() => {
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(dropdown).toBeTruthy();
|
||||
|
||||
// Check for "None" option
|
||||
const noneOption = within(dropdown).getByText(/None \(Remove Profile\)/i);
|
||||
expect(noneOption).toBeTruthy();
|
||||
|
||||
// Check for preset profiles
|
||||
expect(within(dropdown).getByText(/Strict Security/)).toBeTruthy();
|
||||
expect(within(dropdown).getByText(/Moderate Security/)).toBeTruthy();
|
||||
|
||||
// Check for custom profiles
|
||||
expect(within(dropdown).getByText(/Custom Profile/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies security header profile to selected hosts using bulk endpoint', async () => {
|
||||
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
|
||||
bulkUpdateMock.mockResolvedValue({ updated: 2, errors: [] });
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Select a profile
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '1'); // Select profile ID 1
|
||||
|
||||
// Click Apply
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Verify bulk endpoint was called with correct parameters
|
||||
await waitFor(() => {
|
||||
expect(bulkUpdateMock).toHaveBeenCalledWith(['host-1', 'host-2'], 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes security header profile when "None" selected', async () => {
|
||||
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
|
||||
bulkUpdateMock.mockResolvedValue({ updated: 2, errors: [] });
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Select "None" (value 0)
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '0');
|
||||
|
||||
// Verify warning is shown
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This will remove the security header profile from all selected hosts/
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click Apply
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Verify null was sent to API (remove profile)
|
||||
await waitFor(() => {
|
||||
expect(bulkUpdateMock).toHaveBeenCalledWith(['host-1', 'host-2'], null);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Apply button when no options selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
// Apply button should be disabled when nothing is selected
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
expect(applyButton).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
it('handles partial failure with appropriate toast', async () => {
|
||||
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
|
||||
bulkUpdateMock.mockResolvedValue({
|
||||
updated: 1,
|
||||
errors: [{ uuid: 'host-2', error: 'Profile not found' }],
|
||||
});
|
||||
|
||||
const toast = await import('react-hot-toast');
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option and select a profile
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '1');
|
||||
|
||||
// Click Apply
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Verify error toast was called
|
||||
await waitFor(() => {
|
||||
expect(toast.toast.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets state on modal close', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option and select a profile
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '1');
|
||||
|
||||
// Close modal
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const cancelButton = within(dialog).getByRole('button', { name: /Cancel/i });
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
// Re-open modal
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Security header checkbox should be unchecked (state was reset)
|
||||
await waitFor(() => {
|
||||
const securityHeaderLabel2 = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow2 = securityHeaderLabel2.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox2 = within(securityHeaderRow2).getByRole('checkbox');
|
||||
expect(securityHeaderCheckbox2).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows profile description when profile is selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Select a profile
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '1'); // Strict Security
|
||||
|
||||
// Verify description is shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Maximum security headers')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
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 { BrowserRouter } from 'react-router-dom'
|
||||
import RateLimiting from '../RateLimiting'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import type { SecurityStatus } from '../../api/security'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/settings')
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>{ui}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockStatusEnabled: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled', api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' },
|
||||
rate_limit: { enabled: true, mode: 'enabled' },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const mockStatusDisabled: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled', api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' },
|
||||
rate_limit: { enabled: false, mode: 'disabled' },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const mockSecurityConfig = {
|
||||
config: {
|
||||
name: 'default',
|
||||
rate_limit_requests: 10,
|
||||
rate_limit_burst: 5,
|
||||
rate_limit_window_sec: 60,
|
||||
},
|
||||
}
|
||||
|
||||
describe('RateLimiting page', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('shows loading state while fetching status', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
vi.mocked(securityApi.getSecurityConfig).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders rate limiting page with toggle disabled when rate_limit is off', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('rate-limit-toggle')
|
||||
expect(toggle).toBeInTheDocument()
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('renders rate limiting page with toggle enabled when rate_limit is on', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('rate-limit-toggle')
|
||||
expect((toggle as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('shows configuration inputs when enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('rate-limit-burst')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rate-limit-window')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls updateSetting when toggle is clicked', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-toggle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('rate-limit-toggle')
|
||||
await userEvent.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.rate_limit.enabled',
|
||||
'true',
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('calls updateSecurityConfig when save button is clicked', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
vi.mocked(securityApi.updateSecurityConfig).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Wait for initial values to be set from config
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-rps')).toHaveValue(10)
|
||||
})
|
||||
|
||||
// Change RPS value using tripleClick to select all then type
|
||||
const rpsInput = screen.getByTestId('rate-limit-rps')
|
||||
await userEvent.tripleClick(rpsInput)
|
||||
await userEvent.keyboard('25')
|
||||
|
||||
// Click save
|
||||
const saveBtn = screen.getByTestId('save-rate-limit-btn')
|
||||
await userEvent.click(saveBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.updateSecurityConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rate_limit_requests: 25,
|
||||
rate_limit_burst: 5,
|
||||
rate_limit_window_sec: 60,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('displays default values from config', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('rate-limit-rps')).toHaveValue(10)
|
||||
expect(screen.getByTestId('rate-limit-burst')).toHaveValue(5)
|
||||
expect(screen.getByTestId('rate-limit-window')).toHaveValue(60)
|
||||
})
|
||||
|
||||
it('hides configuration inputs when disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('rate-limit-rps')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('rate-limit-burst')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('rate-limit-window')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows info banner about rate limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Rate limiting helps protect/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import SMTPSettings from '../SMTPSettings'
|
||||
import * as smtpApi from '../../api/smtp'
|
||||
import { toast } from '../../utils/toast'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
|
||||
// Mock API
|
||||
vi.mock('../../api/smtp', () => ({
|
||||
getSMTPConfig: vi.fn(),
|
||||
updateSMTPConfig: vi.fn(),
|
||||
testSMTPConnection: vi.fn(),
|
||||
sendTestEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SMTPSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(toast.success).mockClear()
|
||||
vi.mocked(toast.error).mockClear()
|
||||
})
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
// Should show loading skeletons (Skeleton components don't use animate-spin)
|
||||
expect(document.querySelectorAll('[class*="animate-pulse"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders SMTP form with existing config', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
// Wait for the form to populate with data
|
||||
await waitFor(() => {
|
||||
const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement
|
||||
return hostInput.value === 'smtp.example.com'
|
||||
})
|
||||
|
||||
const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement
|
||||
expect(hostInput.value).toBe('smtp.example.com')
|
||||
|
||||
const portInput = screen.getByPlaceholderText('587') as HTMLInputElement
|
||||
expect(portInput.value).toBe('587')
|
||||
|
||||
expect(screen.getByText('SMTP Configured')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows not configured state when SMTP is not set up', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
encryption: 'starttls',
|
||||
configured: false,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SMTP Not Configured')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves SMTP settings successfully', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
encryption: 'starttls',
|
||||
configured: false,
|
||||
})
|
||||
vi.mocked(smtpApi.updateSMTPConfig).mockResolvedValue({
|
||||
message: 'SMTP configuration saved successfully',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('smtp.gmail.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.gmail.com')
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Charon <no-reply@example.com>'),
|
||||
'test@example.com'
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(smtpApi.updateSMTPConfig).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('tests SMTP connection', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
vi.mocked(smtpApi.testSMTPConnection).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Connection')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByText('Test Connection'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(smtpApi.testSMTPConnection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows test email form when SMTP is configured', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Send Test Email')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByPlaceholderText('recipient@example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends test email', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
vi.mocked(smtpApi.sendTestEmail).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Email sent',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Send Test Email')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('recipient@example.com'),
|
||||
'test@test.com'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /Send Test/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(smtpApi.sendTestEmail).toHaveBeenCalledWith({ to: 'test@test.com' })
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces backend validation errors on save', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
encryption: 'starttls',
|
||||
configured: false,
|
||||
})
|
||||
vi.mocked(smtpApi.updateSMTPConfig).mockRejectedValue({ response: { data: { error: 'invalid host' } } })
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('smtp.gmail.com')).toBeInTheDocument())
|
||||
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'bad.host')
|
||||
await user.type(screen.getByPlaceholderText('Charon <no-reply@example.com>'), 'ops@example.com')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('invalid host')
|
||||
})
|
||||
})
|
||||
|
||||
it('disables test connection until required fields are set and shows error toast on failure', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
encryption: 'starttls',
|
||||
configured: false,
|
||||
})
|
||||
vi.mocked(smtpApi.testSMTPConnection).mockRejectedValue({ response: { data: { error: 'cannot connect' } } })
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Test Connection')).toBeInTheDocument())
|
||||
|
||||
// Button should start disabled until host and from address are provided
|
||||
expect(screen.getByRole('button', { name: 'Test Connection' })).toBeDisabled()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.acme.local')
|
||||
await user.type(screen.getByPlaceholderText('Charon <no-reply@example.com>'), 'from@acme.local')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Test Connection' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('cannot connect')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test email failures and keeps input value intact', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
vi.mocked(smtpApi.sendTestEmail).mockRejectedValue({ response: { data: { error: 'smtp unreachable' } } })
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Send Test Email')).toBeInTheDocument())
|
||||
const input = screen.getByPlaceholderText('recipient@example.com') as HTMLInputElement
|
||||
await user.type(input, 'keepme@example.com')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Send Test/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('smtp unreachable')
|
||||
expect(input.value).toBe('keepme@example.com')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Security Page - QA Security Audit Tests
|
||||
*
|
||||
* Tests edge cases, input validation, error states, and security concerns
|
||||
* for the Cerberus Dashboard implementation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
const mockSecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({ data: { rulesets: [] } })),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Security Page - QA Security Audit', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('Input Validation', () => {
|
||||
it('React escapes XSS in rendered text - validation check', async () => {
|
||||
// Note: React automatically escapes text content, so XSS in input values
|
||||
// won't execute. This test verifies that property.
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// DOM should not contain any actual script elements from user input
|
||||
expect(document.querySelectorAll('script[src*="alert"]').length).toBe(0)
|
||||
|
||||
// Verify React is escaping properly - any text rendered should be text, not HTML
|
||||
expect(screen.queryByText('<script>')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles empty admin whitelist gracefully', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Find the admin whitelist input by placeholder
|
||||
const whitelistInput = screen.getByPlaceholderText(/192.168.1.0\/24/i)
|
||||
expect(whitelistInput).toBeInTheDocument()
|
||||
expect(whitelistInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('displays error toast when toggle mutation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
// CrowdSec is not running, so toggle will try to START it
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start CrowdSec'))
|
||||
})
|
||||
})
|
||||
|
||||
it('handles CrowdSec start failure gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Failed to start'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles CrowdSec stop failure gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Failed to stop'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('handles CrowdSec status check failure gracefully', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockRejectedValue(new Error('Status check failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// Page should still render even if status check fails
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Concurrent Operations', () => {
|
||||
it('disables controls during pending mutations', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
// Never resolving promise to simulate pending state
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
// Overlay should appear indicating operation in progress
|
||||
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('prevents double toggle when starting CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
let callCount = 0
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(async () => {
|
||||
callCount++
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return { status: 'started', pid: 123, lapi_ready: true }
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
|
||||
// First click
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for toggle to become disabled (mutation in progress)
|
||||
await waitFor(() => {
|
||||
expect(toggle).toBeDisabled()
|
||||
})
|
||||
|
||||
// Second click attempt while disabled should be ignored
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for potential multiple calls
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
})
|
||||
|
||||
// Should only be called once due to disabled state
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI Consistency', () => {
|
||||
it('maintains card order when services are toggled', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Get initial card order
|
||||
const initialCards = screen.getAllByRole('heading', { level: 3 })
|
||||
const initialOrder = initialCards.map(card => card.textContent)
|
||||
|
||||
// Toggle a service
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for mutation to settle
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalled())
|
||||
|
||||
// Cards should still be in same order
|
||||
const finalCards = screen.getAllByRole('heading', { level: 3 })
|
||||
const finalOrder = finalCards.map(card => card.textContent)
|
||||
|
||||
expect(finalOrder).toEqual(initialOrder)
|
||||
})
|
||||
|
||||
it('shows correct layer indicator badges', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Each layer should have a Badge with layer number
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows all four security cards even when all disabled', async () => {
|
||||
const disabledStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: '', enabled: false },
|
||||
waf: { mode: 'enabled' as const, enabled: false },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false }
|
||||
}
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// All 4 cards should be present - check for h3 headings
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map(card => card.textContent)
|
||||
expect(cardNames).toContain('CrowdSec')
|
||||
expect(cardNames).toContain('Access Control')
|
||||
expect(cardNames).toContain('Coraza WAF')
|
||||
expect(cardNames).toContain('Rate Limiting')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('all toggles have proper test IDs for automation', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-acl')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-waf')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-rate-limit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CrowdSec controls surface primary actions when enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
|
||||
// CrowdSec card should have Configure button now
|
||||
const configButtons = screen.getAllByRole('button', { name: /Configure/i })
|
||||
expect(configButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Contract Verification (Spec Compliance)', () => {
|
||||
it('pipeline order matches spec: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map(card => card.textContent)
|
||||
|
||||
// Spec requirement: Admin Whitelist + security cards + Security Access Logs
|
||||
expect(cardNames).toEqual(['Admin Whitelist', 'CrowdSec', 'Access Control', 'Coraza WAF', 'Rate Limiting', 'Security Access Logs'])
|
||||
})
|
||||
|
||||
it('layer indicators match spec descriptions', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Layer indicators are now Badges with just the layer number
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('threat summaries match spec when services enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
// CrowdSec must be running to show threat protection descriptions
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// From spec:
|
||||
// CrowdSec: "Known attackers, botnets, brute-force attempts"
|
||||
// ACL: "Unauthorized IPs, geo-based attacks, insider threats"
|
||||
// WAF: "SQL injection, XSS, RCE, zero-day exploits*"
|
||||
// Rate Limiting: "DDoS attacks, credential stuffing, API abuse"
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Unauthorized IPs, geo-based attacks/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/DDoS attacks, credential stuffing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles rapid toggle clicks without crashing', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(resolve, 50))
|
||||
)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
|
||||
// Rapid clicks
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await user.click(toggle)
|
||||
}
|
||||
|
||||
// Page should still be functional
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('handles undefined crowdsec status gracefully', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue(null as never)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// Should not crash
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Security Dashboard Card Status Verification Tests
|
||||
* Test IDs: SD-01 through SD-10
|
||||
*
|
||||
* Tests all 4 security cards display correct status, Cerberus disabled banner,
|
||||
* and toggle switches disabled when Cerberus is off.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// Test Data Fixtures
|
||||
const mockSecurityStatusAllEnabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
const mockSecurityStatusCerberusDisabled = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { mode: 'disabled' as const, api_url: '', enabled: false },
|
||||
waf: { mode: 'disabled' as const, enabled: false },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const mockSecurityStatusMixed = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'disabled' as const, enabled: false },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
describe('Security Dashboard - Card Status Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('SD-01: Cerberus Disabled Banner', () => {
|
||||
it('should show "Security Features Unavailable" banner when cerberus.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show documentation link in disabled banner', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
// Documentation link uses "Learn More" text in current UI
|
||||
const docButtons = screen.getAllByRole('button', { name: /Learn More/i })
|
||||
expect(docButtons.length).toBeGreaterThanOrEqual(1)
|
||||
expect(docButtons[0]).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show banner when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText(/^Cerberus Disabled$/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-02: CrowdSec Card Active Status', () => {
|
||||
it('should show "Enabled" when crowdsec.enabled=true', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
// Status badges now show 'Enabled' text
|
||||
const enabledBadges = screen.getAllByText('Enabled')
|
||||
expect(enabledBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(toggle).toBeChecked()
|
||||
})
|
||||
|
||||
it('should show running PID when CrowdSec is running', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Running \(pid 1234\)/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-03: CrowdSec Card Disabled Status', () => {
|
||||
it('should show "Disabled" when crowdsec.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatusAllEnabled,
|
||||
crowdsec: { mode: 'disabled', api_url: '', enabled: false },
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(toggle).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-04: WAF (Coraza) Card Status', () => {
|
||||
it('should show "Active" when waf.enabled=true', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show "Disabled" when waf.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusMixed)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-05: Rate Limiting Card Status', () => {
|
||||
it('should show badge and text when rate_limit.enabled=true', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-rate-limit')).toBeChecked()
|
||||
const enabledBadges = screen.getAllByText('Enabled')
|
||||
expect(enabledBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show "Disabled" badge when rate_limit.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatusAllEnabled,
|
||||
rate_limit: { enabled: false },
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-rate-limit')).not.toBeChecked()
|
||||
const disabledBadges = screen.getAllByText('Disabled')
|
||||
expect(disabledBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-06: ACL Card Status', () => {
|
||||
it('should show "Active" when acl.enabled=true', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-acl')).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show "Disabled" when acl.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusMixed)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-acl')).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-07: Layer Indicators', () => {
|
||||
it('should display all layer indicators in correct order', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Layer indicators are now Badges with just the layer number
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-08: Threat Protection Summaries', () => {
|
||||
it('should display threat protection descriptions for each card', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
// CrowdSec must be running to show threat protection descriptions
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify threat protection descriptions
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Unauthorized IPs, geo-based attacks/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/DDoS attacks, credential stuffing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-09: Card Order (Pipeline Sequence)', () => {
|
||||
it('should maintain card order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Get all card headings (includes Admin Whitelist when Cerberus is enabled)
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map((card: HTMLElement) => card.textContent)
|
||||
|
||||
// Verify pipeline order with Admin Whitelist first (when Cerberus enabled)
|
||||
expect(cardNames).toEqual(['Admin Whitelist', 'CrowdSec', 'Access Control', 'Coraza WAF', 'Rate Limiting', 'Security Access Logs'])
|
||||
})
|
||||
|
||||
it('should maintain card order even after toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Toggle WAF off
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
// Cards should still be in order
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map((card: HTMLElement) => card.textContent)
|
||||
expect(cardNames).toEqual(['Admin Whitelist', 'CrowdSec', 'Access Control', 'Coraza WAF', 'Rate Limiting', 'Security Access Logs'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-10: Toggle Switches Disabled When Cerberus Off', () => {
|
||||
it('should disable all service toggles when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// All toggles should be disabled
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-waf')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-acl')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-rate-limit')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable toggles when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// All toggles should be enabled
|
||||
expect(screen.getByTestId('toggle-crowdsec')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-waf')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-acl')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-rate-limit')).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Security Error Handling Tests
|
||||
* Test IDs: EH-01 through EH-10
|
||||
*
|
||||
* Tests error messages on API failures, toast notifications on mutation errors,
|
||||
* and optimistic update rollback.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// Test Data Fixtures
|
||||
const mockSecurityStatusAllEnabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
const mockSecurityStatusCrowdsecDisabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: false },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
describe('Security Error Handling Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('EH-01: Failed Security Status Fetch Shows Error', () => {
|
||||
it('should show "Failed to load security configuration" when API fails', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load security configuration/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-02: Toggle Mutation Failure Shows Toast', () => {
|
||||
it('should call toast.error() when toggle mutation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Permission denied'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-03: CrowdSec Start Failure Shows Specific Toast', () => {
|
||||
it('should show "Failed to start CrowdSec: [message]" on start failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCrowdsecDisabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Service unavailable'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
await user.click(screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start CrowdSec'))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Service unavailable'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-04: CrowdSec Stop Failure Shows Specific Toast', () => {
|
||||
it('should show "Failed to stop CrowdSec: [message]" on stop failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Process locked'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
await user.click(screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop CrowdSec'))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Process locked'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-05: WAF Toggle Failure Shows Error', () => {
|
||||
it('should show error toast when WAF toggle fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('WAF configuration error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-06: Rate Limiting Update Failure Shows Toast', () => {
|
||||
it('should show error toast when rate limiting toggle fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Rate limit config error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
await user.click(screen.getByTestId('toggle-rate-limit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-07: Network Error Shows Generic Message', () => {
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Network request failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Network request failed'))
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle non-Error objects gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue('Unknown error string')
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-08: ACL Toggle Failure Shows Error', () => {
|
||||
it('should show error when ACL toggle fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('ACL update failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('ACL update failed'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-09: Multiple Consecutive Failures Show Multiple Toasts', () => {
|
||||
it('should show separate toast for each failed operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Server error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// First failure
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Second failure
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-10: Optimistic Update Reverts on Error', () => {
|
||||
it('should revert toggle state when mutation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Update failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
// WAF is initially enabled
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
expect(toggle).toBeChecked()
|
||||
|
||||
// Click to disable - optimistic update will uncheck it
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for error and rollback
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// After rollback, the toggle should be back to checked (enabled)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should revert CrowdSec state on start failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCrowdsecDisabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Start failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
// CrowdSec is initially disabled
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(toggle).not.toBeChecked()
|
||||
|
||||
// Click to enable
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for error
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start CrowdSec'))
|
||||
})
|
||||
|
||||
// After rollback, toggle should be back to unchecked (disabled)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-crowdsec')).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should revert CrowdSec state on stop failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Stop failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
// CrowdSec is initially enabled
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(toggle).toBeChecked()
|
||||
|
||||
// Click to disable
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for error
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop CrowdSec'))
|
||||
})
|
||||
|
||||
// After rollback, toggle should be back to checked (enabled)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Security Loading Overlay Tests
|
||||
* Test IDs: LS-01 through LS-10
|
||||
*
|
||||
* Tests ConfigReloadOverlay appears during operations, specific loading messages,
|
||||
* and overlay blocks interactions.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// Test Data Fixtures
|
||||
const mockSecurityStatusAllEnabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
const mockSecurityStatusCrowdsecDisabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: false },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
describe('Security Loading Overlay Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('LS-01: Initial Page Load Shows Loading Text', () => {
|
||||
it('should show Skeleton components during initial load', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// Loading state now uses Skeleton components instead of text
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-02: Toggling Service Shows CerberusLoader Overlay', () => {
|
||||
it('should show ConfigReloadOverlay with type="cerberus" when toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
// Never-resolving promise to keep loading state
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Cerberus configuration updating/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-03: Starting CrowdSec Shows "Summoning the guardian..."', () => {
|
||||
it('should show specific message for CrowdSec start operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCrowdsecDisabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
// Never-resolving promise to keep loading state
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/CrowdSec is starting/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-04: Stopping CrowdSec Shows "Guardian rests..."', () => {
|
||||
it('should show specific message for CrowdSec stop operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
// Never-resolving promise to keep loading state
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/CrowdSec is stopping/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-05: WAF Config Operations Show Overlay', () => {
|
||||
it('should show overlay when toggling WAF', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-06: Rate Limiting Toggle Shows Overlay', () => {
|
||||
it('should show overlay when toggling rate limiting', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
await user.click(screen.getByTestId('toggle-rate-limit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Cerberus configuration updating/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-07: ACL Toggle Shows Overlay', () => {
|
||||
it('should show overlay when toggling ACL', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-08: Overlay Contains CerberusLoader Component', () => {
|
||||
it('should render CerberusLoader animation within overlay', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
// The CerberusLoader has role="status" with aria-label="Security Loading"
|
||||
expect(screen.getByRole('status', { name: /Security Loading/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-09: Overlay Blocks Interactions', () => {
|
||||
it('should show overlay during toggle operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify the fixed overlay is present (it has class "fixed inset-0")
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have z-50 overlay that covers content', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
const overlay = document.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-10: Overlay Disappears on Mutation Success', () => {
|
||||
it('should remove overlay after toggle completes successfully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
// First call - resolves quickly to simulate successful toggle
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
// The overlay might flash briefly and disappear, so we verify no overlay after completion
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
// Wait for mutation to complete and overlay to disappear
|
||||
await waitFor(() => {
|
||||
const overlay = document.querySelector('.fixed.inset-0.bg-slate-900\\/70')
|
||||
// After successful mutation, overlay should be gone
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should not show overlay when mutation completes instantly', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// After successful load, no overlay should be present
|
||||
const overlay = document.querySelector('.fixed.inset-0.bg-slate-900\\/70')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as api from '../../api/security'
|
||||
import type { SecurityStatus, RuleSetsResponse } from '../../api/security'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return { ...actual, useNavigate: () => mockNavigate }
|
||||
})
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/crowdsec')
|
||||
|
||||
const defaultFeatureFlags = {
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
}
|
||||
|
||||
const baseStatus: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const createQueryClient = (initialData = []) => createTestQueryClient([
|
||||
{ key: ['securityConfig'], data: mockSecurityConfig },
|
||||
{ key: ['securityRulesets'], data: mockRuleSets },
|
||||
{ key: ['feature-flags'], data: defaultFeatureFlags },
|
||||
...initialData,
|
||||
])
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode, initialData = []) => {
|
||||
const qc = createQueryClient(initialData)
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockSecurityConfig = {
|
||||
config: {
|
||||
name: 'default',
|
||||
waf_mode: 'block',
|
||||
waf_rules_source: '',
|
||||
admin_whitelist: '',
|
||||
},
|
||||
}
|
||||
|
||||
const mockRuleSets: RuleSetsResponse = {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'uuid-1', name: 'OWASP CRS', source_url: '', mode: 'blocking', last_updated: '', content: '' },
|
||||
{ id: 2, uuid: 'uuid-2', name: 'Custom Rules', source_url: '', mode: 'detection', last_updated: '', content: '' },
|
||||
],
|
||||
}
|
||||
// Types already imported at top-level; avoid duplicate declarations
|
||||
describe('Security page', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
|
||||
vi.mocked(api.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
vi.mocked(api.updateSecurityConfig).mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('shows banner when all services are disabled and links to docs', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValueOnce(status as SecurityStatus)
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValueOnce({
|
||||
...status,
|
||||
crowdsec: { ...status.crowdsec, enabled: true }
|
||||
} as SecurityStatus)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
expect(await screen.findByText('Security Features Unavailable')).toBeInTheDocument()
|
||||
const docBtns = screen.getAllByText('Learn More')
|
||||
expect(docBtns.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders per-service toggles and calls updateSetting on change', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec') as HTMLInputElement
|
||||
expect(crowdsecToggle.disabled).toBe(false)
|
||||
// Ensure enable-all controls were removed
|
||||
expect(screen.queryByTestId('enable-all-btn')).toBeNull()
|
||||
})
|
||||
|
||||
it('calls updateSetting when toggling ACL', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
const updateSpy = vi.mocked(settingsApi.updateSetting)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const aclToggle = screen.getByTestId('toggle-acl')
|
||||
await userEvent.click(aclToggle)
|
||||
await waitFor(() => expect(updateSpy).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
// Export button is in CrowdSecConfig component, not Security page
|
||||
|
||||
it('calls start/stop endpoints for CrowdSec via toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const baseStatus: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ status: 'started', pid: 123, lapi_ready: true })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
|
||||
|
||||
cleanup()
|
||||
|
||||
const enabledStatus: SecurityStatus = { ...baseStatus, crowdsec: { enabled: true, mode: 'local' as const, api_url: '' } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(enabledStatus as SecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const stopToggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(stopToggle)
|
||||
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('disables service toggles when cerberus is off', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Features Unavailable')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(crowdsecToggle).toBeDisabled()
|
||||
})
|
||||
|
||||
it('displays correct WAF threat protection summary when enabled', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: true, mode: 'enabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
vi.mocked(api.getSecurityConfig).mockResolvedValue({
|
||||
config: { ...mockSecurityConfig.config, waf_mode: 'monitor' },
|
||||
})
|
||||
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
// WAF now shows threat protection summary instead of mode text
|
||||
await waitFor(() => expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Security', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
const mockSecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true }
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading state initially', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// Loading state now uses Skeleton components instead of text
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show error if security status fails to load', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Failed'))
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Failed to load security configuration/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should render Cerberus Dashboard when status loads', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show banner when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Service Toggles', () => {
|
||||
it('should toggle CrowdSec on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle WAF on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, waf: { mode: 'enabled', enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await act(async () => {
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle ACL on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, acl: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
const toggle = screen.getByTestId('toggle-acl')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle Rate Limiting on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, rate_limit: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
const toggle = screen.getByTestId('toggle-rate-limit')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.rate_limit.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin Whitelist', () => {
|
||||
it('should load admin whitelist from config', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
||||
expect(screen.getByDisplayValue('10.0.0.0/8')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update admin whitelist on save', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockMutate = vi.fn()
|
||||
const { useUpdateSecurityConfig } = await import('../../hooks/useSecurity')
|
||||
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /Save/i })
|
||||
await user.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutate).toHaveBeenCalledWith({ name: 'default', admin_whitelist: '10.0.0.0/8' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrowdSec Controls', () => {
|
||||
it('should start CrowdSec when toggling on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ status: 'started', pid: 123, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await act(async () => {
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool')
|
||||
expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop CrowdSec when toggling off', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue({ success: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await act(async () => {
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'false', 'security', 'bool')
|
||||
expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
// Note: WAF Controls tests removed - dropdowns moved to dedicated WAF config page (/security/waf)
|
||||
|
||||
describe('Card Order (Pipeline Sequence)', () => {
|
||||
it('should render cards in correct pipeline order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Get all card headings (CardTitle uses text-base class)
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map(card => card.textContent)
|
||||
|
||||
// Verify pipeline order: Admin Whitelist + CrowdSec (Layer 1) → ACL (Layer 2) → Coraza (Layer 3) → Rate Limiting (Layer 4) + Security Access Logs
|
||||
expect(cardNames).toEqual(['Admin Whitelist', 'CrowdSec', 'Access Control', 'Coraza WAF', 'Rate Limiting', 'Security Access Logs'])
|
||||
})
|
||||
|
||||
it('should display layer indicators on each card', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Layer indicators are now Badges with just the layer number
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display threat protection summaries', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
// CrowdSec must be running to show threat protection descriptions
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Verify threat protection descriptions
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Unauthorized IPs, geo-based attacks/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/DDoS attacks, credential stuffing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading Overlay', () => {
|
||||
it('should show overlay when service is toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show overlay when starting CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show overlay when stopping CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import SecurityHeaders from '../../pages/SecurityHeaders';
|
||||
import { securityHeadersApi, SecurityHeaderProfile } from '../../api/securityHeaders';
|
||||
import { createBackup } from '../../api/backups';
|
||||
|
||||
vi.mock('../../api/securityHeaders');
|
||||
vi.mock('../../api/backups');
|
||||
vi.mock('react-hot-toast');
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('SecurityHeaders', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render loading state', () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Security Headers')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render empty state', async () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No custom profiles yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render list of profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Profile 1',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Profile 2',
|
||||
is_preset: false,
|
||||
security_score: 90,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Profile 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Profile 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render presets', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Basic Security',
|
||||
description: 'Essential headers',
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
security_score: 65,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Strict Security',
|
||||
description: 'Strong security',
|
||||
is_preset: true,
|
||||
preset_type: 'strict',
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Basic Security')).toBeInTheDocument();
|
||||
expect(screen.getByText('Strict Security')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should open create form dialog', async () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Create Profile/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const createButton = screen.getAllByRole('button', { name: /Create Profile/ })[0];
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Create Security Header Profile/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should open edit dialog', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({
|
||||
score: 85,
|
||||
max_score: 100,
|
||||
breakdown: {},
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole('button', { name: /Edit/ });
|
||||
fireEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Edit Security Header Profile/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone profile', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Original Profile',
|
||||
description: 'Test description',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
hsts_enabled: true,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'Original Profile (Copy)',
|
||||
security_score: 85,
|
||||
} as SecurityHeaderProfile);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Original Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const cloneButton = buttons.find(btn => btn.querySelector('.lucide-copy'));
|
||||
if (cloneButton) {
|
||||
fireEvent.click(cloneButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityHeadersApi.createProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const createCall = vi.mocked(securityHeadersApi.createProfile).mock.calls[0][0];
|
||||
expect(createCall.name).toBe('Original Profile (Copy)');
|
||||
});
|
||||
|
||||
it('should delete profile with backup', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(createBackup).mockResolvedValue({ filename: 'backup.tar.gz' });
|
||||
vi.mocked(securityHeadersApi.deleteProfile).mockResolvedValue(undefined);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click delete button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton);
|
||||
}
|
||||
|
||||
// Confirm deletion - wait for the dialog to appear
|
||||
await waitFor(() => {
|
||||
const headings = screen.getAllByText(/Confirm Deletion/i);
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
}, { timeout: 2000 });
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /Delete/i });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createBackup).toHaveBeenCalled();
|
||||
expect(securityHeadersApi.deleteProfile).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should separate quick presets from custom profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Custom Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Basic Security',
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
security_score: 65,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Profiles (Read-Only)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Profiles')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// System profiles should have View and Clone buttons
|
||||
const presetCard = screen.getByText('Basic Security').closest('div');
|
||||
expect(presetCard).toBeInTheDocument();
|
||||
|
||||
// Custom profile should have Edit button
|
||||
const customCard = screen.getByText('Custom Profile').closest('div');
|
||||
expect(customCard?.textContent).toContain('Custom Profile');
|
||||
});
|
||||
|
||||
it('should display security scores', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'High Score Profile',
|
||||
is_preset: false,
|
||||
security_score: 95,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('95')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import Setup from '../Setup';
|
||||
import * as setupApi from '../../api/setup';
|
||||
|
||||
// Mock AuthContext so useAuth works in tests
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/setup', () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
performSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Setup Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('renders setup form when setup is required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Charon')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify logo is present
|
||||
expect(screen.getAllByAltText('Charon').length).toBeGreaterThan(0);
|
||||
|
||||
expect(screen.getByLabelText('Name')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Email Address')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Password')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render form when setup is not required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Welcome to Charon')).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form successfully', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Charon')).toBeTruthy();
|
||||
});
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText('Name'), 'Admin')
|
||||
await user.type(screen.getByLabelText('Email Address'), 'admin@example.com')
|
||||
await user.type(screen.getByLabelText('Password'), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: 'Create Admin Account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setupApi.performSetup).toHaveBeenCalledWith({
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error on submission failure', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockRejectedValue({
|
||||
response: { data: { error: 'Setup failed' } }
|
||||
});
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Charon')).toBeTruthy();
|
||||
});
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText('Name'), 'Admin')
|
||||
await user.type(screen.getByLabelText('Email Address'), 'admin@example.com')
|
||||
await user.type(screen.getByLabelText('Password'), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: 'Create Admin Account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Setup failed')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('has proper autocomplete attributes for password managers', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Charon')).toBeTruthy();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText('Email Address')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
|
||||
expect(emailInput).toHaveAttribute('autocomplete', 'email')
|
||||
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,644 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import SystemSettings from '../SystemSettings'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
import client from '../../api/client'
|
||||
import { LanguageProvider } from '../../context/LanguageContext'
|
||||
|
||||
// Note: react-i18next mock is provided globally by src/test/setup.ts
|
||||
|
||||
// Mock API modules
|
||||
vi.mock('../../api/settings', () => ({
|
||||
getSettings: vi.fn(),
|
||||
updateSetting: vi.fn(),
|
||||
validatePublicURL: vi.fn(),
|
||||
testPublicURL: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/featureFlags', () => ({
|
||||
getFeatureFlags: vi.fn(),
|
||||
updateFeatureFlags: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SystemSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default mock responses
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'auto',
|
||||
'ui.domain_link_behavior': 'new_tab',
|
||||
'security.cerberus.enabled': 'false',
|
||||
})
|
||||
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: {
|
||||
status: 'healthy',
|
||||
service: 'charon',
|
||||
version: '0.1.0',
|
||||
git_commit: 'abc123',
|
||||
build_time: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSL Provider Selection', () => {
|
||||
it('renders SSL Provider label', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays the correct help text for SSL provider', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Choose the Certificate Authority/i)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the SSL provider select trigger', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Radix UI Select uses a button as the trigger
|
||||
const selectTrigger = screen.getByRole('combobox', { name: /ssl provider/i })
|
||||
expect(selectTrigger).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays Auto as default selection', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Auto (Recommended)')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves SSL provider setting when save button is clicked', async () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const saveButtons = screen.getAllByRole('button', { name: /Save Settings/i })
|
||||
await user.click(saveButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.ssl_provider',
|
||||
expect.any(String),
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('General Settings', () => {
|
||||
it('renders the page title', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Settings')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('loads and displays Caddy Admin API setting', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://custom:2019',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByPlaceholderText('http://localhost:2019') as HTMLInputElement
|
||||
expect(input.value).toBe('http://custom:2019')
|
||||
})
|
||||
})
|
||||
|
||||
it('saves all settings when save button is clicked', async () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Save Settings')).toHaveLength(2)
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const saveButtons = screen.getAllByRole('button', { name: /Save Settings/i })
|
||||
await user.click(saveButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledTimes(4)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.admin_api',
|
||||
expect.any(String),
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.ssl_provider',
|
||||
expect.any(String),
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'ui.domain_link_behavior',
|
||||
expect.any(String),
|
||||
'ui',
|
||||
'string'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('System Status', () => {
|
||||
it('displays system health information', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: {
|
||||
status: 'healthy',
|
||||
service: 'charon',
|
||||
version: '1.0.0',
|
||||
git_commit: 'abc123def',
|
||||
build_time: '2025-12-06T00:00:00Z',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('charon')).toBeTruthy()
|
||||
expect(screen.getByText('1.0.0')).toBeTruthy()
|
||||
expect(screen.getByText('abc123def')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays System Status section', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Status')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Features', () => {
|
||||
it('renders the Features section', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Features')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays all feature flag toggles', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
expect(screen.getByText('CrowdSec Console Enrollment')).toBeTruthy()
|
||||
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Cerberus toggle as checked when enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const switchInput = screen.getByRole('checkbox', { name: /cerberus security suite toggle/i })
|
||||
expect(switchInput).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows Uptime toggle as checked when enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.uptime.enabled': true,
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
|
||||
})
|
||||
|
||||
const switchInput = screen.getByRole('checkbox', { name: /uptime monitoring toggle/i })
|
||||
expect(switchInput).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows Cerberus toggle as unchecked when disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const switchInput = screen.getByRole('checkbox', { name: /cerberus security suite toggle/i })
|
||||
expect(switchInput).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('toggles Cerberus feature flag when switch is clicked', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const switchInput = screen.getByRole('checkbox', { name: /cerberus security suite toggle/i })
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(featureFlagsApi.updateFeatureFlags).toHaveBeenCalledWith({
|
||||
'feature.cerberus.enabled': true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles CrowdSec Console Enrollment feature flag when switch is clicked', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('CrowdSec Console Enrollment')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const switchInput = screen.getByRole('checkbox', { name: /crowdsec console enrollment toggle/i })
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(featureFlagsApi.updateFeatureFlags).toHaveBeenCalledWith({
|
||||
'feature.crowdsec.console_enrollment': true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles Uptime feature flag when switch is clicked', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.uptime.enabled': true,
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const switchInput = screen.getByRole('checkbox', { name: /uptime monitoring toggle/i })
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(featureFlagsApi.updateFeatureFlags).toHaveBeenCalledWith({
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading skeleton when feature flags are not loaded', async () => {
|
||||
// Set settings to resolve but feature flags to never resolve (pending state)
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'auto',
|
||||
'ui.domain_link_behavior': 'new_tab',
|
||||
})
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
// When featureFlags is undefined but settings is loaded, it shows skeleton in the Features card
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Features')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Verify skeleton elements are rendered (Skeleton component uses animate-pulse class)
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows loading overlay while toggling a feature flag', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const switchInput = screen.getByRole('checkbox', { name: /cerberus security suite toggle/i })
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Updating features...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Application URL Card', () => {
|
||||
it('renders public URL input field', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows green border and checkmark when URL is valid', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
// Mock validation response for valid URL
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: { valid: true, normalized: 'https://example.com' },
|
||||
})
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
// Wait for debounced validation
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', {
|
||||
url: 'https://example.com',
|
||||
})
|
||||
}, { timeout: 1000 })
|
||||
|
||||
await waitFor(() => {
|
||||
const checkIcon = document.querySelector('.text-green-500')
|
||||
expect(checkIcon).toBeTruthy()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.className).toContain('border-green-500')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows red border and X icon when URL is invalid', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
// Mock validation response for invalid URL
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: { valid: false, error: 'Invalid URL format' },
|
||||
})
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'invalid-url')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', {
|
||||
url: 'invalid-url',
|
||||
})
|
||||
}, { timeout: 1000 })
|
||||
|
||||
await waitFor(() => {
|
||||
const xIcon = document.querySelector('.text-red-500')
|
||||
expect(xIcon).toBeTruthy()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.className).toContain('border-red-500')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows invalid URL error message when validation fails', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: { valid: false, error: 'Invalid URL format' },
|
||||
})
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'bad-url')
|
||||
|
||||
// Wait for debounce and validation
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for red border class indicating invalid state
|
||||
const inputElement = screen.getByPlaceholderText('https://charon.example.com')
|
||||
expect(inputElement.className).toContain('border-red')
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('clears validation state when URL is cleared', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'app.public_url': 'https://example.com',
|
||||
})
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com') as HTMLInputElement
|
||||
expect(input.value).toBe('https://example.com')
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
await user.clear(input)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.className).not.toContain('border-green-500')
|
||||
expect(input.className).not.toContain('border-red-500')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders test button and verifies functionality', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'app.public_url': 'https://example.com',
|
||||
})
|
||||
vi.mocked(settingsApi.testPublicURL).mockResolvedValue({
|
||||
reachable: true,
|
||||
latency: 42,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Find test button by looking for buttons with External Link icon
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const testButton = buttons.find((btn) => btn.querySelector('.lucide-external-link'))
|
||||
expect(testButton).toBeTruthy()
|
||||
expect(testButton).not.toBeDisabled()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(testButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.testPublicURL).toHaveBeenCalledWith('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
it('disables test button when URL is empty', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'app.public_url': '',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com') as HTMLInputElement
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const testButton = buttons.find((btn) => btn.querySelector('.lucide-external-link'))
|
||||
expect(testButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('handles validation API error gracefully', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
const xIcon = document.querySelector('.text-red-500')
|
||||
expect(xIcon).toBeTruthy()
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Uptime from '../Uptime'
|
||||
import * as uptimeApi from '../../api/uptime'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
vi.mock('../../api/uptime')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Uptime page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders no monitors message', async () => {
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([])
|
||||
renderWithProviders(<Uptime />)
|
||||
expect(await screen.findByText(/No monitors found/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls updateMonitor when toggling monitoring', async () => {
|
||||
const monitor = {
|
||||
id: 'm1', name: 'Test Monitor', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.updateMonitor).mockResolvedValue({ ...monitor, enabled: false })
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('Test Monitor')).toBeInTheDocument())
|
||||
const card = screen.getByText('Test Monitor').closest('div') as HTMLElement
|
||||
const settingsBtn = within(card).getByTitle('Monitor settings')
|
||||
await userEvent.click(settingsBtn)
|
||||
const toggleBtn = within(card).getByText('Pause')
|
||||
await userEvent.click(toggleBtn)
|
||||
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m1', { enabled: false }))
|
||||
})
|
||||
|
||||
it('shows Never when last_check is missing', async () => {
|
||||
const monitor = {
|
||||
id: 'm2', name: 'NoLastCheck', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: null, latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('NoLastCheck')).toBeInTheDocument())
|
||||
const lastCheck = screen.getByText('Never')
|
||||
expect(lastCheck).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows PAUSED state when monitor is disabled', async () => {
|
||||
const monitor = {
|
||||
id: 'm3', name: 'PausedMonitor', url: 'http://example.com', type: 'http', interval: 60, enabled: false,
|
||||
status: 'down', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('PausedMonitor')).toBeInTheDocument())
|
||||
expect(screen.getByText('PAUSED')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders heartbeat bars from history and displays status in bar titles', async () => {
|
||||
const monitor = {
|
||||
id: 'm4', name: 'WithHistory', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
const now = new Date()
|
||||
const history = [
|
||||
{ id: 1, monitor_id: 'm4', status: 'up', latency: 10, message: 'OK', created_at: new Date(now.getTime() - 30000).toISOString() },
|
||||
{ id: 2, monitor_id: 'm4', status: 'down', latency: 20, message: 'Fail', created_at: new Date(now.getTime() - 20000).toISOString() },
|
||||
{ id: 3, monitor_id: 'm4', status: 'up', latency: 5, message: 'OK', created_at: new Date(now.getTime() - 10000).toISOString() },
|
||||
]
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue(history)
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('WithHistory')).toBeInTheDocument())
|
||||
|
||||
// Bar titles include 'Status:' and the status should be capitalized
|
||||
await waitFor(() => expect(document.querySelectorAll('[title*="Status:"]').length).toBeGreaterThanOrEqual(history.length))
|
||||
const barTitles = Array.from(document.querySelectorAll('[title*="Status:"]'))
|
||||
expect(barTitles.some(el => (el.getAttribute('title') || '').includes('Status: UP'))).toBeTruthy()
|
||||
expect(barTitles.some(el => (el.getAttribute('title') || '').includes('Status: DOWN'))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pause button is yellow and appears before delete in settings menu', async () => {
|
||||
const monitor = {
|
||||
id: 'm12', name: 'OrderTest', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('OrderTest')).toBeInTheDocument())
|
||||
const card = screen.getByText('OrderTest').closest('div') as HTMLElement
|
||||
await userEvent.click(within(card).getByTitle('Monitor settings'))
|
||||
|
||||
const configureBtn = within(card).getByText('Configure')
|
||||
// Find the menu container by traversing up until the absolute positioned menu is found
|
||||
let menuContainer: HTMLElement | null = configureBtn.parentElement
|
||||
while (menuContainer && !menuContainer.className.includes('absolute')) {
|
||||
menuContainer = menuContainer.parentElement
|
||||
}
|
||||
expect(menuContainer).toBeTruthy()
|
||||
const buttons = Array.from(menuContainer!.querySelectorAll('button'))
|
||||
const pauseBtn = buttons.find(b => b.textContent?.trim() === 'Pause')
|
||||
const deleteBtn = buttons.find(b => b.textContent?.trim() === 'Delete')
|
||||
expect(pauseBtn).toBeTruthy()
|
||||
expect(deleteBtn).toBeTruthy()
|
||||
// Ensure Pause appears before Delete
|
||||
expect(buttons.indexOf(pauseBtn!)).toBeLessThan(buttons.indexOf(deleteBtn!))
|
||||
// Ensure Pause has yellow styling class
|
||||
expect(pauseBtn!.className).toContain('text-yellow-600')
|
||||
})
|
||||
|
||||
it('deletes monitor when delete confirmed and shows toast', async () => {
|
||||
const monitor = {
|
||||
id: 'm5', name: 'DeleteMe', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.deleteMonitor).mockResolvedValue(undefined)
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('DeleteMe')).toBeInTheDocument())
|
||||
const card = screen.getByText('DeleteMe').closest('div') as HTMLElement
|
||||
const settingsBtn = within(card).getByTitle('Monitor settings')
|
||||
await userEvent.click(settingsBtn)
|
||||
const deleteBtn = within(card).getByText('Delete')
|
||||
await userEvent.click(deleteBtn)
|
||||
await waitFor(() => expect(uptimeApi.deleteMonitor).toHaveBeenCalledWith('m5'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens configure modal and saves changes via updateMonitor', async () => {
|
||||
const monitor = {
|
||||
id: 'm6', name: 'ConfigMe', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.updateMonitor).mockResolvedValue({ ...monitor, max_retries: 6 })
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('ConfigMe')).toBeInTheDocument())
|
||||
const card = screen.getByText('ConfigMe').closest('div') as HTMLElement
|
||||
await userEvent.click(within(card).getByTitle('Monitor settings'))
|
||||
await userEvent.click(within(card).getByText('Configure'))
|
||||
// Modal should open
|
||||
await waitFor(() => expect(screen.getByText('Configure Monitor')).toBeInTheDocument())
|
||||
const spinbuttons = screen.getAllByRole('spinbutton')
|
||||
const maxRetriesInput = spinbuttons.find(el => el.getAttribute('value') === '3') as HTMLInputElement
|
||||
await userEvent.clear(maxRetriesInput)
|
||||
await userEvent.type(maxRetriesInput, '6')
|
||||
await userEvent.clear(screen.getByLabelText('Name'))
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Renamed Monitor')
|
||||
await userEvent.click(screen.getByText('Save Changes'))
|
||||
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m6', { name: 'Renamed Monitor', max_retries: 6, interval: 60 }))
|
||||
})
|
||||
|
||||
it('does not call deleteMonitor when canceling delete', async () => {
|
||||
const monitor = {
|
||||
id: 'm7', name: 'DoNotDelete', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.deleteMonitor).mockResolvedValue(undefined)
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => false)
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('DoNotDelete')).toBeInTheDocument())
|
||||
const card = screen.getByText('DoNotDelete').closest('div') as HTMLElement
|
||||
await userEvent.click(within(card).getByTitle('Monitor settings'))
|
||||
await userEvent.click(within(card).getByText('Delete'))
|
||||
expect(uptimeApi.deleteMonitor).not.toHaveBeenCalled()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows toast error when toggle update fails', async () => {
|
||||
const monitor = {
|
||||
id: 'm8', name: 'ToggleFail', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.updateMonitor).mockRejectedValue(new Error('Update failed'))
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('ToggleFail')).toBeInTheDocument())
|
||||
const card = screen.getByText('ToggleFail').closest('div') as HTMLElement
|
||||
await userEvent.click(within(card).getByTitle('Monitor settings'))
|
||||
await userEvent.click(within(card).getByText('Pause'))
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('separates monitors into Proxy Hosts, Remote Servers and Other sections', async () => {
|
||||
const proxyMonitor = { id: 'm9', name: 'ProxyMon', url: 'http://p', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 1, max_retries: 2, proxy_host_id: 1 }
|
||||
const remoteMonitor = { id: 'm10', name: 'RemoteMon', url: 'http://r', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 2, max_retries: 2, remote_server_id: 2 }
|
||||
const otherMonitor = { id: 'm11', name: 'OtherMon', url: 'http://o', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 3, max_retries: 2 }
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([proxyMonitor, remoteMonitor, otherMonitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('Proxy Hosts')).toBeInTheDocument())
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Other Monitors')).toBeInTheDocument()
|
||||
expect(screen.getByText('ProxyMon')).toBeInTheDocument()
|
||||
expect(screen.getByText('RemoteMon')).toBeInTheDocument()
|
||||
expect(screen.getByText('OtherMon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,524 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import UsersPage from '../UsersPage'
|
||||
import * as usersApi from '../../api/users'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import client from '../../api/client'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
// Mock APIs
|
||||
vi.mock('../../api/users', () => ({
|
||||
listUsers: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
inviteUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
updateUserPermissions: vi.fn(),
|
||||
validateInvite: vi.fn(),
|
||||
acceptInvite: vi.fn(),
|
||||
previewInviteURL: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: '123-456',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
enabled: true,
|
||||
permission_mode: 'allow_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: '789-012',
|
||||
email: 'user@example.com',
|
||||
name: 'Regular User',
|
||||
role: 'user' as const,
|
||||
enabled: true,
|
||||
invite_status: 'accepted' as const,
|
||||
permission_mode: 'allow_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: '345-678',
|
||||
email: 'pending@example.com',
|
||||
name: '',
|
||||
role: 'user' as const,
|
||||
enabled: false,
|
||||
invite_status: 'pending' as const,
|
||||
permission_mode: 'deny_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const mockProxyHosts = [
|
||||
{
|
||||
uuid: '1',
|
||||
name: 'Test Host',
|
||||
domain_names: 'test.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
application: 'none' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('UsersPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts)
|
||||
vi.mocked(toast.success).mockClear()
|
||||
vi.mocked(toast.error).mockClear()
|
||||
})
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
vi.mocked(usersApi.listUsers).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders user list', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('User Management')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Admin User')).toBeTruthy()
|
||||
expect(screen.getByText('admin@example.com')).toBeTruthy()
|
||||
expect(screen.getByText('Regular User')).toBeTruthy()
|
||||
expect(screen.getByText('user@example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows pending invite status', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pending Invite')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows active status for accepted users', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens invite modal when clicking invite button', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invite User')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows permission mode in user list', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Blacklist').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Whitelist')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toggles user enabled status', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'Updated' })
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Regular User')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Find the switch for the non-admin user and toggle it
|
||||
const switches = screen.getAllByRole('checkbox')
|
||||
// The second switch should be for the regular user (admin switch is disabled)
|
||||
const userSwitch = switches.find(
|
||||
(sw) => !(sw as HTMLInputElement).disabled && (sw as HTMLInputElement).checked
|
||||
)
|
||||
|
||||
if (userSwitch) {
|
||||
const user = userEvent.setup()
|
||||
await user.click(userSwitch)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.updateUser).toHaveBeenCalledWith(2, { enabled: false })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('invites a new user', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.inviteUser).mockResolvedValue({
|
||||
id: 4,
|
||||
uuid: 'new-user',
|
||||
email: 'new@example.com',
|
||||
role: 'user',
|
||||
invite_token: 'test-token-123',
|
||||
email_sent: false,
|
||||
expires_at: '2024-01-03T00:00:00Z',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invite User')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
// Wait for modal to open - look for the modal's email input placeholder
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
await user.type(screen.getByPlaceholderText('user@example.com'), 'new@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /Send Invite/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.inviteUser).toHaveBeenCalledWith({
|
||||
email: 'new@example.com',
|
||||
role: 'user',
|
||||
permission_mode: 'allow_all',
|
||||
permitted_hosts: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a user after confirmation', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.deleteUser).mockResolvedValue({ message: 'Deleted' })
|
||||
|
||||
// Mock window.confirm
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Regular User')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Find delete buttons (trash icons) - admin user's delete button is disabled
|
||||
const deleteButtons = screen.getAllByTitle('Delete User')
|
||||
// Find the first non-disabled delete button
|
||||
const enabledDeleteButton = deleteButtons.find((btn) => !(btn as HTMLButtonElement).disabled)
|
||||
|
||||
expect(enabledDeleteButton).toBeTruthy()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(enabledDeleteButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete this user?')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.deleteUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updates user permissions from the modal', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateUserPermissions).mockResolvedValue({ message: 'ok' })
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument())
|
||||
|
||||
const editButtons = screen.getAllByTitle('Edit Permissions')
|
||||
const firstEditable = editButtons.find((btn) => !(btn as HTMLButtonElement).disabled)
|
||||
expect(firstEditable).toBeTruthy()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(firstEditable!)
|
||||
|
||||
const modal = await screen.findByText(/Edit Permissions/i)
|
||||
const modalContainer = modal.closest('.bg-dark-card') as HTMLElement
|
||||
|
||||
// Switch to whitelist (deny_all) and toggle first host
|
||||
const modeSelect = within(modalContainer).getByDisplayValue('Allow All (Blacklist)')
|
||||
await user.selectOptions(modeSelect, 'deny_all')
|
||||
const checkbox = within(modalContainer).getByLabelText(/Test Host/) as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
await user.click(checkbox)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Permissions' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.updateUserPermissions).toHaveBeenCalledWith(2, {
|
||||
permission_mode: 'deny_all',
|
||||
permitted_hosts: expect.arrayContaining([expect.any(Number)]),
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('Permissions updated')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows manual invite link flow when email is not sent and allows copy', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.inviteUser).mockResolvedValue({
|
||||
id: 5,
|
||||
uuid: 'invitee',
|
||||
email: 'manual@example.com',
|
||||
role: 'user',
|
||||
invite_token: 'token-123',
|
||||
email_sent: false,
|
||||
expires_at: '2025-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
get: () => ({ writeText }),
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
await user.type(screen.getByPlaceholderText('user@example.com'), 'manual@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /Send Invite/i }))
|
||||
|
||||
await screen.findByDisplayValue(/accept-invite\?token=token-123/)
|
||||
const copyButton = await screen.findByRole('button', { name: /copy invite link/i })
|
||||
|
||||
await user.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard')
|
||||
})
|
||||
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalDescriptor)
|
||||
} else {
|
||||
delete (navigator as unknown as { clipboard?: unknown }).clipboard
|
||||
}
|
||||
})
|
||||
|
||||
describe('URL Preview in InviteModal', () => {
|
||||
it('shows URL preview when valid email is entered', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: {
|
||||
preview_url: 'https://charon.example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
|
||||
base_url: 'https://charon.example.com',
|
||||
is_configured: true,
|
||||
warning: false,
|
||||
warning_message: '',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
|
||||
}, { timeout: 1000 })
|
||||
|
||||
// Look for the preview URL content with ellipsis replacing the token
|
||||
await waitFor(() => {
|
||||
const previewText = screen.getByText(/charon\.example\.com.*accept-invite.*\.\.\./)
|
||||
expect(previewText).toBeTruthy()
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('debounces URL preview for 500ms', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: {
|
||||
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
|
||||
base_url: 'https://example.com',
|
||||
is_configured: true,
|
||||
warning: false,
|
||||
warning_message: '',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
// Wait 600ms to ensure debounce has completed
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledTimes(1)
|
||||
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('replaces sample token with ellipsis in preview', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: {
|
||||
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
|
||||
base_url: 'https://example.com',
|
||||
is_configured: true,
|
||||
warning: false,
|
||||
warning_message: '',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
const preview = screen.getByText(/example\.com.*accept-invite/)
|
||||
expect(preview.textContent).toContain('...')
|
||||
expect(preview.textContent).not.toContain('SAMPLE_TOKEN_PREVIEW')
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('shows warning when not configured', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: {
|
||||
preview_url: 'http://localhost:8080/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
|
||||
base_url: 'http://localhost:8080',
|
||||
is_configured: false,
|
||||
warning: true,
|
||||
warning_message: 'Application URL not configured',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
// Look for link to system settings
|
||||
const link = screen.getByRole('link')
|
||||
expect(link.getAttribute('href')).toContain('/settings/system')
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('does not show preview when email is invalid', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'invalid')
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
|
||||
// Preview should not be fetched or displayed
|
||||
expect(client.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles preview API error gracefully', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockRejectedValue(new Error('API error'))
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
// Wait for debounce
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
|
||||
}, { timeout: 1000 })
|
||||
|
||||
// Verify preview is not displayed after error
|
||||
const previewQuery = screen.queryByText(/accept-invite/)
|
||||
expect(previewQuery).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,541 @@
|
||||
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 { BrowserRouter } from 'react-router-dom'
|
||||
import WafConfig from '../WafConfig'
|
||||
import * as securityApi from '../../api/security'
|
||||
import type { SecurityRuleSet, RuleSetsResponse } from '../../api/security'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>{ui}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockRuleSet: SecurityRuleSet = {
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'OWASP CRS',
|
||||
source_url: '',
|
||||
mode: 'blocking',
|
||||
last_updated: '2024-01-15T10:00:00Z',
|
||||
content: 'SecRule REQUEST_URI "@contains /admin" "id:1000,phase:1,deny,status:403"',
|
||||
}
|
||||
|
||||
describe('WafConfig page', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('shows loading state while fetching rulesets', async () => {
|
||||
// Keep the promise pending to test loading state
|
||||
vi.mocked(securityApi.getRuleSets).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
expect(screen.getByTestId('waf-loading')).toBeInTheDocument()
|
||||
expect(screen.getByText('Loading WAF configuration...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state when fetch fails', async () => {
|
||||
vi.mocked(securityApi.getRuleSets).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('waf-error')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Failed to load WAF configuration/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Network error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when no rulesets exist', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('waf-empty-state')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('No Rule Sets')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Create your first WAF rule set/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders rulesets table when data exists', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('OWASP CRS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByText('Inline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows create form when Add Rule Set button is clicked', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Create Rule Set' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ruleset-name-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ruleset-content-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits new ruleset and closes form on success', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Fill in the form
|
||||
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test Rules')
|
||||
await userEvent.type(
|
||||
screen.getByTestId('ruleset-content-input'),
|
||||
'SecRule ARGS "@contains test" "id:1,phase:1,deny"'
|
||||
)
|
||||
|
||||
// Submit
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
name: 'Test Rules',
|
||||
source_url: undefined,
|
||||
content: 'SecRule ARGS "@contains test" "id:1,phase:1,deny"',
|
||||
mode: 'blocking',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('opens edit form when edit button is clicked', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
|
||||
|
||||
expect(screen.getByText('Edit Rule Set')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('OWASP CRS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens delete confirmation dialog and deletes on confirm', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.deleteRuleSet).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click delete button
|
||||
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
|
||||
|
||||
// Confirm dialog should appear
|
||||
expect(screen.getByText('Delete Rule Set')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Are you sure you want to delete "OWASP CRS"/)).toBeInTheDocument()
|
||||
|
||||
// Confirm deletion
|
||||
await userEvent.click(screen.getByTestId('confirm-delete-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.deleteRuleSet).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels delete when clicking cancel button', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click delete button
|
||||
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
|
||||
|
||||
// Click cancel
|
||||
await userEvent.click(screen.getByText('Cancel'))
|
||||
|
||||
// Dialog should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Rule Set')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(securityApi.deleteRuleSet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels delete when clicking backdrop', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click delete button
|
||||
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
|
||||
|
||||
// Click backdrop
|
||||
await userEvent.click(screen.getByTestId('confirm-dialog-backdrop'))
|
||||
|
||||
// Dialog should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Rule Set')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays mode correctly for detection-only rulesets', async () => {
|
||||
const detectionRuleset: SecurityRuleSet = {
|
||||
...mockRuleSet,
|
||||
mode: 'detection',
|
||||
}
|
||||
const response: RuleSetsResponse = { rulesets: [detectionRuleset] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Detection')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays URL link when source_url is provided', async () => {
|
||||
const urlRuleset: SecurityRuleSet = {
|
||||
...mockRuleSet,
|
||||
source_url: 'https://example.com/rules.conf',
|
||||
content: '',
|
||||
}
|
||||
const response: RuleSetsResponse = { rulesets: [urlRuleset] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const urlLink = screen.getByText('URL')
|
||||
expect(urlLink).toHaveAttribute('href', 'https://example.com/rules.conf')
|
||||
expect(urlLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('validates form - submit disabled without name', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Only add content, no name
|
||||
await userEvent.type(screen.getByTestId('ruleset-content-input'), 'SecRule test')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('validates form - submit disabled without content or URL', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Only add name, no content or URL
|
||||
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('allows form submission with URL instead of content', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Add name and URL, no content
|
||||
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Remote Rules')
|
||||
await userEvent.type(screen.getByTestId('ruleset-url-input'), 'https://example.com/rules.conf')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
|
||||
expect(submitBtn).not.toBeDisabled()
|
||||
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
name: 'Remote Rules',
|
||||
source_url: 'https://example.com/rules.conf',
|
||||
content: undefined,
|
||||
mode: 'blocking',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles between blocking and detection mode', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test')
|
||||
await userEvent.type(screen.getByTestId('ruleset-content-input'), 'SecRule test')
|
||||
|
||||
// Select detection mode
|
||||
await userEvent.click(screen.getByTestId('mode-detection'))
|
||||
|
||||
// Verify mode description changed
|
||||
expect(screen.getByText(/Malicious requests will be logged but not blocked/)).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Rule Set' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'detection' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('hides form when cancel is clicked', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
expect(screen.getByRole('heading', { name: 'Create Rule Set' })).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
// Form should be hidden, empty state visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('waf-empty-state')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('updates existing ruleset correctly', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open edit form
|
||||
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
|
||||
|
||||
// Update name
|
||||
const nameInput = screen.getByTestId('ruleset-name-input')
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'Updated CRS')
|
||||
|
||||
// Submit
|
||||
await userEvent.click(screen.getByText('Update Rule Set'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Updated CRS',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens delete from edit form', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open edit form
|
||||
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
|
||||
|
||||
// Click delete button in edit form header
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Confirm dialog should appear
|
||||
expect(screen.getByText('Delete Rule Set')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts rules correctly in table', async () => {
|
||||
const multiRuleSet: SecurityRuleSet = {
|
||||
...mockRuleSet,
|
||||
content: `SecRule ARGS "@contains test1" "id:1,phase:1,deny"
|
||||
SecRule ARGS "@contains test2" "id:2,phase:1,deny"
|
||||
SecRule ARGS "@contains test3" "id:3,phase:1,deny"`,
|
||||
}
|
||||
const response: RuleSetsResponse = { rulesets: [multiRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('3 rule(s)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows preset dropdown when creating new ruleset', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
expect(screen.getByTestId('preset-select')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose a preset...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('auto-fills form when preset is selected', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Select OWASP CRS preset
|
||||
const presetSelect = screen.getByTestId('preset-select')
|
||||
await userEvent.selectOptions(presetSelect, 'OWASP Core Rule Set')
|
||||
|
||||
// Verify form is auto-filled
|
||||
expect(screen.getByTestId('ruleset-name-input')).toHaveValue('OWASP Core Rule Set')
|
||||
expect(screen.getByTestId('ruleset-url-input')).toHaveValue(
|
||||
'https://github.com/coreruleset/coreruleset/archive/refs/tags/v3.3.5.tar.gz'
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-fills content for inline preset', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Select SQL Injection preset (has inline content)
|
||||
const presetSelect = screen.getByTestId('preset-select')
|
||||
await userEvent.selectOptions(presetSelect, 'Basic SQL Injection Protection')
|
||||
|
||||
// Verify content is auto-filled
|
||||
const contentInput = screen.getByTestId('ruleset-content-input') as HTMLTextAreaElement
|
||||
expect(contentInput.value).toContain('SecRule')
|
||||
expect(contentInput.value).toContain('SQLi')
|
||||
})
|
||||
|
||||
it('does not show preset dropdown when editing', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
|
||||
|
||||
// Preset dropdown should not be visible when editing
|
||||
expect(screen.queryByTestId('preset-select')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user