import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Users, UserPlus, Mail, Shield, Trash2, Settings, X, Check, AlertCircle, Clock, Copy, Loader2, ExternalLink, AlertTriangle, Pencil, Key, Lock, UserCircle, } from 'lucide-react' import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import client from '../api/client' import { getProxyHosts, type ProxyHost } from '../api/proxyHosts' import { listUsers, inviteUser, deleteUser, updateUser, updateUserPermissions, resendInvite, getProfile, updateProfile, regenerateApiKey, type User, type InviteUserRequest, type PermissionMode, type UpdateUserPermissionsRequest } from '../api/users' import { Alert, AlertDescription } from '../components/ui/Alert' import { Button } from '../components/ui/Button' import { Card } from '../components/ui/Card' import { Input } from '../components/ui/Input' import { Label } from '../components/ui/Label' import { Switch } from '../components/ui/Switch' import { useAuth } from '../hooks/useAuth' import { useFocusTrap } from '../hooks/useFocusTrap' import { toast } from '../utils/toast' interface InviteModalProps { isOpen: boolean onClose: () => void proxyHosts: ProxyHost[] } function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) { const { t } = useTranslation() const queryClient = useQueryClient() const dialogRef = useRef(null) const [email, setEmail] = useState('') const [emailError, setEmailError] = useState(null) const [role, setRole] = useState<'user' | 'admin' | 'passthrough'>('user') const [permissionMode, setPermissionMode] = useState('allow_all') const [selectedHosts, setSelectedHosts] = useState([]) const [inviteResult, setInviteResult] = useState<{ inviteUrl: string emailSent: boolean expiresAt: string } | null>(null) const hasUsableInviteUrl = (inviteUrl?: string): inviteUrl is string => { const normalized = (inviteUrl ?? '').trim() return normalized.length > 0 && normalized !== '[REDACTED]' } 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 } useFocusTrap(dialogRef, 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({ inviteUrl: data.invite_url ?? '', 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 = async () => { if (inviteResult?.inviteUrl && hasUsableInviteUrl(inviteResult.inviteUrl)) { const link = inviteResult.inviteUrl try { await navigator.clipboard.writeText(link) } catch { const textarea = document.createElement('textarea') textarea.value = link textarea.setAttribute('readonly', 'true') textarea.style.position = 'absolute' textarea.style.left = '-9999px' document.body.appendChild(textarea) textarea.select() document.execCommand('copy') document.body.removeChild(textarea) } toast.success(t('users.inviteLinkCopied')) } } const handleClose = () => { setEmail('') setEmailError(null) setRole('user' as 'user' | 'admin' | 'passthrough') 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')}

)}
{hasUsableInviteUrl(inviteResult.inviteUrl) ? (
) : (

{t('users.inviteLinkHiddenForSecurity', { defaultValue: 'Invite link is hidden for security. Share the invite through configured email delivery.' })}

)}

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

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

{emailError}

)}

{role === 'admin' && t('users.roleAdminDescription')} {role === 'user' && t('users.roleUserDescription')} {role === 'passthrough' && t('users.rolePassthroughDescription')}

{(role === 'user' || role === 'passthrough') && ( <>

{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 dialogRef = useRef(null) 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]) const handleClose = useCallback(() => { onClose() }, [onClose]) useFocusTrap(dialogRef, 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) => ( )) )}
) } interface UserDetailModalProps { isOpen: boolean onClose: () => void user: User | null isSelf: boolean } function UserDetailModal({ isOpen, onClose, user, isSelf }: UserDetailModalProps) { const { t } = useTranslation() const { changePassword } = useAuth() const queryClient = useQueryClient() const dialogRef = useRef(null) const [name, setName] = useState('') const [email, setEmail] = useState('') const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [showPasswordSection, setShowPasswordSection] = useState(false) const [apiKeyMasked, setApiKeyMasked] = useState('') useEffect(() => { if (user) { setName(user.name || '') setEmail(user.email || '') setShowPasswordSection(false) setCurrentPassword('') setNewPassword('') setConfirmPassword('') } }, [user]) // Fetch profile for API key info when editing self const { data: profile } = useQuery({ queryKey: ['profile'], queryFn: getProfile, enabled: isOpen && isSelf, }) useEffect(() => { if (profile) { setApiKeyMasked(profile.api_key_masked || '') } }, [profile]) useFocusTrap(dialogRef, isOpen, onClose) const profileMutation = useMutation({ mutationFn: async () => { if (isSelf) { return updateProfile({ name, email }) } return updateUser(user!.id, { name, email }) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) if (isSelf) queryClient.invalidateQueries({ queryKey: ['profile'] }) toast.success(t('users.profileUpdated')) onClose() }, onError: (error: unknown) => { const err = error as { response?: { data?: { error?: string } } } toast.error(err.response?.data?.error || t('users.profileUpdateFailed')) }, }) const passwordMutation = useMutation({ mutationFn: async () => { if (newPassword !== confirmPassword) { throw new Error(t('users.passwordMismatch')) } return changePassword(currentPassword, newPassword) }, onSuccess: () => { toast.success(t('users.passwordChanged')) setShowPasswordSection(false) setCurrentPassword('') setNewPassword('') setConfirmPassword('') }, onError: (error: unknown) => { const err = error as { message?: string } toast.error(err.message || t('users.passwordChangeFailed')) }, }) const regenApiKeyMutation = useMutation({ mutationFn: regenerateApiKey, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['profile'] }) setApiKeyMasked(data.api_key_masked) toast.success(t('users.apiKeyRegenerated')) }, onError: (error: unknown) => { const err = error as { response?: { data?: { error?: string } } } toast.error(err.response?.data?.error || t('users.apiKeyRegenerateFailed')) }, }) if (!isOpen || !user) return null return ( <>

{isSelf ? t('users.myProfile') : t('users.editUser')}

{/* Name & Email */}
setName(e.target.value)} />
setEmail(e.target.value)} />
{/* Password Section — self only */} {isSelf && (
{showPasswordSection && (
setCurrentPassword(e.target.value)} /> setNewPassword(e.target.value)} /> setConfirmPassword(e.target.value)} /> {newPassword && confirmPassword && newPassword !== confirmPassword && (

{t('users.passwordMismatch')}

)}
)}
)} {/* API Key Section — self only */} {isSelf && (
{t('users.apiKey')}
{apiKeyMasked && (

{apiKeyMasked}

)}
)}
) } export default function UsersPage() { const { t } = useTranslation() const { user: authUser } = useAuth() const queryClient = useQueryClient() const [inviteModalOpen, setInviteModalOpen] = useState(false) const [permissionsModalOpen, setPermissionsModalOpen] = useState(false) const [selectedUser, setSelectedUser] = useState(null) const [detailModalOpen, setDetailModalOpen] = useState(false) const [detailUser, setDetailUser] = useState(null) const [isSelfEdit, setIsSelfEdit] = useState(false) 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) } const openDetail = (user: User, self: boolean) => { setDetailUser(user) setIsSelfEdit(self) setDetailModalOpen(true) } const currentUser = users?.find((u) => u.id === authUser?.user_id) if (isLoading) { return (
) } return (

{t('users.title')}

{/* My Profile Card */} {currentUser && (

{t('users.myProfile')}

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

{currentUser.email}

)}
{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 === 'admin' && t('users.roleAdmin')} {user.role === 'user' && t('users.roleUser')} {user.role === 'passthrough' && t('users.rolePassthrough')} {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} /> { setDetailModalOpen(false) setDetailUser(null) }} user={detailUser} isSelf={isSelfEdit} />
) }