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:
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user