diff --git a/PostFundManagement.Api/Endpoints/Governance/GetTenantUsersEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetTenantUsersEndpoint.cs new file mode 100644 index 0000000..df7c401 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetTenantUsersEndpoint.cs @@ -0,0 +1,34 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Errors; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public sealed class GetTenantUsersEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/tenants/{tenantIdHash}/users", async (IGovernanceService service, string tenantIdHash, + CancellationToken cancellationToken = default) => + await service.GetTenantUsersAsync(tenantIdHash, cancellationToken) switch + { + { IsSuccess: true, Value: var tenantUsers } => Results.Ok(tenantUsers), + var r when r.HasError() => Results.Forbid(), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Gets users created and managed by one tenant") + .RequireAuthorization(Constants.MainScope) + .WithName(typeof(GetTenantUsersEndpoint).ToEndpointName("Retrieval")) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Governance); + } +} + diff --git a/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs b/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs index f82d6fa..8bbb97c 100644 --- a/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Services.Governance; +using PostFundManagement.Application.Services.Governance; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; @@ -95,4 +95,7 @@ public interface IGovernanceService [Log] Task> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default); + + [Log] + Task> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default); } diff --git a/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs b/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs index 8deb7fd..fa5d154 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; @@ -288,6 +288,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut return Result.Ok(cached.FromJson()); })); + public async Task> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) => + await service.GetTenantUsersAsync(tenantIdHash, cancellationToken); + public async Task> GetTenantsAsync(CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { diff --git a/PostFundManagement.Application/Services/Governance/GovernanceService.cs b/PostFundManagement.Application/Services/Governance/GovernanceService.cs index 265a2bf..1ddfdab 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; @@ -452,6 +452,26 @@ public sealed class GovernanceService(IDbContextFactory co return Result.Ok(tenantOrganisations?.Select(i => i.Map()).ToArray() ?? []); })); + public async Task> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + { + var decodeResult = hashService.DecodeLongIdHash(tenantIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(IdDecodeError.Create() + .CausedBy(decodeResult.Errors)); + + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var tenantUsers = await context.TenantUsers.AsNoTracking() + .Include(i => i.User) + .Include(i => i.Tenant) + .Where(f => f.TenantId == decodeResult.Value) + .ToListAsync(cancellationToken); + + return Result.Ok(tenantUsers?.Select(i => i.Map()).ToArray() ?? []); + })); + public async Task> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { diff --git a/PostFundManagement.ConsoleUI/src/App.tsx b/PostFundManagement.ConsoleUI/src/App.tsx index df40f9d..5fa292b 100644 --- a/PostFundManagement.ConsoleUI/src/App.tsx +++ b/PostFundManagement.ConsoleUI/src/App.tsx @@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from './lib/queryClient'; import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider'; -import { ClaimProtectedRoute, useRbac } from './auth/rbac'; +import { ClaimProtectedRoute, TenantManagementProtectedRoute, useRbac } from './auth/rbac'; import { ToastProvider } from './components/ui/Toast'; import { AppLayout } from './components/layout/AppLayout'; @@ -151,17 +151,17 @@ export default function App() { + - + } /> + - + } /> } /> diff --git a/PostFundManagement.ConsoleUI/src/auth/rbac.ts b/PostFundManagement.ConsoleUI/src/auth/rbac.ts index ea24443..e403991 100644 --- a/PostFundManagement.ConsoleUI/src/auth/rbac.ts +++ b/PostFundManagement.ConsoleUI/src/auth/rbac.ts @@ -354,3 +354,29 @@ export const ClaimProtectedRoute: React.FC<{ return React.createElement(React.Fragment, null, children); }; + +/** + * Route protection for Tenant Management (Solution Owners [Tenant 1 + Admin] OR Tenant Self-Service users). + */ +export const TenantManagementProtectedRoute: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const { canManageTenants, isLoading } = useAuth(); + const { canView, canAccess } = useRbac(); + + if (isLoading) { + return null; + } + + const hasAccess = + canManageTenants || + canView('tenant_management') || + canView('tenant_self_service') || + canAccess('tenant_self_service'); + + if (!hasAccess) { + return React.createElement(Navigate, { to: '/dashboard', replace: true }); + } + + return React.createElement(React.Fragment, null, children); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index 519851f..80b0c2d 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -138,7 +138,7 @@ export const AppLayout: React.FC = () => { const visibleNavItems = navItems .filter((item) => { if (item.path === '/tenants') { - return canManageTenants || canView('tenant_management'); + return canManageTenants || canView('tenant_management') || canView('tenant_self_service'); } if (item.path === '/settings') { return ( @@ -456,7 +456,7 @@ export const AppLayout: React.FC = () => {
Tenant: - {canManageTenants ? ( + {canManageTenants || canView('tenant_management') || canView('tenant_self_service') ? ( = ({ 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 canManageRolesAndClaims = + canApprove('settings_users') || + canApprove('tenant_management') || + canApprove('tenant_self_service') || + canAdd('tenant_self_service') || + isUnrestricted; + const canManageSessions = + canApprove('settings_users') || + canApprove('tenant_management') || + hasTenantSelfHelpAccess || + isUnrestricted; + const canChangeStatus = + canAdd('settings_users') || + canAdd('tenant_management') || + canAdd('tenant_self_service') || + canApprove('settings_users') || + isUnrestricted; const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview'); @@ -105,8 +119,11 @@ export const UserSecurityModal: React.FC = ({ // 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) { + const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find( + (r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId)) + ); + const roleName = selectedRole?.name || (selectedRole as any)?.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); @@ -192,11 +209,16 @@ export const UserSecurityModal: React.FC = ({ 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')) { + const decodedId = effectiveUser?.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; + const assignedRoleIds = new Set( + (Array.isArray(userRoles) ? userRoles : []).map((r) => r?.id || (r as any)?.Id).filter(Boolean) + ); + const availableRoles = (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => { + if (!r) return false; + const rId = r.id || (r as any).Id; + const rName = r.name || (r as any).Name || ''; + if (rId && assignedRoleIds.has(rId)) return false; + if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) { return false; } return true; @@ -204,21 +226,28 @@ export const UserSecurityModal: React.FC = ({ const assignableClaims = React.useMemo(() => { const claimMap = new Map(); - systemClaims.forEach((c) => { - const type = c.claimType || c.type; - if (type) { - if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) { + if (Array.isArray(systemClaims)) { + systemClaims.forEach((c) => { + if (!c) return; + const type = c.claimType || c.type; + if (type && typeof type === 'string') { + if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) { + return; + } + claimMap.set(type, { claim: type, label: type }); + } + }); + } + if (Array.isArray(ROLE_CLAIM_DEFINITIONS)) { + ROLE_CLAIM_DEFINITIONS.forEach((def) => { + if (!def || !def.claim) return; + const claimStr = String(def.claim); + if (hasTenantSelfHelpAccess && (claimStr.toLowerCase() === 'unrestricted' || claimStr.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})` }); - }); + claimMap.set(claimStr, { claim: claimStr, label: `${claimStr} (${def.label || claimStr})` }); + }); + } return Array.from(claimMap.values()); }, [systemClaims, hasTenantSelfHelpAccess]); @@ -325,10 +354,10 @@ export const UserSecurityModal: React.FC = ({ - {getActivityStatusBadge(effectiveUser.status).label} + {(getActivityStatusBadge(effectiveUser.status) || {}).label || 'Active'}
@@ -336,8 +365,8 @@ export const UserSecurityModal: React.FC = ({ @@ -345,8 +374,8 @@ export const UserSecurityModal: React.FC = ({ @@ -365,8 +394,8 @@ export const UserSecurityModal: React.FC = ({