feat: add SMTP settings page and user management features

- Added a new SMTP settings page with functionality to configure SMTP settings, test connections, and send test emails.
- Implemented user management page to list users, invite new users, and manage user permissions.
- Created modals for inviting users and editing user permissions.
- Added tests for the new SMTP settings and user management functionalities.
- Updated navigation to include links to the new SMTP settings and user management pages.
This commit is contained in:
GitHub Actions
2025-12-05 00:47:57 +00:00
parent d3c5196631
commit c06c2829a6
27 changed files with 6050 additions and 30 deletions
+204
View File
@@ -0,0 +1,204 @@
import { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query'
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 [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(`Welcome, ${data.email}! You can now log in.`)
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || 'Failed to accept invitation')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (password !== confirmPassword) {
toast.error('Passwords do not match')
return
}
if (password.length < 8) {
toast.error('Password must be at least 8 characters')
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">Invalid Link</h2>
<p className="text-gray-400 text-center mb-6">
This invitation link is invalid or incomplete.
</p>
<Button onClick={() => navigate('/login')}>Go to Login</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">Validating invitation...</p>
</div>
</Card>
</div>
)
}
if (validationError || !validation?.valid) {
const errorData = validationError as { response?: { data?: { error?: string } } } | undefined
const errorMessage = errorData?.response?.data?.error || 'This invitation has expired or is invalid.'
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">Invitation Invalid</h2>
<p className="text-gray-400 text-center mb-6">{errorMessage}</p>
<Button onClick={() => navigate('/login')}>Go to Login</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">Account Created!</h2>
<p className="text-gray-400 text-center mb-6">
Your account has been set up successfully. Redirecting to login...
</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="Accept Invitation">
<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">You&apos;ve been invited!</span>
</div>
<p className="text-sm text-gray-300">
Complete your account setup for <strong>{validation.email}</strong>
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Your Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
required
/>
<div className="space-y-2">
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
<PasswordStrengthMeter password={password} />
</div>
<Input
label="Confirm Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
error={
confirmPassword && password !== confirmPassword
? 'Passwords do not match'
: undefined
}
/>
<Button
type="submit"
className="w-full"
isLoading={acceptMutation.isPending}
disabled={!name || !password || password !== confirmPassword}
>
Create Account
</Button>
</form>
</div>
</Card>
</div>
</div>
)
}
+233
View File
@@ -0,0 +1,233 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { toast } from '../utils/toast'
import { getSMTPConfig, updateSMTPConfig, testSMTPConnection, sendTestEmail } from '../api/smtp'
import type { SMTPConfigRequest } from '../api/smtp'
import { Mail, Send, CheckCircle2, XCircle, Loader2 } from 'lucide-react'
export default function SMTPSettings() {
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('SMTP settings saved successfully')
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || 'Failed to save SMTP settings')
},
})
const testConnectionMutation = useMutation({
mutationFn: testSMTPConnection,
onSuccess: (data) => {
if (data.success) {
toast.success(data.message || 'SMTP connection successful')
} else {
toast.error(data.error || 'SMTP connection failed')
}
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || 'Failed to test SMTP connection')
},
})
const sendTestEmailMutation = useMutation({
mutationFn: async () => sendTestEmail({ to: testEmail }),
onSuccess: (data) => {
if (data.success) {
toast.success(data.message || 'Test email sent successfully')
setTestEmail('')
} else {
toast.error(data.error || 'Failed to send test email')
}
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || 'Failed to send test email')
},
})
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 gap-2">
<Mail className="h-6 w-6 text-blue-500" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Email (SMTP) Settings</h2>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Configure SMTP settings to enable email notifications and user invitations.
</p>
<Card className="p-6">
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="SMTP Host"
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="smtp.gmail.com"
/>
<Input
label="Port"
type="number"
value={port}
onChange={(e) => setPort(parseInt(e.target.value) || 587)}
placeholder="587"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="your@email.com"
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
helperText="Use app-specific password for Gmail"
/>
</div>
<Input
label="From Address"
type="email"
value={fromAddress}
onChange={(e) => setFromAddress(e.target.value)}
placeholder="Charon <no-reply@example.com>"
/>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Encryption
</label>
<select
value={encryption}
onChange={(e) => setEncryption(e.target.value as 'none' | 'ssl' | 'starttls')}
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 transition-colors"
>
<option value="starttls">STARTTLS (Recommended)</option>
<option value="ssl">SSL/TLS</option>
<option value="none">None</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700">
<Button
variant="secondary"
onClick={() => testConnectionMutation.mutate()}
isLoading={testConnectionMutation.isPending}
disabled={!host || !fromAddress}
>
Test Connection
</Button>
<Button
onClick={() => saveMutation.mutate()}
isLoading={saveMutation.isPending}
>
Save Settings
</Button>
</div>
</div>
</Card>
{/* Status Indicator */}
<Card className="p-4">
<div className="flex items-center gap-3">
{smtpConfig?.configured ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span className="text-green-500 font-medium">SMTP Configured</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-yellow-500" />
<span className="text-yellow-500 font-medium">SMTP Not Configured</span>
</>
)}
</div>
</Card>
{/* Test Email */}
{smtpConfig?.configured && (
<Card className="p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Send Test Email
</h3>
<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" />
Send Test
</Button>
</div>
</Card>
)}
</div>
)
}
+11
View File
@@ -24,6 +24,17 @@ export default function Settings() {
System
</Link>
<Link
to="/settings/smtp"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/settings/smtp')
? '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'
}`}
>
Email (SMTP)
</Link>
<Link
to="/settings/account"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
+582
View File
@@ -0,0 +1,582 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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 { toast } from '../utils/toast'
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,
} from 'lucide-react'
interface InviteModalProps {
isOpen: boolean
onClose: () => void
proxyHosts: ProxyHost[]
}
function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
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 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('Invitation email sent')
} else {
toast.success('User invited - copy the invite link below')
}
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || 'Failed to invite user')
},
})
const copyInviteLink = () => {
if (inviteResult?.token) {
const link = `${window.location.origin}/accept-invite?token=${inviteResult.token}`
navigator.clipboard.writeText(link)
toast.success('Invite link copied to clipboard')
}
}
const handleClose = () => {
setEmail('')
setRole('user')
setPermissionMode('allow_all')
setSelectedHosts([])
setInviteResult(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" />
Invite User
</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">User Invited Successfully</span>
</div>
{inviteResult.emailSent ? (
<p className="text-sm text-gray-300">
An invitation email has been sent to the user.
</p>
) : (
<p className="text-sm text-gray-300">
Email was not sent. Share the invite link manually.
</p>
)}
</div>
{!inviteResult.emailSent && (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Invite Link
</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}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-gray-500">
Expires: {new Date(inviteResult.expiresAt).toLocaleString()}
</p>
</div>
)}
<Button onClick={handleClose} className="w-full">
Done
</Button>
</div>
) : (
<>
<Input
label="Email Address"
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">
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">User</option>
<option value="admin">Admin</option>
</select>
</div>
{role === 'user' && (
<>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Permission Mode
</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">Allow All (Blacklist)</option>
<option value="deny_all">Deny All (Whitelist)</option>
</select>
<p className="mt-1 text-xs text-gray-500">
{permissionMode === 'allow_all'
? 'User can access all hosts EXCEPT those selected below'
: 'User can ONLY access hosts selected below'}
</p>
</div>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{permissionMode === 'allow_all' ? 'Blocked Hosts' : 'Allowed Hosts'}
</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">No proxy hosts configured</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={handleClose} className="flex-1">
Cancel
</Button>
<Button
onClick={() => inviteMutation.mutate()}
isLoading={inviteMutation.isPending}
disabled={!email}
className="flex-1"
>
<Mail className="h-4 w-4 mr-2" />
Send Invite
</Button>
</div>
</>
)}
</div>
</div>
</div>
)
}
interface PermissionsModalProps {
isOpen: boolean
onClose: () => void
user: User | null
proxyHosts: ProxyHost[]
}
function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModalProps) {
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('Permissions updated')
onClose()
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || 'Failed to update permissions')
},
})
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" />
Edit Permissions - {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">
Permission Mode
</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">Allow All (Blacklist)</option>
<option value="deny_all">Deny All (Whitelist)</option>
</select>
<p className="mt-1 text-xs text-gray-500">
{permissionMode === 'allow_all'
? 'User can access all hosts EXCEPT those selected below'
: 'User can ONLY access hosts selected below'}
</p>
</div>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{permissionMode === 'allow_all' ? 'Blocked Hosts' : 'Allowed Hosts'}
</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">No proxy hosts configured</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">
Cancel
</Button>
<Button
onClick={() => updatePermissionsMutation.mutate()}
isLoading={updatePermissionsMutation.isPending}
className="flex-1"
>
Save Permissions
</Button>
</div>
</div>
</div>
</div>
)
}
export default function UsersPage() {
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('User updated')
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || 'Failed to update user')
},
})
const deleteMutation = useMutation({
mutationFn: deleteUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User deleted')
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || 'Failed to delete user')
},
})
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">User Management</h1>
</div>
<Button onClick={() => setInviteModalOpen(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Invite User
</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">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Role</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Permissions</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Enabled</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-400">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 || '(No name)'}</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" />
Pending Invite
</span>
) : user.invite_status === 'expired' ? (
<span className="inline-flex items-center gap-1 text-red-400 text-xs">
<AlertCircle className="h-3 w-3" />
Invite Expired
</span>
) : (
<span className="inline-flex items-center gap-1 text-green-400 text-xs">
<Check className="h-3 w-3" />
Active
</span>
)}
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400">
{user.permission_mode === 'deny_all' ? 'Whitelist' : '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="Edit Permissions"
>
<Settings className="h-4 w-4" />
</button>
)}
<button
onClick={() => {
if (confirm('Are you sure you want to delete this user?')) {
deleteMutation.mutate(user.id)
}
}}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-gray-800 rounded"
title="Delete User"
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,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,209 @@
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 SMTPSettings from '../SMTPSettings'
import * as smtpApi from '../../api/smtp'
// Mock API
vi.mock('../../api/smtp', () => ({
getSMTPConfig: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConnection: vi.fn(),
sendTestEmail: 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}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
)
}
describe('SMTPSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders loading state initially', () => {
vi.mocked(smtpApi.getSMTPConfig).mockReturnValue(new Promise(() => {}))
renderWithProviders(<SMTPSettings />)
// Should show loading spinner
expect(document.querySelector('.animate-spin')).toBeTruthy()
})
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,
})
renderWithProviders(<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,
})
renderWithProviders(<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',
})
renderWithProviders(<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',
})
renderWithProviders(<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,
})
renderWithProviders(<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',
})
renderWithProviders(<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' })
})
})
})
@@ -0,0 +1,281 @@
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 UsersPage from '../UsersPage'
import * as usersApi from '../../api/users'
import * as proxyHostsApi from '../../api/proxyHosts'
// 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(),
}))
vi.mock('../../api/proxyHosts', () => ({
getProxyHosts: 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}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
)
}
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: 'host-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)
})
it('renders loading state initially', () => {
vi.mocked(usersApi.listUsers).mockReturnValue(new Promise(() => {}))
renderWithProviders(<UsersPage />)
expect(document.querySelector('.animate-spin')).toBeTruthy()
})
it('renders user list', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<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)
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Pending Invite')).toBeTruthy()
})
})
it('shows active status for accepted users', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<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)
renderWithProviders(<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)
renderWithProviders(<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' })
renderWithProviders(<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',
})
renderWithProviders(<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)
renderWithProviders(<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()
})
})