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(null) const [role, setRole] = useState<'user' | 'admin'>('user') const [permissionMode, setPermissionMode] = useState('allow_all') const [selectedHosts, setSelectedHosts] = useState([]) 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) */}
{/* Layer 2: Form container (z-50, pointer-events-none) */}
{/* Layer 3: Form content (pointer-events-auto) */}

{t('users.inviteUser')}

{inviteResult ? (
{t('users.inviteSuccess')}
{inviteResult.emailSent ? (

{t('users.inviteEmailSent')}

) : (

{t('users.inviteEmailNotSent')}

)}
{!inviteResult.emailSent && (

{t('users.expires')}: {new Date(inviteResult.expiresAt).toLocaleString()}

)}
) : ( <>
{ setEmail(e.target.value) validateEmail(e.target.value) }} placeholder="user@example.com" /> {emailError && (

{emailError}

)}
{role === 'user' && ( <>

{permissionMode === 'allow_all' ? t('users.allowAllDescription') : t('users.denyAllDescription')}

{proxyHosts.length === 0 ? (

{t('users.noProxyHosts')}

) : ( proxyHosts.map((host) => ( )) )}
)} {/* URL Preview */} {urlPreview && (
{urlPreview.preview_url.replace('SAMPLE_TOKEN_PREVIEW', '...')}
{urlPreview.warning && ( {t('users.inviteUrlWarning')} {t('users.configureApplicationUrl')} )}
)}
)}
) } 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('allow_all') const [selectedHosts, setSelectedHosts] = useState([]) // 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) */}
{/* Layer 2: Form container (z-50, pointer-events-none) */}
{/* Layer 3: Form content (pointer-events-auto) */}

{t('users.editPermissions')} - {user.name || user.email}

{permissionMode === 'allow_all' ? t('users.allowAllDescription') : t('users.denyAllDescription')}

{proxyHosts.length === 0 ? (

{t('users.noProxyHosts')}

) : ( proxyHosts.map((host) => ( )) )}
) } export default function UsersPage() { const { t } = useTranslation() const queryClient = useQueryClient() const [inviteModalOpen, setInviteModalOpen] = useState(false) const [permissionsModalOpen, setPermissionsModalOpen] = useState(false) const [selectedUser, setSelectedUser] = useState(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 (
) } return (

{t('users.title')}

{users?.map((user) => ( ))}
{t('users.columnUser')} {t('users.columnRole')} {t('common.status')} {t('users.columnPermissions')} {t('common.enabled')} {t('common.actions')}

{user.name || t('users.noName')}

{user.email}

{user.role} {user.invite_status === 'pending' ? ( {t('users.pendingInvite')} ) : user.invite_status === 'expired' ? ( {t('users.inviteExpired')} ) : ( {t('common.active')} )} {user.permission_mode === 'deny_all' ? t('users.whitelist') : t('users.blacklist')} toggleEnabledMutation.mutate({ id: user.id, enabled: !user.enabled, }) } disabled={user.role === 'admin'} />
{user.invite_status === 'pending' && ( )} {user.role !== 'admin' && ( )}
setInviteModalOpen(false)} proxyHosts={proxyHosts} /> { setPermissionsModalOpen(false) setSelectedUser(null) }} user={selectedUser} proxyHosts={proxyHosts} />
) }