Fixed tenant management

This commit is contained in:
2026-10-20 17:56:04 +02:00
parent d7a168c9fa
commit dee9098379
16 changed files with 344 additions and 168 deletions
@@ -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<IdDecodeError>() => 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<TenantUser[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance);
}
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Governance; using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Domain; using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
@@ -95,4 +95,7 @@ public interface IGovernanceService
[Log] [Log]
Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default); Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default);
} }
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
@@ -288,6 +288,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<TenantOrganisation[]>()); return Result.Ok(cached.FromJson<TenantOrganisation[]>());
})); }));
public async Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
await service.GetTenantUsersAsync(tenantIdHash, cancellationToken);
public async Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default) => public async Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Tenant[]>>>)(async () => await Result.Try((Func<Task<Result<Tenant[]>>>)(async () =>
{ {
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain; using PostFundManagement.Domain;
@@ -452,6 +452,26 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
return Result.Ok(tenantOrganisations?.Select(i => i.Map()).ToArray() ?? []); return Result.Ok(tenantOrganisations?.Select(i => i.Map()).ToArray() ?? []);
})); }));
public async Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<TenantUser[]>>>)(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<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) => public async Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () => await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () =>
{ {
+5 -5
View File
@@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient'; import { queryClient } from './lib/queryClient';
import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider'; 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 { ToastProvider } from './components/ui/Toast';
import { AppLayout } from './components/layout/AppLayout'; import { AppLayout } from './components/layout/AppLayout';
@@ -151,17 +151,17 @@ export default function App() {
<Route <Route
path="/tenants" path="/tenants"
element={ element={
<TenantAdminProtectedRoute> <TenantManagementProtectedRoute>
<TenantManagementPage /> <TenantManagementPage />
</TenantAdminProtectedRoute> </TenantManagementProtectedRoute>
} }
/> />
<Route <Route
path="/governance/tenants" path="/governance/tenants"
element={ element={
<TenantAdminProtectedRoute> <TenantManagementProtectedRoute>
<TenantManagementPage /> <TenantManagementPage />
</TenantAdminProtectedRoute> </TenantManagementProtectedRoute>
} }
/> />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
@@ -354,3 +354,29 @@ export const ClaimProtectedRoute: React.FC<{
return React.createElement(React.Fragment, null, children); 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);
};
@@ -138,7 +138,7 @@ export const AppLayout: React.FC = () => {
const visibleNavItems = navItems const visibleNavItems = navItems
.filter((item) => { .filter((item) => {
if (item.path === '/tenants') { if (item.path === '/tenants') {
return canManageTenants || canView('tenant_management'); return canManageTenants || canView('tenant_management') || canView('tenant_self_service');
} }
if (item.path === '/settings') { if (item.path === '/settings') {
return ( return (
@@ -456,7 +456,7 @@ export const AppLayout: React.FC = () => {
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300"> <div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
<span className="text-slate-400">Tenant:</span> <span className="text-slate-400">Tenant:</span>
{canManageTenants ? ( {canManageTenants || canView('tenant_management') || canView('tenant_self_service') ? (
<Link <Link
to="/tenants" to="/tenants"
className="bg-slate-900/80 hover:bg-slate-800/90 text-slate-200 hover:text-white px-2.5 py-1 rounded-lg border border-slate-800 hover:border-slate-700 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate transition group cursor-pointer" className="bg-slate-900/80 hover:bg-slate-800/90 text-slate-200 hover:text-white px-2.5 py-1 rounded-lg border border-slate-800 hover:border-slate-700 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate transition group cursor-pointer"
@@ -54,9 +54,23 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac(); const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service'); const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
const canManageRolesAndClaims = canApprove('settings_users') || canApprove('tenant_management') || isUnrestricted; const canManageRolesAndClaims =
const canManageSessions = canApprove('settings_users') || canApprove('tenant_management') || hasTenantSelfHelpAccess || isUnrestricted; canApprove('settings_users') ||
const canChangeStatus = canAdd('settings_users') || canAdd('tenant_management') || canApprove('settings_users') || isUnrestricted; 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'); const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
@@ -105,8 +119,11 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
// Role Mutations // Role Mutations
const assignRoleMutation = useMutation({ const assignRoleMutation = useMutation({
mutationFn: (roleId: string) => { mutationFn: (roleId: string) => {
const selectedRole = systemRoles.find((r) => r.id === roleId); const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find(
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1') && hasTenantSelfHelpAccess) { (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.'); throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.');
} }
return assignUserRole(userId, roleId); return assignUserRole(userId, roleId);
@@ -192,11 +209,16 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
if (!user) return null; if (!user) return null;
const effectiveUser = freshUser || user; const effectiveUser = freshUser || user;
const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; const decodedId = effectiveUser?.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
const assignedRoleIds = new Set(userRoles.map((r) => r.id)); const assignedRoleIds = new Set(
const availableRoles = systemRoles.filter((r) => { (Array.isArray(userRoles) ? userRoles : []).map((r) => r?.id || (r as any)?.Id).filter(Boolean)
if (assignedRoleIds.has(r.id)) return false; );
if (hasTenantSelfHelpAccess && (r.name.toLowerCase() === 'admin' || r.id === '1')) { 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 false;
} }
return true; return true;
@@ -204,21 +226,28 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
const assignableClaims = React.useMemo(() => { const assignableClaims = React.useMemo(() => {
const claimMap = new Map<string, { claim: string; label: string }>(); const claimMap = new Map<string, { claim: string; label: string }>();
systemClaims.forEach((c) => { if (Array.isArray(systemClaims)) {
const type = c.claimType || c.type; systemClaims.forEach((c) => {
if (type) { if (!c) return;
if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) { 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; return;
} }
claimMap.set(type, { claim: type, label: type }); claimMap.set(claimStr, { claim: claimStr, label: `${claimStr} (${def.label || claimStr})` });
} });
}); }
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()); return Array.from(claimMap.values());
}, [systemClaims, hasTenantSelfHelpAccess]); }, [systemClaims, hasTenantSelfHelpAccess]);
@@ -325,10 +354,10 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</span> </span>
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${ className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${
getActivityStatusBadge(effectiveUser.status).className (getActivityStatusBadge(effectiveUser.status) || {}).className || 'bg-slate-800 text-slate-300'
}`} }`}
> >
{getActivityStatusBadge(effectiveUser.status).label} {(getActivityStatusBadge(effectiveUser.status) || {}).label || 'Active'}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -336,8 +365,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button <button
type="button" type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)} onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
disabled={statusMutation.isPending} disabled={statusMutation.isPending || !canChangeStatus}
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" 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 disabled:opacity-50 cursor-pointer"
> >
Suspend User Suspend User
</button> </button>
@@ -345,8 +374,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button <button
type="button" type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Active)} onClick={() => statusMutation.mutate(ActivityStatus.Active)}
disabled={statusMutation.isPending} disabled={statusMutation.isPending || !canChangeStatus}
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" 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 disabled:opacity-50 cursor-pointer"
> >
Activate User Activate User
</button> </button>
@@ -365,8 +394,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<select <select
value={selectedRoleToAdd} value={selectedRoleToAdd}
onChange={(e) => setSelectedRoleToAdd(e.target.value)} onChange={(e) => setSelectedRoleToAdd(e.target.value)}
disabled={loadingSystemRoles || availableRoles.length === 0} disabled={loadingSystemRoles || availableRoles.length === 0 || !canManageRolesAndClaims}
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" 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 disabled:opacity-50"
> >
<option value=""> <option value="">
{availableRoles.length === 0 ? 'All system roles assigned' : 'Select role to assign...'} {availableRoles.length === 0 ? 'All system roles assigned' : 'Select role to assign...'}
@@ -380,8 +409,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button <button
type="button" type="button"
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)} onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)}
disabled={!selectedRoleToAdd || assignRoleMutation.isPending} disabled={!selectedRoleToAdd || assignRoleMutation.isPending || !canManageRolesAndClaims}
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" 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 cursor-pointer"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
<span>Assign Role</span> <span>Assign Role</span>
@@ -392,29 +421,34 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80"> <div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
{loadingUserRoles ? ( {loadingUserRoles ? (
<div className="p-4 text-center text-slate-500">Loading roles...</div> <div className="p-4 text-center text-slate-500">Loading roles...</div>
) : userRoles.length === 0 ? ( ) : (Array.isArray(userRoles) ? userRoles : []).length === 0 ? (
<div className="p-4 text-center text-slate-500">No roles assigned to this user.</div> <div className="p-4 text-center text-slate-500">No roles assigned to this user.</div>
) : ( ) : (
userRoles.map((role) => ( (Array.isArray(userRoles) ? userRoles : []).map((role) => {
<div key={role.id} className="p-3 flex items-center justify-between"> if (!role) return null;
<div> const roleId = role.id || (role as any).Id || String(role);
<div className="text-white font-semibold flex items-center gap-1.5"> const roleName = role.name || (role as any).Name || String(role);
<Shield className="w-3.5 h-3.5 text-blue-400" /> return (
<span>{role.name}</span> <div key={roleId} 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>{roleName}</span>
</div>
<div className="text-[10px] text-slate-500 font-mono">Role ID: {roleId}</div>
</div> </div>
<div className="text-[10px] text-slate-500 font-mono">Role ID: {role.id}</div> <button
type="button"
onClick={() => removeRoleMutation.mutate(roleId)}
disabled={removeRoleMutation.isPending || !canManageRolesAndClaims}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition disabled:opacity-50 cursor-pointer"
title="Revoke Role"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</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>
</div> </div>
@@ -497,7 +531,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button <button
type="button" type="button"
onClick={() => addClaimMutation.mutate()} onClick={() => addClaimMutation.mutate()}
disabled={!newClaimType.trim() || !newClaimValue.trim() || addClaimMutation.isPending} disabled={!newClaimType.trim() || !newClaimValue.trim() || addClaimMutation.isPending || !canManageRolesAndClaims}
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" 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" /> <Plus className="w-3.5 h-3.5" />
@@ -510,34 +544,40 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80"> <div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
{loadingUserClaims ? ( {loadingUserClaims ? (
<div className="p-4 text-center text-slate-500">Loading user claims...</div> <div className="p-4 text-center text-slate-500">Loading user claims...</div>
) : userClaims.length === 0 ? ( ) : (Array.isArray(userClaims) ? userClaims : []).length === 0 ? (
<div className="p-4 text-center text-slate-500">No active claims found for user.</div> <div className="p-4 text-center text-slate-500">No active claims found for user.</div>
) : ( ) : (
userClaims.map((claim, idx) => ( (Array.isArray(userClaims) ? userClaims : []).map((claim, idx) => {
<div key={idx} className="p-3 flex items-center justify-between"> if (!claim) return null;
<div> const claimType = claim.claimType || claim.type || 'Custom Claim';
<div className="text-white font-medium flex items-center gap-1.5"> const claimVal = claim.claimValue ?? claim.value ?? '';
<Key className="w-3.5 h-3.5 text-amber-400" /> const claimId = claim.claimId || (claim as any).id || claimType || String(idx);
<span>{claim.type || 'Custom Claim'}</span> return (
</div> <div key={idx} className="p-3 flex items-center justify-between">
<div className="text-[11px] text-slate-300 font-mono mt-0.5"> <div>
Value: <span className="text-emerald-400">{claim.value}</span> <div className="text-white font-medium flex items-center gap-1.5">
{claim.issuer && <span className="text-slate-500 ml-2">({claim.issuer})</span>} <Key className="w-3.5 h-3.5 text-amber-400" />
<span>{claimType}</span>
</div>
<div className="text-[11px] text-slate-300 font-mono mt-0.5">
Value: <span className="text-emerald-400">{claimVal}</span>
{claim.issuer && <span className="text-slate-500 ml-2">({claim.issuer})</span>}
</div>
</div> </div>
{claimType && (
<button
type="button"
onClick={() => deleteClaimMutation.mutate(claimId)}
disabled={deleteClaimMutation.isPending || !canManageRolesAndClaims}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition disabled:opacity-50 cursor-pointer"
title="Delete Claim"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</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>
</div> </div>
@@ -26,8 +26,7 @@ import {
createTenant, createTenant,
updateTenant, updateTenant,
getTenantOrganisations, getTenantOrganisations,
getOrganisations, getTenantUsers,
getUsers,
} from '../services/api'; } from '../services/api';
import { Modal } from '../components/ui/Modal'; import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
@@ -36,7 +35,8 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils'; import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
import { ActivityStatusBadge } from '../components/ui/StatusBadge'; import { ActivityStatusBadge } from '../components/ui/StatusBadge';
import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types'; import { ObfuscatedIdNumber } from '../components/ui/ObfuscatedIdNumber';
import { Tenant, Organisation, TenantOrganisation, TenantUser, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
import { formatDate, getOrganisationTypeLabel, getUserEmail, registerUserEmails } from '../lib/formatters'; import { formatDate, getOrganisationTypeLabel, getUserEmail, registerUserEmails } from '../lib/formatters';
import { CreateUserModal } from '../components/users/CreateUserModal'; import { CreateUserModal } from '../components/users/CreateUserModal';
import { UserSecurityModal } from '../components/users/UserSecurityModal'; import { UserSecurityModal } from '../components/users/UserSecurityModal';
@@ -71,27 +71,21 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
staleTime: 30000, staleTime: 30000,
}); });
// Fetch all organisations directory (from governance cache) to resolve complete organisation details // Fetch tenant users directory to resolve linked user email per organisation
const { data: allOrganisations = [], isLoading: loadingAllOrgs } = useQuery<Organisation[]>({ const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
queryKey: ['organisations'], queryKey: ['tenant-users', tenantId],
queryFn: () => getOrganisations(), queryFn: () => getTenantUsers(tenantId),
staleTime: 60000,
});
// Fetch user directory to resolve linked user email per organisation
const { data: usersDirectory = [] } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
React.useEffect(() => { React.useEffect(() => {
if (usersDirectory && usersDirectory.length > 0) { if (tenantUsers && tenantUsers.length > 0) {
registerUserEmails(usersDirectory); const users = tenantUsers.map((tu) => tu.user).filter((u): u is User => !!u);
registerUserEmails(users);
} }
}, [usersDirectory]); }, [tenantUsers]);
const isLoading = loadingTenantOrgs || (loadingAllOrgs && tenantOrganisations.length > 0 && allOrganisations.length === 0); const isLoading = loadingTenantOrgs;
return ( return (
<div className="py-4 px-6 border-l-4 border-blue-500 bg-linear-to-r from-blue-950/20 via-slate-900/40 to-slate-950/60 space-y-3"> <div className="py-4 px-6 border-l-4 border-blue-500 bg-linear-to-r from-blue-950/20 via-slate-900/40 to-slate-950/60 space-y-3">
@@ -171,26 +165,16 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
</thead> </thead>
<tbody className="divide-y divide-slate-800/50"> <tbody className="divide-y divide-slate-800/50">
{tenantOrganisations.map((tOrg) => { {tenantOrganisations.map((tOrg) => {
const orgId = tOrg.organisationId || tOrg.organisation?.id || tOrg.id; const org = tOrg.organisation;
// Use the organisationId to fetch/match the organisation detail const orgId = org?.id || tOrg.organisationId || tOrg.id;
const detailedOrg = allOrganisations.find( const orgName = org?.name || 'Unnamed Organisation';
(o) => String(o.id) === String(orgId) || String(o.id) === String(tOrg.organisationId) const regNo = org?.registrationNo || '—';
); const orgType = org?.type ?? tOrg.status;
const org = detailedOrg || tOrg.organisation; const orgEmail = org?.email || '—';
const orgName = org?.name || tOrg.organisation?.name || 'Unnamed Organisation'; const linkedUserId = org?.userId;
const regNo = org?.registrationNo || tOrg.organisation?.registrationNo || '—';
const orgType = org?.type ?? tOrg.organisation?.type;
const orgEmail = org?.email || tOrg.organisation?.email || '—';
const linkedUserId =
org?.userId ||
(org as any)?.UserId ||
(org as any)?.organisationUser ||
(org as any)?.OrganisationUser ||
(tOrg.organisation as any)?.userId ||
(tOrg as any)?.userId;
const matchedUser = linkedUserId const matchedUser = linkedUserId
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(linkedUserId).toLowerCase()) ? tenantUsers.find((tu) => tu.user?.id && String(tu.user.id).toLowerCase() === String(linkedUserId).toLowerCase())?.user
: null; : null;
const userEmailDisplay = const userEmailDisplay =
@@ -199,8 +183,8 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
(linkedUserId && String(linkedUserId).includes('@') ? String(linkedUserId) : null) || (linkedUserId && String(linkedUserId).includes('@') ? String(linkedUserId) : null) ||
org?.email || org?.email ||
'—'; '—';
const status = org?.status ?? tOrg.organisation?.status ?? tOrg.status; const status = org?.status ?? tOrg.status;
const createdAt = org?.createdAt || tOrg.createdAt || tOrg.organisation?.createdAt; const createdAt = org?.createdAt || tOrg.createdAt;
return ( return (
<tr key={String(tOrg.id) + '-' + String(orgId)} className="hover:bg-slate-800/30 transition"> <tr key={String(tOrg.id) + '-' + String(orgId)} className="hover:bg-slate-800/30 transition">
@@ -274,21 +258,12 @@ const TenantUsersSubList: React.FC<{
onProvisionUser: (tenantId: number | string) => void; onProvisionUser: (tenantId: number | string) => void;
onManageUser: (user: User) => void; onManageUser: (user: User) => void;
}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => { }> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => {
const { data: usersDirectory = [], isLoading, refetch } = useQuery<User[]>({ const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId], queryKey: ['tenant-users', tenantId],
queryFn: () => getUsers(1, 200), queryFn: () => getTenantUsers(tenantId),
staleTime: 30000, staleTime: 30000,
}); });
const tenantUsers = useMemo(() => {
return usersDirectory.filter((u) => {
if (u.tenant_id !== undefined && u.tenant_id !== null) {
return String(u.tenant_id) === String(tenantId);
}
return true;
});
}, [usersDirectory, tenantId]);
const { canAdd } = useRbac(); const { canAdd } = useRbac();
const canProvision = canAdd('tenant_management') || canAdd('tenant_self_service') || canAdd('settings_users'); const canProvision = canAdd('tenant_management') || canAdd('tenant_self_service') || canAdd('settings_users');
@@ -356,24 +331,45 @@ const TenantUsersSubList: React.FC<{
<td colSpan={6} className="py-4 text-center text-slate-500">No users provisioned under Tenant #{tenantId}.</td> <td colSpan={6} className="py-4 text-center text-slate-500">No users provisioned under Tenant #{tenantId}.</td>
</tr> </tr>
) : ( ) : (
tenantUsers.map((u) => ( tenantUsers.map((tu) => {
<tr key={u.id} className="hover:bg-slate-800/30 transition"> const u = tu.user;
<td className="py-2 px-3 text-white font-medium">{u.email || '—'}</td> const userId = u?.id || tu.userId;
<td className="py-2 px-3 font-mono text-slate-400 text-[11px]">{u.id}</td> const email = u?.email || '—';
<td className="py-2 px-3 font-mono text-slate-300 text-[11px]">{u.idNumber || '—'}</td> const role = u?.role || 'Standard User';
<td className="py-2 px-3 text-slate-300">{u.role || 'Standard User'}</td> const idNumber = u?.idNumber;
<td className="py-2 px-3"><ActivityStatusBadge status={u.status} /></td> const status = u?.status ?? tu.status;
<td className="py-2 px-3 text-right">
<button return (
onClick={() => onManageUser(u)} <tr key={String(tu.id) + '-' + String(userId)} className="hover:bg-slate-800/30 transition">
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" <td className="py-2 px-3 text-white font-medium">{email}</td>
> <td className="py-2 px-3 font-mono text-slate-400 text-[11px]">{userId}</td>
<ShieldCheck className="w-3 h-3 text-blue-400" /> <td className="py-2 px-3 font-mono text-slate-300 text-[11px]">
Manage Access &amp; Roles <ObfuscatedIdNumber idNumber={idNumber} />
</button> </td>
</td> <td className="py-2 px-3 text-slate-300">{role}</td>
</tr> <td className="py-2 px-3"><ActivityStatusBadge status={status} /></td>
)) <td className="py-2 px-3 text-right">
<button
onClick={() => {
const userToManage: User = u || {
id: tu.userId,
email: null,
role: null,
idNumber: null,
status: tu.status,
lastLoginAt: null,
};
onManageUser(userToManage);
}}
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"
>
<ShieldCheck className="w-3 h-3 text-blue-400" />
Manage Access &amp; Roles
</button>
</td>
</tr>
);
})
)} )}
</tbody> </tbody>
</table> </table>
@@ -541,9 +537,24 @@ export const TenantManagementPage: React.FC = () => {
return 'Active'; return 'Active';
}; };
// Auto-expand active tenant for tenant self-service users
React.useEffect(() => {
if (!canManageTenants && currentTenantId) {
setExpandedTenants((prev) => ({
...prev,
[String(currentTenantId)]: true,
}));
}
}, [canManageTenants, currentTenantId]);
// Filter & Search // Filter & Search
const filteredTenants = useMemo(() => { const filteredTenants = useMemo(() => {
return tenants.filter((t) => { return tenants.filter((t) => {
// Non-admins (tenant self-service) only see their own tenant partition boundary
if (!canManageTenants && currentTenantId && String(t.id) !== String(currentTenantId)) {
return false;
}
const s = Number(t.status ?? ActivityStatus.Active); const s = Number(t.status ?? ActivityStatus.Active);
const matchesSearch = const matchesSearch =
!searchTerm.trim() || !searchTerm.trim() ||
@@ -559,16 +570,16 @@ export const TenantManagementPage: React.FC = () => {
return matchesSearch && matchesStatus; return matchesSearch && matchesStatus;
}); });
}, [tenants, searchTerm, statusFilter]); }, [tenants, searchTerm, statusFilter, canManageTenants, currentTenantId]);
// Sorting // Sorting
const { sortedData, sortColumn, sortDirection, handleSort } = useTableSort(filteredTenants, 'id', 'asc'); const { sortedData, sortColumn, sortDirection, handleSort } = useTableSort(filteredTenants, 'id', 'asc');
// Metrics // Metrics (scoped to visible partitions)
const totalCount = tenants.length; const totalCount = filteredTenants.length;
const activeCount = tenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length; const activeCount = filteredTenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length;
const inactiveCount = tenants.filter((t) => Number(t.status) === 2).length; const inactiveCount = filteredTenants.filter((t) => Number(t.status) === 2).length;
const disabledCount = tenants.filter((t) => Number(t.status) === 3).length; const disabledCount = filteredTenants.filter((t) => Number(t.status) === 3).length;
// Export columns (using integer ID only, no hashid) // Export columns (using integer ID only, no hashid)
const exportColumns: ExportColumn<Tenant>[] = [ const exportColumns: ExportColumn<Tenant>[] = [
@@ -65,6 +65,7 @@ import {
ActivityStatus, ActivityStatus,
Tenant, Tenant,
TenantOrganisation, TenantOrganisation,
TenantUser,
CreateTenantRequest, CreateTenantRequest,
UpdateTenantRequest, UpdateTenantRequest,
Beneficiary, Beneficiary,
@@ -1632,6 +1633,26 @@ export const getTenantOrganisations = async (tenantId: string | number): Promise
} }
}; };
export const getTenantUsers = async (tenantId: string | number): Promise<TenantUser[]> => {
const hashedTenantId = await toolsService.encodeId(tenantId);
try {
const res = await apiClient.get<TenantUser[]>(
`/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/users`
);
return res.data || [];
} catch (err: any) {
if (String(tenantId) !== String(hashedTenantId)) {
try {
const fallbackRes = await apiClient.get<TenantUser[]>(
`/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/users`
);
return fallbackRes.data || [];
} catch {}
}
throw err;
}
};
// ========================================== // ==========================================
// 10. Projects: Beneficiaries & Outcomes APIs // 10. Projects: Beneficiaries & Outcomes APIs
// ========================================== // ==========================================
+12
View File
@@ -400,6 +400,18 @@ export interface TenantOrganisation {
creator?: User | null; creator?: User | null;
} }
export interface TenantUser {
id: number | string;
tenantId: number | string;
userId: string;
status: ActivityStatus;
createdAt: string;
createdBy: string;
tenant?: Tenant | null;
user?: User | null;
creator?: User | null;
}
export interface Portfolio { export interface Portfolio {
id: number | string; id: number | string;
tenantId?: number | string; tenantId?: number | string;
@@ -1,11 +1,11 @@
namespace PostFundManagement.Domain.Entities; namespace PostFundManagement.Domain.Entities;
[EntityTypeConfiguration<TenantOrganisationConfiguration, TenantOrganisation>] [EntityTypeConfiguration<TenantOrganisationConfiguration, TenantOrganisation>]
public class TenantOrganisation : Models.TenantOrganisation public class TenantOrganisation : Models.TenantOrganisation
{ {
public virtual Tenant? Tenant { get; set; } public virtual Tenant? Tenant { get; set; }
public virtual Organisation? Organisation { get; set; } public new virtual Organisation? Organisation { get; set; }
public virtual User? Creator { get; set; } public virtual User? Creator { get; set; }
} }
@@ -1,4 +1,4 @@
namespace PostFundManagement.Domain.Entities; namespace PostFundManagement.Domain.Entities;
[EntityTypeConfiguration<TenantUserConfiguration, TenantUser>] [EntityTypeConfiguration<TenantUserConfiguration, TenantUser>]
public class TenantUser : Models.TenantUser public class TenantUser : Models.TenantUser
@@ -9,7 +9,7 @@ public class TenantUser : Models.TenantUser
public virtual User? Creator { get; set; } public virtual User? Creator { get; set; }
[ForeignKey(nameof(UserId))] [ForeignKey(nameof(UserId))]
public virtual User? User { get; set; } public new virtual User? User { get; set; }
} }
public sealed class TenantUserConfiguration : IEntityTypeConfiguration<TenantUser> public sealed class TenantUserConfiguration : IEntityTypeConfiguration<TenantUser>
@@ -11,7 +11,8 @@ public static class Mappers
CreatedAt = entity.CreatedAt, CreatedAt = entity.CreatedAt,
CreatedBy = entity.CreatedBy, CreatedBy = entity.CreatedBy,
Status = entity.Status, Status = entity.Status,
UserId = entity.UserId UserId = entity.UserId,
User = entity.User?.Map()
}; };
public static TenantOrganisation Map(this Entities.TenantOrganisation entity) => new() public static TenantOrganisation Map(this Entities.TenantOrganisation entity) => new()
@@ -21,7 +22,8 @@ public static class Mappers
CreatedAt = entity.CreatedAt, CreatedAt = entity.CreatedAt,
CreatedBy = entity.CreatedBy, CreatedBy = entity.CreatedBy,
OrganisationId = entity.OrganisationId, OrganisationId = entity.OrganisationId,
TenantId = entity.TenantId TenantId = entity.TenantId,
Organisation = entity.Organisation?.Map()
}; };
public static Tenant Map(this Entities.Tenant entity) => new() public static Tenant Map(this Entities.Tenant entity) => new()
@@ -1,4 +1,4 @@
namespace PostFundManagement.Domain.Models; namespace PostFundManagement.Domain.Models;
public class TenantOrganisation public class TenantOrganisation
{ {
@@ -13,4 +13,6 @@ public class TenantOrganisation
public long OrganisationId { get; set; } public long OrganisationId { get; set; }
public ActivityStatus Status { get; set; } public ActivityStatus Status { get; set; }
public Organisation? Organisation { get; set; }
} }
@@ -1,4 +1,4 @@
namespace PostFundManagement.Domain.Models; namespace PostFundManagement.Domain.Models;
public class TenantUser public class TenantUser
{ {
@@ -13,4 +13,6 @@ public class TenantUser
public Guid UserId { get; set; } public Guid UserId { get; set; }
public ActivityStatus Status { get; set; } public ActivityStatus Status { get; set; }
public User? User { get; set; }
} }