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
@@ -38,6 +38,9 @@ import {
User as UserIcon,
RotateCcw,
} from 'lucide-react';
import { CreateUserModal } from '../components/users/CreateUserModal';
import { UserSecurityModal } from '../components/users/UserSecurityModal';
import { useRbac } from '../auth/rbac';
import {
getUsers,
getUser,
@@ -97,6 +100,23 @@ export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
const { canView, canAdd, canExport } = useRbac();
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
const canViewInvites = canView('settings_invites');
const canViewNotifications = canView('settings_notifications');
const canViewAudit = canView('settings_auditlog');
const canAddUser = canAdd('settings_users') || canAdd('tenant_self_service');
const canAddInvite = canAdd('settings_invites');
React.useEffect(() => {
if (activeTab === 'users' && !canViewUsers) {
if (canViewInvites) setActiveTab('invites');
else if (canViewNotifications) setActiveTab('notifications');
else if (canViewAudit) setActiveTab('audit');
}
}, [activeTab, canViewUsers, canViewInvites, canViewNotifications, canViewAudit]);
// ==========================================
// 1. User Directory State
// ==========================================
@@ -105,6 +125,7 @@ export const SettingsPage: React.FC = () => {
const [userRoleFilter, setUserRoleFilter] = useState<string>('all');
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
const userFilter: UserFilterRequest = {
email: userEmailSearch.trim() || undefined,
@@ -572,77 +593,85 @@ export const SettingsPage: React.FC = () => {
{/* Navigation Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'users'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Users className="w-4 h-4" />
<span>User Directory ({usersList.length})</span>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="top"
/>
</button>
{canViewUsers && (
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'users'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Users className="w-4 h-4" />
<span>User Directory ({usersList.length})</span>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="top"
/>
</button>
)}
<button
onClick={() => setActiveTab('invites')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'invites'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Mail className="w-4 h-4" />
<span>Communications &amp; Invites ({invitesList.length})</span>
<HelpTooltip
title="Communications Dispatch"
content="Statutory onboarding invitations and applicant notification ledger."
size="sm"
placement="top"
/>
</button>
{canViewInvites && (
<button
onClick={() => setActiveTab('invites')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'invites'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Mail className="w-4 h-4" />
<span>Communications &amp; Invites ({invitesList.length})</span>
<HelpTooltip
title="Communications Dispatch"
content="Statutory onboarding invitations and applicant notification ledger."
size="sm"
placement="top"
/>
</button>
)}
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'notifications'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Bell className="w-4 h-4" />
<span>Gateways &amp; Notifications ({fetchedNotifications.length})</span>
<HelpTooltip
title="Notification Gateways"
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
size="sm"
placement="top"
/>
</button>
{canViewNotifications && (
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'notifications'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Bell className="w-4 h-4" />
<span>Gateways &amp; Notifications ({fetchedNotifications.length})</span>
<HelpTooltip
title="Notification Gateways"
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
size="sm"
placement="top"
/>
</button>
)}
<button
onClick={() => setActiveTab('audit')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'audit'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<History className="w-4 h-4" />
<span>Audit &amp; Telemetry Ledger ({auditLogs.length})</span>
<HelpTooltip
title="Audit Trail"
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
size="sm"
placement="top"
/>
</button>
{canViewAudit && (
<button
onClick={() => setActiveTab('audit')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'audit'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<History className="w-4 h-4" />
<span>Audit &amp; Telemetry Ledger ({auditLogs.length})</span>
<HelpTooltip
title="Audit Trail"
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
size="sm"
placement="top"
/>
</button>
)}
</div>
{/* ============================================================== */}
@@ -725,12 +754,25 @@ export const SettingsPage: React.FC = () => {
<span>Refresh</span>
</button>
{canAddUser && (
<button
type="button"
onClick={() => setIsCreateUserModalOpen(true)}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
title="Provision a new tenant user with Hashids Hex ID and role assignment"
>
<Plus className="w-3.5 h-3.5" />
<span>Provision User</span>
</button>
)}
<ExportButton
title="User Directory &amp; Access Register"
subtitle="Canonical directory of authorized users and account states"
filename={`pfm-users-directory-${new Date().toISOString().split('T')[0]}`}
columns={userExportColumns}
data={sortedUsers}
claim="settings_users"
/>
<button
@@ -830,10 +872,11 @@ export const SettingsPage: React.FC = () => {
setSelectedUserForDetail(u);
setIsUserDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 rounded-lg transition cursor-pointer"
title="Manage User Roles, Claims, Status, and Security Sessions"
>
<Eye className="w-3 h-3 text-blue-400" />
Inspect
<Shield className="w-3 h-3 text-blue-400" />
Manage Access
</button>
{/* Status switch actions */}
@@ -948,15 +991,18 @@ export const SettingsPage: React.FC = () => {
filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`}
columns={inviteExportColumns}
data={sortedInvites}
claim="settings_invites"
/>
<button
onClick={() => setIsSendInviteModalOpen(true)}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>Dispatch Invitation</span>
</button>
{canAddInvite && (
<button
onClick={() => setIsSendInviteModalOpen(true)}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>Dispatch Invitation</span>
</button>
)}
</div>
</div>
@@ -1227,6 +1273,7 @@ export const SettingsPage: React.FC = () => {
filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`}
columns={notificationExportColumns}
data={filteredNotifications}
claim="settings_notifications"
/>
</div>
</div>
@@ -1493,6 +1540,7 @@ export const SettingsPage: React.FC = () => {
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
columns={auditExportColumns}
data={filteredAuditLogs}
claim="settings_auditlog"
/>
</div>
</div>
@@ -2092,73 +2140,24 @@ export const SettingsPage: React.FC = () => {
</Modal>
{/* ============================================================== */}
{/* Modal: User Inspection */}
{/* Modal: User Administration & Role/Claim/Session Control */}
{/* ============================================================== */}
<Modal
<UserSecurityModal
isOpen={isUserDetailModalOpen}
onClose={() => setIsUserDetailModalOpen(false)}
title="User Access & Security Details"
>
{selectedUserForDetail && (
<div className="space-y-4 text-xs">
{loadingUserFreshDetail ? (
<div className="p-6 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching user profile details...
</div>
) : (
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
<span className="text-white font-semibold text-xs">
{userFreshDetail?.email || selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
<span className="font-mono text-slate-300 text-xs break-all">
{userFreshDetail?.id || selectedUserForDetail.id}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Id Number:</span>
<span className="font-mono text-slate-200 text-xs">
{userFreshDetail?.idNumber || selectedUserForDetail.idNumber || '—'}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">System Role:</span>
<UserRoleBadge role={userFreshDetail?.role ?? selectedUserForDetail.role} />
</div>
<div className="flex items-center justify-between pt-1">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).className}`}>
{getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).label}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
<span className="font-mono text-slate-300">
{(userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt) ? formatDate((userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt)!) : 'Never'}
</span>
</div>
</div>
</div>
)}
onClose={() => {
setIsUserDetailModalOpen(false);
setSelectedUserForDetail(null);
}}
user={selectedUserForDetail}
/>
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsUserDetailModalOpen(false)}
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition"
>
Close
</button>
</div>
</div>
)}
</Modal>
{/* ============================================================== */}
{/* Modal: Provision Tenant User */}
{/* ============================================================== */}
<CreateUserModal
isOpen={isCreateUserModalOpen}
onClose={() => setIsCreateUserModalOpen(false)}
/>
{/* ============================================================== */}
{/* Modal: Notification Payload & Data Inspection */}