Files
Charon/frontend/src/pages/UsersPage.tsx
T
Jeremy cf6d3bd319 fix: resolve modal dropdown z-index conflicts across application
Restructure 7 modal components to use 3-layer architecture preventing
native select dropdown menus from being blocked by modal overlays.

Components fixed:
- ProxyHostForm: ACL selector and Security Headers dropdowns
- User management: Role and permission mode selection
- Uptime monitors: Monitor type selection (HTTP/TCP)
- Remote servers: Provider selection dropdown
- CrowdSec: IP ban duration selection

The fix separates modal background overlay (z-40) from form container
(z-50) and enables pointer events only on form content, allowing
native dropdown menus to render above all modal layers.

Resolves user inability to select security policies, user roles,
monitor types, and other critical configuration options through
the UI interface.
2026-02-05 19:03:37 +00:00

749 lines
28 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { Switch } from '../components/ui/Switch'
import { Alert, AlertDescription } from '../components/ui/Alert'
import { Label } from '../components/ui/Label'
import { toast } from '../utils/toast'
import client from '../api/client'
import {
listUsers,
inviteUser,
deleteUser,
updateUser,
updateUserPermissions,
resendInvite,
} from '../api/users'
import type { User, InviteUserRequest, PermissionMode, UpdateUserPermissionsRequest } from '../api/users'
import { getProxyHosts } from '../api/proxyHosts'
import type { ProxyHost } from '../api/proxyHosts'
import {
Users,
UserPlus,
Mail,
Shield,
Trash2,
Settings,
X,
Check,
AlertCircle,
Clock,
Copy,
Loader2,
ExternalLink,
AlertTriangle,
} from 'lucide-react'
interface InviteModalProps {
isOpen: boolean
onClose: () => void
proxyHosts: ProxyHost[]
}
function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState<string | null>(null)
const [role, setRole] = useState<'user' | 'admin'>('user')
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
const [inviteResult, setInviteResult] = useState<{
token: string
emailSent: boolean
expiresAt: string
} | null>(null)
const [urlPreview, setUrlPreview] = useState<{
preview_url: string
base_url: string
is_configured: boolean
warning: boolean
warning_message: string
} | null>(null)
const validateEmail = (emailValue: string): boolean => {
if (!emailValue) {
setEmailError(null)
return false
}
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (!emailRegex.test(emailValue)) {
setEmailError(t('users.invalidEmail'))
return false
}
setEmailError(null)
return true
}
// Keyboard navigation - close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose])
// Fetch preview when email changes
useEffect(() => {
if (email && email.includes('@')) {
const fetchPreview = async () => {
try {
const response = await client.post('/users/preview-invite-url', { email })
setUrlPreview(response.data)
} catch {
setUrlPreview(null)
}
}
const debounce = setTimeout(fetchPreview, 500)
return () => clearTimeout(debounce)
} else {
setUrlPreview(null)
}
}, [email])
const inviteMutation = useMutation({
mutationFn: async () => {
const request: InviteUserRequest = {
email,
role,
permission_mode: permissionMode,
permitted_hosts: selectedHosts,
}
return inviteUser(request)
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['users'] })
setInviteResult({
token: data.invite_token,
emailSent: data.email_sent,
expiresAt: data.expires_at,
})
if (data.email_sent) {
toast.success(t('users.inviteSent'))
} else {
toast.success(t('users.inviteCreated'))
}
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || t('users.inviteFailed'))
},
})
const copyInviteLink = () => {
if (inviteResult?.token) {
const link = `${window.location.origin}/accept-invite?token=${inviteResult.token}`
navigator.clipboard.writeText(link)
toast.success(t('users.inviteLinkCopied'))
}
}
const handleClose = () => {
setEmail('')
setEmailError(null)
setRole('user')
setPermissionMode('allow_all')
setSelectedHosts([])
setInviteResult(null)
setUrlPreview(null)
onClose()
}
const toggleHost = (hostId: number) => {
setSelectedHosts((prev) =>
prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId]
)
}
if (!isOpen) return null
return (
<>
{/* Layer 1: Background overlay (z-40) */}
<div className="fixed inset-0 bg-black/50 z-40" onClick={handleClose} />
{/* Layer 2: Form container (z-50, pointer-events-none) */}
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
{/* Layer 3: Form content (pointer-events-auto) */}
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto">
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h3 id="invite-modal-title" className="text-lg font-semibold text-white flex items-center gap-2">
<UserPlus className="h-5 w-5" />
{t('users.inviteUser')}
</h3>
<button onClick={handleClose} className="text-gray-400 hover:text-white" aria-label={t('common.close')}>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
{inviteResult ? (
<div className="space-y-4">
<div className="bg-green-900/20 border border-green-800 rounded-lg p-4">
<div className="flex items-center gap-2 text-green-400 mb-2">
<Check className="h-5 w-5" />
<span className="font-medium">{t('users.inviteSuccess')}</span>
</div>
{inviteResult.emailSent ? (
<p className="text-sm text-gray-300">
{t('users.inviteEmailSent')}
</p>
) : (
<p className="text-sm text-gray-300">
{t('users.inviteEmailNotSent')}
</p>
)}
</div>
{!inviteResult.emailSent && (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
{t('users.inviteLink')}
</label>
<div className="flex gap-2">
<Input
type="text"
value={`${window.location.origin}/accept-invite?token=${inviteResult.token}`}
readOnly
className="flex-1 text-sm"
/>
<Button onClick={copyInviteLink} aria-label={t('users.copyInviteLink')} title={t('users.copyInviteLink')}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-gray-500">
{t('users.expires')}: {new Date(inviteResult.expiresAt).toLocaleString()}
</p>
</div>
)}
<Button onClick={handleClose} className="w-full">
{t('users.done')}
</Button>
</div>
) : (
<>
<div>
<Input
label={t('users.emailAddress')}
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
validateEmail(e.target.value)
}}
placeholder="user@example.com"
/>
{emailError && (
<p className="mt-1 text-xs text-red-400" role="alert">
{emailError}
</p>
)}
</div>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{t('users.role')}
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{role === 'user' && (
<>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{t('users.permissionMode')}
</label>
<select
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="allow_all">{t('users.allowAllBlacklist')}</option>
<option value="deny_all">{t('users.denyAllWhitelist')}</option>
</select>
<p className="mt-1 text-xs text-gray-500">
{permissionMode === 'allow_all'
? t('users.allowAllDescription')
: t('users.denyAllDescription')}
</p>
</div>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{permissionMode === 'allow_all' ? t('users.blockedHosts') : t('users.allowedHosts')}
</label>
<div className="max-h-48 overflow-y-auto border border-gray-700 rounded-lg">
{proxyHosts.length === 0 ? (
<p className="p-3 text-sm text-gray-500">{t('users.noProxyHosts')}</p>
) : (
proxyHosts.map((host) => (
<label
key={host.uuid}
className="flex items-center gap-3 p-3 hover:bg-gray-800/50 cursor-pointer border-b border-gray-800 last:border-0"
>
<input
type="checkbox"
checked={selectedHosts.includes(
parseInt(host.uuid.split('-')[0], 16) || 0
)}
onChange={() =>
toggleHost(parseInt(host.uuid.split('-')[0], 16) || 0)
}
className="rounded bg-gray-900 border-gray-700 text-blue-500 focus:ring-blue-500"
/>
<div>
<p className="text-sm text-white">{host.name || host.domain_names}</p>
<p className="text-xs text-gray-500">{host.domain_names}</p>
</div>
</label>
))
)}
</div>
</div>
</>
)}
{/* URL Preview */}
{urlPreview && (
<div className="space-y-2 p-4 bg-gray-900/50 rounded-lg border border-gray-700">
<div className="flex items-center gap-2">
<ExternalLink className="h-4 w-4 text-gray-400" />
<Label className="text-sm font-medium text-gray-300">
{t('users.inviteUrlPreview')}
</Label>
</div>
<div className="text-sm font-mono text-gray-400 break-all bg-gray-950 p-2 rounded">
{urlPreview.preview_url.replace('SAMPLE_TOKEN_PREVIEW', '...')}
</div>
{urlPreview.warning && (
<Alert variant="warning" className="mt-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-xs">
{t('users.inviteUrlWarning')}
<Link to="/settings/system" className="ml-1 underline">
{t('users.configureApplicationUrl')}
</Link>
</AlertDescription>
</Alert>
)}
</div>
)}
<div className="flex gap-3 pt-4 border-t border-gray-700">
<Button variant="secondary" onClick={handleClose} className="flex-1">
{t('common.cancel')}
</Button>
<Button
onClick={() => inviteMutation.mutate()}
isLoading={inviteMutation.isPending}
disabled={!email || !!emailError}
className="flex-1"
>
<Mail className="h-4 w-4 mr-2" />
{t('users.sendInvite')}
</Button>
</div>
</>
)}
</div>
</div>
</div>
</>
)
}
interface PermissionsModalProps {
isOpen: boolean
onClose: () => void
user: User | null
proxyHosts: ProxyHost[]
}
function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModalProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
// Update state when user changes
useEffect(() => {
if (user) {
setPermissionMode(user.permission_mode || 'allow_all')
setSelectedHosts(user.permitted_hosts || [])
}
}, [user])
// Keyboard navigation - close on Escape
const handleClose = useCallback(() => {
onClose()
}, [onClose])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handleClose])
const updatePermissionsMutation = useMutation({
mutationFn: async () => {
if (!user) return
const request: UpdateUserPermissionsRequest = {
permission_mode: permissionMode,
permitted_hosts: selectedHosts,
}
return updateUserPermissions(user.id, request)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success(t('users.permissionsUpdated'))
onClose()
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || t('users.permissionsUpdateFailed'))
},
})
const toggleHost = (hostId: number) => {
setSelectedHosts((prev) =>
prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId]
)
}
if (!isOpen || !user) return null
return (
<>
{/* Layer 1: Background overlay (z-40) */}
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
{/* Layer 2: Form container (z-50, pointer-events-none) */}
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50" role="dialog" aria-modal="true" aria-labelledby="permissions-modal-title">
{/* Layer 3: Form content (pointer-events-auto) */}
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto">
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h3 id="permissions-modal-title" className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="h-5 w-5" />
{t('users.editPermissions')} - {user.name || user.email}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white" aria-label={t('common.close')}>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{t('users.permissionMode')}
</label>
<select
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="allow_all">{t('users.allowAllBlacklist')}</option>
<option value="deny_all">{t('users.denyAllWhitelist')}</option>
</select>
<p className="mt-1 text-xs text-gray-500">
{permissionMode === 'allow_all'
? t('users.allowAllDescription')
: t('users.denyAllDescription')}
</p>
</div>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{permissionMode === 'allow_all' ? t('users.blockedHosts') : t('users.allowedHosts')}
</label>
<div className="max-h-64 overflow-y-auto border border-gray-700 rounded-lg">
{proxyHosts.length === 0 ? (
<p className="p-3 text-sm text-gray-500">{t('users.noProxyHosts')}</p>
) : (
proxyHosts.map((host) => (
<label
key={host.uuid}
className="flex items-center gap-3 p-3 hover:bg-gray-800/50 cursor-pointer border-b border-gray-800 last:border-0"
>
<input
type="checkbox"
checked={selectedHosts.includes(
parseInt(host.uuid.split('-')[0], 16) || 0
)}
onChange={() =>
toggleHost(parseInt(host.uuid.split('-')[0], 16) || 0)
}
className="rounded bg-gray-900 border-gray-700 text-blue-500 focus:ring-blue-500"
/>
<div>
<p className="text-sm text-white">{host.name || host.domain_names}</p>
<p className="text-xs text-gray-500">{host.domain_names}</p>
</div>
</label>
))
)}
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-gray-700">
<Button variant="secondary" onClick={onClose} className="flex-1">
{t('common.cancel')}
</Button>
<Button
onClick={() => updatePermissionsMutation.mutate()}
isLoading={updatePermissionsMutation.isPending}
className="flex-1"
>
{t('users.savePermissions')}
</Button>
</div>
</div>
</div>
</div>
</>
)
}
export default function UsersPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [inviteModalOpen, setInviteModalOpen] = useState(false)
const [permissionsModalOpen, setPermissionsModalOpen] = useState(false)
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: listUsers,
})
const { data: proxyHosts = [] } = useQuery({
queryKey: ['proxyHosts'],
queryFn: getProxyHosts,
})
const toggleEnabledMutation = useMutation({
mutationFn: async ({ id, enabled }: { id: number; enabled: boolean }) => {
return updateUser(id, { enabled })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success(t('users.userUpdated'))
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || t('users.userUpdateFailed'))
},
})
const deleteMutation = useMutation({
mutationFn: deleteUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success(t('users.userDeleted'))
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || t('users.userDeleteFailed'))
},
})
const resendInviteMutation = useMutation({
mutationFn: resendInvite,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['users'] })
if (data.email_sent) {
toast.success(t('users.inviteResent'))
} else {
toast.success(t('users.inviteCreatedNoEmail'))
}
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } }
toast.error(err.response?.data?.error || t('users.resendFailed'))
},
})
const openPermissions = (user: User) => {
setSelectedUser(user)
setPermissionsModalOpen(true)
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-6 w-6 text-blue-500" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{t('users.title')}</h1>
</div>
<Button onClick={() => setInviteModalOpen(true)}>
<UserPlus className="h-4 w-4 mr-2" />
{t('users.inviteUser')}
</Button>
</div>
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th scope="col" className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('users.columnUser')}</th>
<th scope="col" className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('users.columnRole')}</th>
<th scope="col" className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('common.status')}</th>
<th scope="col" className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('users.columnPermissions')}</th>
<th scope="col" className="text-left py-3 px-4 text-sm font-medium text-gray-400">{t('common.enabled')}</th>
<th scope="col" className="text-right py-3 px-4 text-sm font-medium text-gray-400">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="py-3 px-4">
<div>
<p className="text-sm font-medium text-white">{user.name || t('users.noName')}</p>
<p className="text-xs text-gray-500">{user.email}</p>
</div>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
user.role === 'admin'
? 'bg-purple-900/30 text-purple-400'
: 'bg-blue-900/30 text-blue-400'
}`}
>
{user.role}
</span>
</td>
<td className="py-3 px-4">
{user.invite_status === 'pending' ? (
<span className="inline-flex items-center gap-1 text-yellow-400 text-xs">
<Clock className="h-3 w-3" />
{t('users.pendingInvite')}
</span>
) : user.invite_status === 'expired' ? (
<span className="inline-flex items-center gap-1 text-red-400 text-xs">
<AlertCircle className="h-3 w-3" />
{t('users.inviteExpired')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-green-400 text-xs">
<Check className="h-3 w-3" />
{t('common.active')}
</span>
)}
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400">
{user.permission_mode === 'deny_all' ? t('users.whitelist') : t('users.blacklist')}
</span>
</td>
<td className="py-3 px-4">
<Switch
checked={user.enabled}
onChange={() =>
toggleEnabledMutation.mutate({
id: user.id,
enabled: !user.enabled,
})
}
disabled={user.role === 'admin'}
/>
</td>
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-2">
{user.invite_status === 'pending' && (
<button
onClick={() => resendInviteMutation.mutate(user.id)}
className="p-1.5 text-gray-400 hover:text-blue-400 hover:bg-gray-800 rounded"
title={t('users.resendInvite')}
aria-label={t('users.resendInvite')}
disabled={resendInviteMutation.isPending}
>
<Mail className="h-4 w-4" />
</button>
)}
{user.role !== 'admin' && (
<button
onClick={() => openPermissions(user)}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded"
title={t('users.editPermissions')}
aria-label={t('users.editPermissions')}
>
<Settings className="h-4 w-4" />
</button>
)}
<button
onClick={() => {
if (confirm(t('users.deleteConfirm'))) {
deleteMutation.mutate(user.id)
}
}}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-gray-800 rounded"
title={t('users.deleteUser')}
aria-label={t('users.deleteUser')}
disabled={user.role === 'admin'}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<InviteModal
isOpen={inviteModalOpen}
onClose={() => setInviteModalOpen(false)}
proxyHosts={proxyHosts}
/>
<PermissionsModal
isOpen={permissionsModalOpen}
onClose={() => {
setPermissionsModalOpen(false)
setSelectedUser(null)
}}
user={selectedUser}
proxyHosts={proxyHosts}
/>
</div>
)
}