import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Shield, Key, Lock, Unlock, LogOut, Plus, Trash2, RefreshCw, User as UserIcon, CheckCircle2, AlertTriangle, FileText, Hash, } from 'lucide-react'; import { Modal } from '../ui/Modal'; import { useToast } from '../ui/Toast'; import { getUser, getUserRoles, getSystemRoles, assignUserRole, removeUserRole, getUserClaims, forceLogoutUser, setUserLockout, updateUserStatus, } from '../../services/api'; import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/roleClaimsCache'; import { HelpTooltip } from '../ui/HelpTooltip'; import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber'; import { decodeIdNumberHex } from '../../utils/hashids'; import { useAuth } from '../../auth/AuthProvider'; import { User, Role, Claim, ActivityStatus } from '../../types'; import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters'; import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac'; interface UserSecurityModalProps { user: User | null; isOpen: boolean; onClose: () => void; } export const UserSecurityModal: React.FC = ({ user, isOpen, onClose, }) => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); const { user: currentAuthUser } = useAuth(); const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac(); const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service'); const userId = user?.id || ''; const isSelf = Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) || Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase()); const canManageRolesAndClaims = canApprove('settings_users') || canApprove('tenant_management') || canApprove('tenant_self_service') || isUnrestricted; const canManageSessions = canApprove('settings_users') || canApprove('tenant_management') || hasTenantSelfHelpAccess || isUnrestricted; const canChangeStatus = !isSelf && (canAdd('settings_users') || canAdd('tenant_management') || canAdd('tenant_self_service') || canApprove('settings_users') || isUnrestricted); const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview'); const [selectedRoleToAdd, setSelectedRoleToAdd] = useState(''); // 1. Fetch fresh user details const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery({ queryKey: ['user-detail', userId], queryFn: () => (userId ? getUser(userId) : null), enabled: isOpen && !!userId, }); // 2. Fetch assigned user roles const { data: userRoles = [], isLoading: loadingUserRoles, refetch: refetchUserRoles } = useQuery({ queryKey: ['user-roles', userId], queryFn: () => (userId ? getUserRoles(userId) : []), enabled: isOpen && !!userId, }); // 3. Fetch system roles directory const { data: systemRoles = [], isLoading: loadingSystemRoles } = useQuery({ queryKey: ['system-roles'], queryFn: getSystemRoles, enabled: isOpen, }); // Effective role derivation for hooks (Single-role policy) const rawRoleValue = freshUser?.role || user?.role; const userRoleStr = rawRoleValue != null ? String(rawRoleValue).trim() : ''; const assignedRole = React.useMemo(() => { if (Array.isArray(userRoles) && userRoles.length > 0) { return userRoles[0]; } if (userRoleStr && Array.isArray(systemRoles)) { const match = systemRoles.find( (r) => r && ((r.name && r.name.toLowerCase() === userRoleStr.toLowerCase()) || String(r.id) === userRoleStr) ); if (match) return match; return { id: userRoleStr, name: userRoleStr } as Role; } return null; }, [userRoles, userRoleStr, systemRoles]); const assignedRoleId = assignedRole?.id || assignedRole?.Id || ''; const assignedRoleName = assignedRole?.name || assignedRole?.Name || ''; // 4. Fetch claims derived from the user's role by hitting the endpoint const { data: roleClaims = [], isLoading: loadingRoleClaims, refetch: refetchRoleClaims, } = useQuery({ queryKey: ['role-claims-detail', assignedRoleId, assignedRoleName], queryFn: async () => { if (!assignedRoleId && !assignedRoleName) return []; return await fetchAndCacheRoleClaims(String(assignedRoleId), assignedRoleName, true); }, enabled: isOpen && (!!assignedRoleId || !!assignedRoleName), }); // 5. Fetch direct user claims if any const { data: directClaims = [], isLoading: loadingDirectClaims, refetch: refetchDirectClaims, } = useQuery({ queryKey: ['user-direct-claims', userId], queryFn: () => (userId ? getUserClaims(userId) : []), enabled: isOpen && !!userId, }); // Combined claims with metadata const userEffectiveClaims = React.useMemo(() => { const map = new Map(); // 1. Role claims from endpoint if (Array.isArray(roleClaims)) { roleClaims.forEach((c) => { if (!c) return; const type = (c.claimType || c.type || '').trim(); if (!type) return; const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase()); map.set(type.toLowerCase(), { ...c, claimType: type, claimValue: c.claimValue || c.value || 'all', label: def?.label || type, description: def?.description || 'Security claim granted via role membership', source: assignedRoleName ? `Role: ${assignedRoleName}` : 'Role Policy', }); }); } // 2. Direct user claims if any if (Array.isArray(directClaims)) { directClaims.forEach((c) => { if (!c) return; const type = (c.claimType || c.type || '').trim(); if (!type) return; if (!map.has(type.toLowerCase())) { const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase()); map.set(type.toLowerCase(), { ...c, claimType: type, claimValue: c.claimValue || c.value || 'all', label: def?.label || type, description: def?.description || 'Direct user account claim', source: 'Direct User Claim', }); } }); } return Array.from(map.values()); }, [roleClaims, directClaims, assignedRoleName]); // Role Mutations (Strictly single-role per user) const assignRoleMutation = useMutation({ mutationFn: (roleId: string) => { if ((Array.isArray(userRoles) ? userRoles : []).length >= 1) { throw new Error('Single-role policy: This user already has an assigned role. Please remove the existing role before assigning a new one.'); } const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find( (r) => r && (String(r.id) === String(roleId) || String(r.Id) === String(roleId)) ); const roleName = selectedRole?.name || selectedRole?.Name || ''; if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) { throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.'); } return assignUserRole(userId, roleId); }, onSuccess: () => { clearCachedUserRbac(userId); success('Role Assigned', 'Role assigned successfully.'); refetchUserRoles(); refetchUser(); refetchRoleClaims(); queryClient.invalidateQueries({ queryKey: ['users'] }); setSelectedRoleToAdd(''); }, onError: (err: unknown) => toastError('Role Assignment Failed', err), }); const removeRoleMutation = useMutation({ mutationFn: (roleId: string) => removeUserRole(userId, roleId), onSuccess: () => { clearCachedUserRbac(userId); success('Role Removed', 'Role revoked from user.'); refetchUserRoles(); refetchUser(); refetchRoleClaims(); queryClient.invalidateQueries({ queryKey: ['users'] }); }, onError: (err: unknown) => toastError('Role Removal Failed', err), }); // Session & Lockout Mutations const logoutMutation = useMutation({ mutationFn: () => forceLogoutUser(userId), onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'), onError: (err: unknown) => toastError('Logout Failed', err), }); const lockUserMutation = useMutation({ mutationFn: () => setUserLockout(userId, true), onSuccess: () => { success('Account Locked', 'User account lockout enabled (disable = true).'); refetchUser(); queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['user-detail', userId] }); }, onError: (err: unknown) => toastError('Lock User Failed', err), }); const unlockUserMutation = useMutation({ mutationFn: () => setUserLockout(userId, false), onSuccess: () => { success('Account Unlocked', 'User account lockout disabled (disable = false).'); refetchUser(); queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['user-detail', userId] }); }, onError: (err: unknown) => toastError('Unlock User Failed', err), }); const statusMutation = useMutation({ mutationFn: (newStatus: ActivityStatus) => { if (isSelf) { throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.'); } return updateUserStatus(userId, newStatus); }, onSuccess: () => { success('Status Updated', 'User activity status changed.'); refetchUser(); queryClient.invalidateQueries({ queryKey: ['users'] }); }, onError: (err: unknown) => toastError('Status Update Failed', err), }); const availableRoles = React.useMemo(() => { return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => { if (!r) return false; const rId = r.id || r.Id; const rName = r.name || r.Name || ''; if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) { return false; } return true; }); }, [systemRoles, hasTenantSelfHelpAccess]); if (!isOpen || !user) return null; const effectiveUser: User = freshUser || user; const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; return (
{/* Navigation Tabs */}
{/* TAB 1: Overview & Profile */} {activeTab === 'overview' && (
Email: {effectiveUser.email || '—'}
User UUID: {effectiveUser.id}
Id Number:
{decodedId && decodedId !== effectiveUser.idNumber && (
(Decoded: )
)}
Last Login: {effectiveUser.lastLoginAt ? formatDate(effectiveUser.lastLoginAt) : 'Never'}
Account Status: {(getActivityStatusBadge(effectiveUser?.status) || {}).label || 'Active'}
{isSelf ? ( Self-Account (Protected) ) : effectiveUser?.status === ActivityStatus.Active ? ( ) : ( )}
)} {/* TAB 2: Roles (Single-Role Policy) */} {activeTab === 'roles' && (
{/* If user already has a role, enforce single-role policy */} {(Array.isArray(userRoles) ? userRoles : []).length > 0 ? (
Active Assigned Role (1 of 1 Max)

The system strictly enforces one role per user. To assign a different role, remove the current role first.

Role Assigned
) : ( /* Add Role selector when user has no role */
)} {/* List of currently assigned roles */}
{loadingUserRoles ? (
Loading assigned role...
) : (Array.isArray(userRoles) ? userRoles : []).length === 0 ? (

No role assigned to this user.

Select a role above to assign it. Users must have exactly one role.

) : ( (Array.isArray(userRoles) ? userRoles : []).map((role) => { if (!role) return null; const roleId = role.id || role.Id || String(role); const roleName = role.name || role.Name || String(role); return (
{roleName} Active
Role ID: {roleId}
); }) )}
)} {/* TAB 3: Claims (Read-Only Inherited from Role) */} {activeTab === 'claims' && (
{/* Informational Policy Banner */}
Role-Derived Security Claims

{assignedRoleName ? ( <>Claims are inherited directly from the assigned role ({assignedRoleName}). They cannot be manually added or removed at the individual user level. ) : ( <>Assign a role on the Roles tab to grant security claims to this user. )}

Read-Only
{/* Claims list */}
{loadingRoleClaims || loadingDirectClaims ? (
Loading security claims from role endpoint...
) : userEffectiveClaims.length === 0 ? (

No active claims found for user.

{assignedRoleName ? `The assigned role (${assignedRoleName}) does not contain any configured permission claims.` : 'Assign a role to this user on the Roles tab to establish functional permissions.'}

) : ( userEffectiveClaims.map((claim, idx) => { const claimType = claim.claimType || claim.type || 'Custom Claim'; const claimVal = claim.claimValue ?? claim.value ?? 'all'; return (
{claim.label || claimType} {claimType}
{claim.description && (

{claim.description}

)}
Value: {claimVal} {claim.source}
); }) )}
)} {/* TAB 4: Sessions & Security Interventions */} {activeTab === 'sessions' && (
{/* Force Logout Action */}

Revoke Active User Session

Immediately invalidates active JWT tokens and logs the user out.

{/* Lock / Unlock Account */}

Account Lockout

Control tenant login authorization for this account.

)} {/* Modal Footer */}
); };