Implemented rbac

This commit is contained in:
2026-10-11 11:16:08 +02:00
parent 45a6c0aca0
commit 1142cfd94e
24 changed files with 3033 additions and 432 deletions
@@ -0,0 +1,625 @@
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,
addUserClaim,
deleteUserClaim,
forceLogoutUser,
setUserLockout,
updateUserStatus,
getSystemClaims,
} from '../../services/api';
import { HelpTooltip } from '../ui/HelpTooltip';
import { decodeIdNumberHex } from '../../utils/hashids';
import { User, Role, Claim, ActivityStatus } from '../../types';
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
import { useRbac, ROLE_CLAIM_DEFINITIONS, CLAIM_ACTION_OPTIONS } from '../../auth/rbac';
interface UserSecurityModalProps {
user: User | null;
isOpen: boolean;
onClose: () => void;
}
export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
user,
isOpen,
onClose,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
const canManageRolesAndClaims = canApprove('settings_users') || canApprove('tenant_management') || isUnrestricted;
const canManageSessions = canApprove('settings_users') || canApprove('tenant_management') || hasTenantSelfHelpAccess || isUnrestricted;
const canChangeStatus = canAdd('settings_users') || canAdd('tenant_management') || canApprove('settings_users') || isUnrestricted;
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
// Claim creation form state
const [newClaimType, setNewClaimType] = useState('');
const [newClaimValue, setNewClaimValue] = useState('');
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
const userId = user?.id || '';
// 1. Fetch fresh user details
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
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<Role[]>({
queryKey: ['user-roles', userId],
queryFn: () => (userId ? getUserRoles(userId) : []),
enabled: isOpen && !!userId,
});
// 3. Fetch system roles directory
const { data: systemRoles = [], isLoading: loadingSystemRoles } = useQuery<Role[]>({
queryKey: ['system-roles'],
queryFn: getSystemRoles,
enabled: isOpen,
});
// 4. Fetch user claims
const { data: userClaims = [], isLoading: loadingUserClaims, refetch: refetchUserClaims } = useQuery<Claim[]>({
queryKey: ['user-claims', userId],
queryFn: () => (userId ? getUserClaims(userId) : []),
enabled: isOpen && !!userId,
});
// 5. Fetch all system claims across the identity management system (Requirement 4 & 7)
const { data: systemClaims = [], isLoading: loadingSystemClaims } = useQuery<Claim[]>({
queryKey: ['system-claims'],
queryFn: getSystemClaims,
enabled: isOpen,
});
// Role Mutations
const assignRoleMutation = useMutation({
mutationFn: (roleId: string) => {
const selectedRole = systemRoles.find((r) => r.id === roleId);
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1') && hasTenantSelfHelpAccess) {
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.');
}
return assignUserRole(userId, roleId);
},
onSuccess: () => {
success('Role Assigned', 'Role membership updated successfully.');
refetchUserRoles();
setSelectedRoleToAdd('');
},
onError: (err: any) => toastError('Role Assignment Failed', err),
});
const removeRoleMutation = useMutation({
mutationFn: (roleId: string) => removeUserRole(userId, roleId),
onSuccess: () => {
success('Role Removed', 'Role revoked from user.');
refetchUserRoles();
},
onError: (err: any) => toastError('Role Removal Failed', err),
});
// Claim Mutations
const addClaimMutation = useMutation({
mutationFn: () => {
const normalizedType = newClaimType.toLowerCase().trim();
if ((normalizedType === 'unrestricted' || normalizedType === 'admin') && hasTenantSelfHelpAccess) {
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin or Unrestricted claims.');
}
return addUserClaim({
userId,
claimId: Math.floor(Math.random() * 10000) + 1,
claimType: newClaimType.trim(),
claimValue: newClaimValue.trim(),
});
},
onSuccess: () => {
success('Claim Granted', 'User claim added successfully.');
refetchUserClaims();
setNewClaimType('');
setNewClaimValue('');
},
onError: (err: any) => toastError('Claim Creation Failed', err),
});
const deleteClaimMutation = useMutation({
mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId),
onSuccess: () => {
success('Claim Revoked', 'Claim removed from user.');
refetchUserClaims();
},
onError: (err: any) => toastError('Claim Revocation Failed', err),
});
// Session & Lockout Mutations
const logoutMutation = useMutation({
mutationFn: () => forceLogoutUser(userId),
onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'),
onError: (err: any) => toastError('Logout Failed', err),
});
const lockoutMutation = useMutation({
mutationFn: (disableLock: boolean) => setUserLockout(userId, disableLock),
onSuccess: (_, disableLock) => {
success(
disableLock ? 'Account Unlocked' : 'Account Locked',
`User account lockout status successfully modified.`
);
refetchUser();
},
onError: (err: any) => toastError('Lockout Update Failed', err),
});
const statusMutation = useMutation({
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
onSuccess: () => {
success('Status Updated', 'User activity status changed.');
refetchUser();
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => toastError('Status Update Failed', err),
});
if (!user) return null;
const effectiveUser = freshUser || user;
const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
const assignedRoleIds = new Set(userRoles.map((r) => r.id));
const availableRoles = systemRoles.filter((r) => {
if (assignedRoleIds.has(r.id)) return false;
if (hasTenantSelfHelpAccess && (r.name.toLowerCase() === 'admin' || r.id === '1')) {
return false;
}
return true;
});
const assignableClaims = React.useMemo(() => {
const claimMap = new Map<string, { claim: string; label: string }>();
systemClaims.forEach((c) => {
const type = c.claimType || c.type;
if (type) {
if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) {
return;
}
claimMap.set(type, { claim: type, label: type });
}
});
ROLE_CLAIM_DEFINITIONS.forEach((def) => {
if (hasTenantSelfHelpAccess && (def.claim === 'unrestricted' || (def.claim as string) === 'admin')) {
return;
}
claimMap.set(def.claim, { claim: def.claim, label: `${def.claim} (${def.label})` });
});
return Array.from(claimMap.values());
}, [systemClaims, hasTenantSelfHelpAccess]);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`User Administration: ${effectiveUser.email || effectiveUser.id}`}
subtitle="Direct tenant-level user, role, claims, and session administration without database intervention."
>
<div className="space-y-4 text-xs">
{/* Navigation Tabs */}
<div className="flex gap-2 border-b border-slate-800 pb-2">
<button
type="button"
onClick={() => setActiveTab('overview')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer ${
activeTab === 'overview'
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
Profile &amp; Status
</button>
<button
type="button"
onClick={() => setActiveTab('roles')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'roles'
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<Shield className="w-3.5 h-3.5" />
<span>Roles ({userRoles.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('claims')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'claims'
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<Key className="w-3.5 h-3.5" />
<span>Claims ({userClaims.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('sessions')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'sessions'
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<Lock className="w-3.5 h-3.5" />
<span>Security &amp; Sessions</span>
</button>
</div>
{/* TAB 1: Overview & Profile */}
{activeTab === 'overview' && (
<div className="space-y-3">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2.5">
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Email:</span>
<span className="text-white font-medium">{effectiveUser.email || '—'}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User UUID:</span>
<span className="font-mono text-slate-300 text-[11px] break-all">{effectiveUser.id}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Id Number:</span>
<div className="flex items-center gap-2">
<span className="font-mono text-white text-xs">{effectiveUser.idNumber || '—'}</span>
{decodedId && decodedId !== effectiveUser.idNumber && (
<span className="text-[10px] text-blue-400 font-mono">(Decoded: {decodedId})</span>
)}
</div>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
<span className="font-mono text-slate-300">
{effectiveUser.lastLoginAt ? formatDate(effectiveUser.lastLoginAt) : 'Never'}
</span>
</div>
</div>
<div className="border-t border-slate-800/80 pt-2 flex items-center justify-between">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-0.5">
Account Status:
</span>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${
getActivityStatusBadge(effectiveUser.status).className
}`}
>
{getActivityStatusBadge(effectiveUser.status).label}
</span>
</div>
<div className="flex items-center gap-2">
{effectiveUser.status === ActivityStatus.Active ? (
<button
type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
disabled={statusMutation.isPending}
className="px-2.5 py-1 text-xs font-semibold text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 rounded-lg transition"
>
Suspend User
</button>
) : (
<button
type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Active)}
disabled={statusMutation.isPending}
className="px-2.5 py-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition"
>
Activate User
</button>
)}
</div>
</div>
</div>
</div>
)}
{/* TAB 2: Roles */}
{activeTab === 'roles' && (
<div className="space-y-3">
{/* Add Role selector */}
<div className="flex items-center gap-2">
<select
value={selectedRoleToAdd}
onChange={(e) => setSelectedRoleToAdd(e.target.value)}
disabled={loadingSystemRoles || availableRoles.length === 0}
className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
>
<option value="">
{availableRoles.length === 0 ? 'All system roles assigned' : 'Select role to assign...'}
</option>
{availableRoles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<button
type="button"
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)}
disabled={!selectedRoleToAdd || assignRoleMutation.isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
<span>Assign Role</span>
</button>
</div>
{/* List of currently assigned roles */}
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
{loadingUserRoles ? (
<div className="p-4 text-center text-slate-500">Loading roles...</div>
) : userRoles.length === 0 ? (
<div className="p-4 text-center text-slate-500">No roles assigned to this user.</div>
) : (
userRoles.map((role) => (
<div key={role.id} className="p-3 flex items-center justify-between">
<div>
<div className="text-white font-semibold flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-blue-400" />
<span>{role.name}</span>
</div>
<div className="text-[10px] text-slate-500 font-mono">Role ID: {role.id}</div>
</div>
<button
type="button"
onClick={() => removeRoleMutation.mutate(role.id)}
disabled={removeRoleMutation.isPending}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition"
title="Revoke Role"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
)}
{/* TAB 3: Claims */}
{activeTab === 'claims' && (
<div className="space-y-3">
{/* Add claim inline form */}
<div className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Grant Role Claim / Custom Claim</span>
<span className="text-[10px] text-slate-500 font-mono">RBAC Standard Policy</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-slate-400 mb-1 font-medium">Claim Domain</label>
<select
value={assignableClaims.some((d) => d.claim === newClaimType) ? newClaimType : (newClaimType ? 'custom' : '')}
onChange={(e) => {
if (e.target.value === 'custom') {
setNewClaimType('');
} else {
setNewClaimType(e.target.value);
}
}}
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-blue-500"
>
<option value="">-- Select System Claim --</option>
{assignableClaims.map((def) => (
<option key={def.claim} value={def.claim}>
{def.label}
</option>
))}
<option value="custom">-- Custom Claim Type --</option>
</select>
{(!assignableClaims.some((d) => d.claim === newClaimType) || newClaimType === '') && (
<input
type="text"
placeholder="Custom Claim Type..."
value={newClaimType}
onChange={(e) => setNewClaimType(e.target.value)}
className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500"
/>
)}
</div>
<div>
<label className="block text-[10px] text-slate-400 mb-1 font-medium">Claim Value / Action Assignment</label>
<select
value={CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) ? newClaimValue : (newClaimValue ? 'custom' : '')}
onChange={(e) => {
if (e.target.value === 'custom') {
setNewClaimValue('');
} else {
setNewClaimValue(e.target.value);
}
}}
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-blue-500"
>
<option value="">-- Select Action Value --</option>
{CLAIM_ACTION_OPTIONS.map((opt) => (
<option key={opt.action} value={opt.action}>
{opt.action}: {opt.description}
</option>
))}
<option value="custom">-- Custom Value --</option>
</select>
{(!CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) || newClaimValue === '') && (
<input
type="text"
placeholder="Custom Value (e.g. true)..."
value={newClaimValue}
onChange={(e) => setNewClaimValue(e.target.value)}
className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500"
/>
)}
</div>
</div>
<div className="flex justify-end pt-1">
<button
type="button"
onClick={() => addClaimMutation.mutate()}
disabled={!newClaimType.trim() || !newClaimValue.trim() || addClaimMutation.isPending}
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-semibold transition disabled:opacity-50 cursor-pointer text-xs"
>
<Plus className="w-3.5 h-3.5" />
<span>Grant Claim</span>
</button>
</div>
</div>
{/* Existing claims list */}
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
{loadingUserClaims ? (
<div className="p-4 text-center text-slate-500">Loading user claims...</div>
) : userClaims.length === 0 ? (
<div className="p-4 text-center text-slate-500">No active claims found for user.</div>
) : (
userClaims.map((claim, idx) => (
<div key={idx} className="p-3 flex items-center justify-between">
<div>
<div className="text-white font-medium flex items-center gap-1.5">
<Key className="w-3.5 h-3.5 text-amber-400" />
<span>{claim.type || 'Custom Claim'}</span>
</div>
<div className="text-[11px] text-slate-300 font-mono mt-0.5">
Value: <span className="text-emerald-400">{claim.value}</span>
{claim.issuer && <span className="text-slate-500 ml-2">({claim.issuer})</span>}
</div>
</div>
{claim.type && (
<button
type="button"
onClick={() => deleteClaimMutation.mutate(claim.type || String(idx))}
disabled={deleteClaimMutation.isPending}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition"
title="Delete Claim"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))
)}
</div>
</div>
)}
{/* TAB 4: Sessions & Direct Account Interventions */}
{activeTab === 'sessions' && (
<div className="space-y-3">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
{/* Force Logout Action */}
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<div>
<h4 className="text-white font-semibold flex items-center gap-1.5">
<LogOut className="w-4 h-4 text-amber-400" />
<span>Revoke Active User Session</span>
<HelpTooltip
title="User Session Invalidation"
content="Enables a tenant self-service user to immediately logout a user if they are currently logged in, terminating their active token and session."
size="sm"
placement="top"
/>
</h4>
<p className="text-[11px] text-slate-400 mt-0.5">
Terminates all active JWT tokens and sessions immediately without direct database intervention.
</p>
</div>
<button
type="button"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
>
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
</button>
</div>
{/* Lock / Unlock Account */}
<div className="flex items-center justify-between">
<div>
<h4 className="text-white font-semibold flex items-center gap-1.5">
<Lock className="w-4 h-4 text-rose-400" />
<span>Account Lockout Control</span>
<HelpTooltip
title="Account Lockout Control"
content="Enables a tenant self-service user to change a user's lockout status. The system defaults to a lockout period of 100 years when locking a user account."
size="sm"
placement="top"
/>
</h4>
<p className="text-[11px] text-slate-400 mt-0.5">
Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => lockoutMutation.mutate(false)}
disabled={lockoutMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
>
Lock User
</button>
<button
type="button"
onClick={() => lockoutMutation.mutate(true)}
disabled={lockoutMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
>
Unlock User
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal Footer */}
<div className="pt-2 border-t border-slate-800 flex justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Close
</button>
</div>
</div>
</Modal>
);
};