Compare commits
3
Commits
1142cfd94e
...
dee9098379
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dee9098379 | ||
|
|
d7a168c9fa | ||
|
|
28a821ae91 |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ public class GetUsersEndpoint : IEndpoint
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Users)
|
||||
.CacheOutput();
|
||||
.WithTags(EndpointTags.Users);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -288,6 +288,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
||||
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) =>
|
||||
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.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
@@ -208,9 +208,9 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
linkedUser!.IdNumber = request.IdNumber.Clean();
|
||||
}
|
||||
|
||||
var userUpdated = await context.SaveChangesAsync(cancellationToken) > 0;
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess && userUpdated, UpdateError.Create("Failed to update organisation"));
|
||||
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess, UpdateError.Create("Failed to update organisation"));
|
||||
}));
|
||||
|
||||
public async Task<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
@@ -452,6 +452,26 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
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) =>
|
||||
await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
|
||||
namespace PostFundManagement.Application.Services.Governance;
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(LabelLength)] string RegistrationNo,
|
||||
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
|
||||
[Required][MaxLength(LabelLength)] string IdNumber,
|
||||
[MaxLength(LabelLength)] string? IdNumber,
|
||||
[Required] Guid OrganisationUser,
|
||||
[Required] OrganisationType Type,
|
||||
[Required] ActivityStatus Status);
|
||||
@@ -45,7 +45,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
|
||||
public sealed record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(LabelLength)] string RegistrationNo,
|
||||
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
|
||||
[Required][MaxLength(LabelLength)] string IdNumber,
|
||||
[MaxLength(LabelLength)] string? IdNumber,
|
||||
[Required] Guid OrganisationUser,
|
||||
[Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -151,30 +151,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
|
||||
}));
|
||||
|
||||
public async Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<User[]>>>)(async () =>
|
||||
{
|
||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
||||
return await service.GetUsersAsync(pagination, request, cancellationToken);
|
||||
|
||||
var cacheKey = HashService.ToMd5Hash($"getusers-{pagination.Page}-{pagination.PageSize}-" +
|
||||
$"{request.Email}-{request.Status}-{request.SortBy}-{request.SortDescending}").Value;
|
||||
|
||||
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
var usersResult = await service.GetUsersAsync(pagination, request, cancellationToken);
|
||||
|
||||
if (usersResult.IsFailed) return usersResult;
|
||||
|
||||
await cache.SetStringAsync(cacheKey, usersResult.Value.ToJson(),
|
||||
cacheOptions, cancellationToken);
|
||||
|
||||
return usersResult;
|
||||
}
|
||||
|
||||
return Result.Ok(cached.FromJson<User[]>());
|
||||
}));
|
||||
await service.GetUsersAsync(pagination, request, cancellationToken);
|
||||
|
||||
public async Task<Result> LogoutUserAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.LogoutUserAsync(targetUserId, userId, cancellationToken);
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"postcss": "^8.5.28",
|
||||
|
||||
@@ -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() {
|
||||
<Route
|
||||
path="/tenants"
|
||||
element={
|
||||
<TenantAdminProtectedRoute>
|
||||
<TenantManagementProtectedRoute>
|
||||
<TenantManagementPage />
|
||||
</TenantAdminProtectedRoute>
|
||||
</TenantManagementProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/governance/tenants"
|
||||
element={
|
||||
<TenantAdminProtectedRoute>
|
||||
<TenantManagementProtectedRoute>
|
||||
<TenantManagementPage />
|
||||
</TenantAdminProtectedRoute>
|
||||
</TenantManagementProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||
<span className="text-slate-400">Tenant:</span>
|
||||
{canManageTenants ? (
|
||||
{canManageTenants || canView('tenant_management') || canView('tenant_self_service') ? (
|
||||
<Link
|
||||
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"
|
||||
|
||||
@@ -82,7 +82,7 @@ export function ExportButton<T>({
|
||||
columns,
|
||||
data,
|
||||
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
|
||||
userEmail: user?.profile?.email || (claims as any)?.email || '',
|
||||
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ export function ExportButton<T>({
|
||||
columns,
|
||||
data,
|
||||
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
|
||||
userEmail: user?.profile?.email || (claims as any)?.email || '',
|
||||
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ import React, { ReactNode, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { HelpTooltip } from './HelpTooltip';
|
||||
|
||||
interface ModalProps {
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
helpText?: ReactNode;
|
||||
children: ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full' | string;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
@@ -20,6 +21,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
helpText,
|
||||
children,
|
||||
maxWidth = 'lg',
|
||||
size,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -43,13 +45,20 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const maxWidthClass = {
|
||||
const effectiveSize = (size || maxWidth) as string;
|
||||
const maxWidthClassMap: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
}[maxWidth];
|
||||
'3xl': 'max-w-3xl',
|
||||
'4xl': 'max-w-4xl',
|
||||
'5xl': 'max-w-5xl',
|
||||
'6xl': 'max-w-6xl',
|
||||
full: 'max-w-full',
|
||||
};
|
||||
const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff, Copy, Check } from 'lucide-react';
|
||||
import { useToast } from './Toast';
|
||||
|
||||
export interface ObfuscatedIdNumberProps {
|
||||
idNumber?: string | null;
|
||||
className?: string;
|
||||
allowToggle?: boolean;
|
||||
}
|
||||
|
||||
export function maskIdNumber(id: string): string {
|
||||
const clean = id ? id.trim() : '';
|
||||
if (clean.length <= 4) {
|
||||
return '••••';
|
||||
}
|
||||
if (clean.length <= 8) {
|
||||
return `${clean.slice(0, 2)}••••${clean.slice(-2)}`;
|
||||
}
|
||||
// SA ID (13 digits) or standard national IDs: keep first 6 (DOB), mask middle 5, show last 2
|
||||
return `${clean.slice(0, 6)}•••••${clean.slice(-2)}`;
|
||||
}
|
||||
|
||||
export function ObfuscatedIdNumber({
|
||||
idNumber,
|
||||
className = '',
|
||||
allowToggle = true,
|
||||
}: ObfuscatedIdNumberProps): React.ReactElement {
|
||||
const [isRevealed, setIsRevealed] = useState<boolean>(false);
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const { success } = useToast();
|
||||
|
||||
if (!idNumber) {
|
||||
return <span className="text-slate-600 font-mono text-xs">-</span>;
|
||||
}
|
||||
|
||||
const rawId: string = idNumber;
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawId);
|
||||
setCopied(true);
|
||||
success('Copied to Clipboard', 'ID Number copied.');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard fallback
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
setIsRevealed((prev) => !prev);
|
||||
};
|
||||
|
||||
const displayedText = isRevealed ? rawId : maskIdNumber(rawId);
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 font-mono text-xs text-slate-300 ${className}`}>
|
||||
<span className="tracking-wider select-all">{displayedText}</span>
|
||||
|
||||
{allowToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
title={isRevealed ? 'Hide ID Number' : 'Reveal ID Number'}
|
||||
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
>
|
||||
{isRevealed ? <EyeOff className="w-3.5 h-3.5 text-blue-400" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title="Copy ID Number"
|
||||
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { createUser, getSystemRoles, assignUserRole, getTenants } from '../../services/api';
|
||||
import { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import { CreateUserRequest, Role, Tenant } from '../../types';
|
||||
@@ -136,8 +136,22 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
|
||||
await createUser(values.tenantId, values.idNumber.trim(), payload);
|
||||
|
||||
// If an initial role was chosen and we have a newly generated user,
|
||||
// invalidate query cache to refresh directory
|
||||
if (values.initialRoleId) {
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
|
||||
const createdUser = freshUsers.find(
|
||||
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
|
||||
);
|
||||
if (createdUser) {
|
||||
await assignUserRole(createdUser.id, values.initialRoleId);
|
||||
}
|
||||
} catch {
|
||||
// Graceful fallback
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
},
|
||||
onSuccess: (values) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getSystemClaims,
|
||||
} from '../../services/api';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||
@@ -53,9 +54,23 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
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');
|
||||
|
||||
@@ -104,8 +119,11 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
// 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);
|
||||
@@ -191,11 +209,16 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
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;
|
||||
@@ -203,21 +226,28 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
|
||||
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')) {
|
||||
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]);
|
||||
|
||||
@@ -298,10 +328,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<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>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<ObfuscatedIdNumber idNumber={effectiveUser.idNumber} />
|
||||
{decodedId && decodedId !== effectiveUser.idNumber && (
|
||||
<span className="text-[10px] text-blue-400 font-mono">(Decoded: {decodedId})</span>
|
||||
<div className="flex items-center gap-1 text-[10px] text-blue-400 font-mono">
|
||||
<span>(Decoded:</span>
|
||||
<ObfuscatedIdNumber idNumber={decodedId} />
|
||||
<span>)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,10 +354,10 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
</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) || {}).className || 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{getActivityStatusBadge(effectiveUser.status).label}
|
||||
{(getActivityStatusBadge(effectiveUser.status) || {}).label || 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -331,8 +365,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<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"
|
||||
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 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Suspend User
|
||||
</button>
|
||||
@@ -340,8 +374,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<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"
|
||||
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 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Activate User
|
||||
</button>
|
||||
@@ -360,8 +394,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<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"
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{availableRoles.length === 0 ? 'All system roles assigned' : 'Select role to assign...'}
|
||||
@@ -375,8 +409,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<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"
|
||||
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 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Assign Role</span>
|
||||
@@ -387,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">
|
||||
{loadingUserRoles ? (
|
||||
<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>
|
||||
) : (
|
||||
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>
|
||||
(Array.isArray(userRoles) ? userRoles : []).map((role) => {
|
||||
if (!role) return null;
|
||||
const roleId = role.id || (role as any).Id || String(role);
|
||||
const roleName = role.name || (role as any).Name || String(role);
|
||||
return (
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -492,7 +531,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
@@ -505,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">
|
||||
{loadingUserClaims ? (
|
||||
<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>
|
||||
) : (
|
||||
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>}
|
||||
(Array.isArray(userClaims) ? userClaims : []).map((claim, idx) => {
|
||||
if (!claim) return null;
|
||||
const claimType = claim.claimType || claim.type || 'Custom Claim';
|
||||
const claimVal = claim.claimValue ?? claim.value ?? '';
|
||||
const claimId = claim.claimId || (claim as any).id || claimType || String(idx);
|
||||
return (
|
||||
<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>{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>
|
||||
{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>
|
||||
{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>
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
FileText,
|
||||
Scale,
|
||||
Calendar,
|
||||
UserPlus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getOrganisations,
|
||||
@@ -50,6 +55,10 @@ import {
|
||||
getProgrammeValue,
|
||||
getUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
assignUserRole,
|
||||
getSystemRoles,
|
||||
getCurrentUserTenantId,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -72,6 +81,8 @@ import {
|
||||
OrganisationType,
|
||||
ActivityStatus,
|
||||
ApprovalStatus,
|
||||
Role,
|
||||
CreateUserRequest,
|
||||
CreateOrganisationRequest,
|
||||
CreatePortfolioRequest,
|
||||
CreateProgrammeRequest,
|
||||
@@ -96,8 +107,7 @@ const organisationSchema = z.object({
|
||||
name: z.string().min(3, 'Name must be at least 3 characters'),
|
||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||
email: z.string().email('Enter a valid email address'),
|
||||
idNumber: z.string().min(4, 'Government issued ID number is required'),
|
||||
organisationUser: z.string().min(1, 'Please select a linked user'),
|
||||
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
|
||||
type: z.coerce.number().min(1).max(27),
|
||||
status: z.coerce.number().optional(),
|
||||
});
|
||||
@@ -232,6 +242,107 @@ export const GovernancePage: React.FC = () => {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: systemRoles = [] } = useQuery<Role[]>({
|
||||
queryKey: ['system-roles'],
|
||||
queryFn: getSystemRoles,
|
||||
enabled: isOrgModalOpen,
|
||||
});
|
||||
|
||||
const assignableRolesForOrg = React.useMemo(() => {
|
||||
return systemRoles.filter(
|
||||
(r) => r.name.toLowerCase() !== 'admin' && r.id !== '1'
|
||||
);
|
||||
}, [systemRoles]);
|
||||
|
||||
const executiveAndManagerUsers = React.useMemo(() => {
|
||||
return usersDirectory.filter((u) => {
|
||||
const role = String(u.role || '').toLowerCase();
|
||||
return role === 'executive' || role === 'manager';
|
||||
});
|
||||
}, [usersDirectory]);
|
||||
|
||||
// Quick Add Executive User Subform state
|
||||
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
|
||||
const [quickUserEmail, setQuickUserEmail] = useState('');
|
||||
const [quickUserName, setQuickUserName] = useState('');
|
||||
const [quickUserPhone, setQuickUserPhone] = useState('');
|
||||
const [quickUserIdNumber, setQuickUserIdNumber] = useState('');
|
||||
const [quickUserRoleId, setQuickUserRoleId] = useState('');
|
||||
const [isSubmittingQuickUser, setIsSubmittingQuickUser] = useState(false);
|
||||
const [quickUserError, setQuickUserError] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
|
||||
const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive');
|
||||
if (execRole) setQuickUserRoleId(execRole.id);
|
||||
else setQuickUserRoleId(assignableRolesForOrg[0].id);
|
||||
}
|
||||
}, [assignableRolesForOrg, quickUserRoleId]);
|
||||
|
||||
const handleQuickAddUser = async () => {
|
||||
setQuickUserError(null);
|
||||
if (!quickUserEmail || !quickUserEmail.includes('@')) {
|
||||
setQuickUserError('Please provide a valid email address.');
|
||||
return;
|
||||
}
|
||||
if (!quickUserIdNumber || quickUserIdNumber.trim().length < 4) {
|
||||
setQuickUserError('Government issued ID number is required (min 4 characters).');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingQuickUser(true);
|
||||
try {
|
||||
const tenantId = getCurrentUserTenantId() || 1;
|
||||
const payload: CreateUserRequest = {
|
||||
email: quickUserEmail.trim().toLowerCase(),
|
||||
userName: quickUserName.trim() || quickUserEmail.split('@')[0],
|
||||
emailConfirmed: true,
|
||||
phoneNumber: quickUserPhone.trim() || null,
|
||||
phoneNumberConfirmed: false,
|
||||
LockoutEnabled: false,
|
||||
twoFactorEnabled: false,
|
||||
accessFailedCount: 0,
|
||||
};
|
||||
|
||||
await createUser(tenantId, quickUserIdNumber.trim(), payload);
|
||||
|
||||
// Invalidate queries and fetch fresh user list immediately
|
||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
const freshUsers = await getUsers(1, 200);
|
||||
|
||||
const createdUser = freshUsers.find(
|
||||
(u) => u.email && u.email.toLowerCase() === quickUserEmail.trim().toLowerCase()
|
||||
);
|
||||
|
||||
if (createdUser && quickUserRoleId) {
|
||||
try {
|
||||
await assignUserRole(createdUser.id, quickUserRoleId);
|
||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
} catch {
|
||||
// Handled gracefully
|
||||
}
|
||||
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
||||
} else if (createdUser) {
|
||||
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
||||
}
|
||||
|
||||
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
|
||||
setQuickUserEmail('');
|
||||
setQuickUserName('');
|
||||
setQuickUserPhone('');
|
||||
setQuickUserIdNumber('');
|
||||
setIsQuickAddUserOpen(false);
|
||||
} catch (err: any) {
|
||||
const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.';
|
||||
setQuickUserError(errorDetail);
|
||||
toastError('Failed to create user', err);
|
||||
} finally {
|
||||
setIsSubmittingQuickUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
data: organisations = [],
|
||||
isLoading: loadingOrgs,
|
||||
@@ -427,7 +538,6 @@ export const GovernancePage: React.FC = () => {
|
||||
name: '',
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
idNumber: '',
|
||||
organisationUser: '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
@@ -463,7 +573,6 @@ export const GovernancePage: React.FC = () => {
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber,
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
||||
@@ -473,7 +582,6 @@ export const GovernancePage: React.FC = () => {
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber,
|
||||
organisationUser: data.organisationUser,
|
||||
type: Number(data.type),
|
||||
});
|
||||
@@ -553,24 +661,23 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
const openCreateOrg = () => {
|
||||
setEditingOrg(null);
|
||||
const defaultUserId = usersDirectory[0]?.id || sub || currentUserId || '';
|
||||
const defaultUser = usersDirectory.find((u) => u.id === defaultUserId);
|
||||
const defaultUser = executiveAndManagerUsers[0];
|
||||
orgForm.reset({
|
||||
name: '',
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
idNumber: defaultUser?.idNumber || '',
|
||||
organisationUser: defaultUserId,
|
||||
organisationUser: defaultUser?.id || '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
});
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
setIsOrgModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditOrg = (org: Organisation) => {
|
||||
setEditingOrg(org);
|
||||
|
||||
// 1. Resolve target linked user ID synchronously from the organisation model
|
||||
const targetUserId =
|
||||
org.userId ||
|
||||
(org as any).UserId ||
|
||||
@@ -579,46 +686,24 @@ export const GovernancePage: React.FC = () => {
|
||||
org.createdBy ||
|
||||
'';
|
||||
|
||||
// 2. Fast O(1) in-memory match from cached users directory
|
||||
const matchedUser = targetUserId
|
||||
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
|
||||
: null;
|
||||
|
||||
// 3. Resolve idNumber: prefer matchedUser's idNumber, or org's idNumber if any, otherwise blank ''
|
||||
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || (org as any).IdNumber || '';
|
||||
const selectedUserId = matchedUser?.id || targetUserId || '';
|
||||
|
||||
// 4. Perform assignment immediately to form state
|
||||
orgForm.reset({
|
||||
name: org.name || '',
|
||||
registrationNo: org.registrationNo || '',
|
||||
email: org.email || '',
|
||||
idNumber: resolvedIdNumber,
|
||||
organisationUser: selectedUserId,
|
||||
type: org.type || OrganisationType.Enterprise,
|
||||
status: org.status ?? ActivityStatus.Active,
|
||||
});
|
||||
|
||||
// 5. Instantly open modal with fully populated data (no lag, no progressive pop-in)
|
||||
setIsQuickAddUserOpen(false);
|
||||
setQuickUserError(null);
|
||||
setIsOrgModalOpen(true);
|
||||
|
||||
// 6. Non-blocking background sync if target user wasn't in cache or org has missing data
|
||||
if (targetUserId && !matchedUser) {
|
||||
getUser(targetUserId).then((fetchedUser) => {
|
||||
if (fetchedUser) {
|
||||
queryClient.setQueryData<User[]>(['users-directory'], (old = []) => {
|
||||
if (old.some((u) => u.id === fetchedUser.id)) return old;
|
||||
return [fetchedUser, ...old];
|
||||
});
|
||||
if (fetchedUser.idNumber && !orgForm.getValues('idNumber')) {
|
||||
orgForm.setValue('idNumber', fetchedUser.idNumber, { shouldValidate: true });
|
||||
}
|
||||
if (!orgForm.getValues('organisationUser')) {
|
||||
orgForm.setValue('organisationUser', fetchedUser.id, { shouldValidate: true });
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const openCreatePortfolio = () => {
|
||||
@@ -1477,66 +1562,6 @@ export const GovernancePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Linked User Account *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Designated Organisation User"
|
||||
content="Select the designated user account associated with this organisation profile."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
{...orgForm.register('organisationUser', {
|
||||
onChange: (e) => {
|
||||
const selectedId = e.target.value;
|
||||
const selected = usersDirectory.find((u) => u.id === selectedId);
|
||||
// Populate selected user's ID number or blank '' if deselected or missing
|
||||
orgForm.setValue('idNumber', selected?.idNumber || '', { shouldValidate: true });
|
||||
},
|
||||
})}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
<option value="">-- Select linked user account --</option>
|
||||
{usersDirectory.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email || u.id} {u.role ? `(${u.role})` : ''} {u.idNumber ? `• ID: ${u.idNumber}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{orgForm.formState.errors.organisationUser && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.organisationUser.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Government Issued ID Number *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Government Issued ID"
|
||||
content="National identification document number or statutory passport ID of the linked user."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
{...orgForm.register('idNumber')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
|
||||
placeholder="e.g. 8401015800085 or Passport Number"
|
||||
/>
|
||||
{orgForm.formState.errors.idNumber && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
@@ -1561,6 +1586,164 @@ export const GovernancePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-xs font-semibold text-slate-300">
|
||||
Linked Executive / Manager User *
|
||||
</label>
|
||||
<HelpTooltip
|
||||
title="Designated Organisation User"
|
||||
content="Only users with an Executive or Manager role can be assigned to an organisation."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
{!editingOrg && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsQuickAddUserOpen((prev) => !prev);
|
||||
setQuickUserError(null);
|
||||
}}
|
||||
className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 cursor-pointer transition-colors"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{isQuickAddUserOpen ? 'Cancel' : '+ New Executive'}
|
||||
{isQuickAddUserOpen ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
{...orgForm.register('organisationUser')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
<option value="">-- Select linked executive or manager account --</option>
|
||||
{executiveAndManagerUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email || u.id} ({u.role || 'Executive'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{orgForm.formState.errors.organisationUser && (
|
||||
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.organisationUser.message}</p>
|
||||
)}
|
||||
{executiveAndManagerUsers.length === 0 && !isQuickAddUserOpen && (
|
||||
<p className="text-[11px] text-amber-400 mt-1">
|
||||
No Executive or Manager user found. Create one below to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline Collapsible Subform to Create Executive User */}
|
||||
{isQuickAddUserOpen && !editingOrg && (
|
||||
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-blue-500/20 pb-2">
|
||||
<span className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
|
||||
<UserPlus className="w-4 h-4 text-blue-400" />
|
||||
Quick Create Executive / Manager User
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">Mapped to tenant automatically</span>
|
||||
</div>
|
||||
|
||||
{quickUserError && (
|
||||
<div className="p-2 rounded bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[11px]">
|
||||
{quickUserError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={quickUserEmail}
|
||||
onChange={(e) => setQuickUserEmail(e.target.value)}
|
||||
placeholder="executive@organisation.org.za"
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Full / User Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={quickUserName}
|
||||
onChange={(e) => setQuickUserName(e.target.value)}
|
||||
placeholder="e.g. Sipho Ndlovu"
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Phone Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={quickUserPhone}
|
||||
onChange={(e) => setQuickUserPhone(e.target.value)}
|
||||
placeholder="+27 82 123 4567"
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">Government Issued ID *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={quickUserIdNumber}
|
||||
onChange={(e) => setQuickUserIdNumber(e.target.value)}
|
||||
placeholder="13-digit ID or Passport"
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white font-mono focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-[11px] font-medium text-slate-300 mb-1">System Role *</label>
|
||||
<select
|
||||
value={quickUserRoleId}
|
||||
onChange={(e) => setQuickUserRoleId(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{assignableRolesForOrg.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsQuickAddUserOpen(false)}
|
||||
className="px-2.5 py-1 text-xs text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickAddUser}
|
||||
disabled={isSubmittingQuickUser}
|
||||
className="px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors cursor-pointer flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{isSubmittingQuickUser ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Create & Select User
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingOrg && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
@@ -1586,7 +1769,6 @@ export const GovernancePage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||
<button
|
||||
|
||||
@@ -35,11 +35,13 @@ import {
|
||||
Hash,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
User as UserIcon,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { CreateUserModal } from '../components/users/CreateUserModal';
|
||||
import { UserSecurityModal } from '../components/users/UserSecurityModal';
|
||||
import { ObfuscatedIdNumber } from '../components/ui/ObfuscatedIdNumber';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import {
|
||||
getUsers,
|
||||
@@ -127,6 +129,13 @@ export const SettingsPage: React.FC = () => {
|
||||
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
|
||||
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
|
||||
|
||||
const [userPage, setUserPage] = useState(1);
|
||||
const [userPageSize, setUserPageSize] = useState(10);
|
||||
|
||||
React.useEffect(() => {
|
||||
setUserPage(1);
|
||||
}, [userEmailSearch, userStatusFilter, userRoleFilter]);
|
||||
|
||||
const userFilter: UserFilterRequest = {
|
||||
email: userEmailSearch.trim() || undefined,
|
||||
status: userStatusFilter !== 'all' ? userStatusFilter : undefined,
|
||||
@@ -145,7 +154,7 @@ export const SettingsPage: React.FC = () => {
|
||||
refetch: refetchUsers,
|
||||
} = useQuery({
|
||||
queryKey: ['users', userStatusFilter, userEmailSearch],
|
||||
queryFn: () => getUsers(1, 100, userFilter),
|
||||
queryFn: () => getUsers(1, 1000, userFilter),
|
||||
});
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
@@ -489,6 +498,13 @@ export const SettingsPage: React.FC = () => {
|
||||
role: (u: User) => getUserRoleBadge(u.role).label,
|
||||
});
|
||||
|
||||
const totalUsers = sortedUsers.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalUsers / userPageSize));
|
||||
const paginatedUsers = React.useMemo(() => {
|
||||
const start = (userPage - 1) * userPageSize;
|
||||
return sortedUsers.slice(start, start + userPageSize);
|
||||
}, [sortedUsers, userPage, userPageSize]);
|
||||
|
||||
const {
|
||||
sortedData: sortedInvites,
|
||||
sortColumn: inviteSortCol,
|
||||
@@ -842,7 +858,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedUsers.map((u) => {
|
||||
paginatedUsers.map((u) => {
|
||||
const badge = getActivityStatusBadge(u.status);
|
||||
return (
|
||||
<tr key={u.id} className="hover:bg-slate-800/40 transition-colors">
|
||||
@@ -853,7 +869,7 @@ export const SettingsPage: React.FC = () => {
|
||||
{u.id}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 font-mono text-slate-300 text-[11px]">
|
||||
{u.idNumber || <span className="text-slate-600">—</span>}
|
||||
<ObfuscatedIdNumber idNumber={u.idNumber} />
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<UserRoleBadge role={u.role} />
|
||||
@@ -915,6 +931,64 @@ export const SettingsPage: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalUsers > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3 border-t border-slate-800/80 bg-slate-900/40 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
Showing <strong className="text-white font-mono">{Math.min((userPage - 1) * userPageSize + 1, totalUsers)}</strong> to{' '}
|
||||
<strong className="text-white font-mono">{Math.min(userPage * userPageSize, totalUsers)}</strong> of{' '}
|
||||
<strong className="text-white font-mono">{totalUsers}</strong> users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-400">Per page:</span>
|
||||
<select
|
||||
value={userPageSize}
|
||||
onChange={(e) => {
|
||||
setUserPageSize(Number(e.target.value));
|
||||
setUserPage(1);
|
||||
}}
|
||||
className="bg-slate-900 border border-slate-700/80 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserPage((p) => Math.max(1, p - 1))}
|
||||
disabled={userPage <= 1}
|
||||
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="px-2 font-mono text-slate-300">
|
||||
{userPage} / {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={userPage >= totalPages}
|
||||
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
createTenant,
|
||||
updateTenant,
|
||||
getTenantOrganisations,
|
||||
getOrganisations,
|
||||
getUsers,
|
||||
getTenantUsers,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
@@ -36,7 +35,8 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
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 { CreateUserModal } from '../components/users/CreateUserModal';
|
||||
import { UserSecurityModal } from '../components/users/UserSecurityModal';
|
||||
@@ -71,27 +71,21 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Fetch all organisations directory (from governance cache) to resolve complete organisation details
|
||||
const { data: allOrganisations = [], isLoading: loadingAllOrgs } = useQuery<Organisation[]>({
|
||||
queryKey: ['organisations'],
|
||||
queryFn: () => getOrganisations(),
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
// Fetch user directory to resolve linked user email per organisation
|
||||
const { data: usersDirectory = [] } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
// Fetch tenant users directory to resolve linked user email per organisation
|
||||
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getTenantUsers(tenantId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (usersDirectory && usersDirectory.length > 0) {
|
||||
registerUserEmails(usersDirectory);
|
||||
if (tenantUsers && tenantUsers.length > 0) {
|
||||
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 (
|
||||
<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>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{tenantOrganisations.map((tOrg) => {
|
||||
const orgId = tOrg.organisationId || tOrg.organisation?.id || tOrg.id;
|
||||
// Use the organisationId to fetch/match the organisation detail
|
||||
const detailedOrg = allOrganisations.find(
|
||||
(o) => String(o.id) === String(orgId) || String(o.id) === String(tOrg.organisationId)
|
||||
);
|
||||
const org = detailedOrg || tOrg.organisation;
|
||||
const orgName = org?.name || tOrg.organisation?.name || 'Unnamed Organisation';
|
||||
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 org = tOrg.organisation;
|
||||
const orgId = org?.id || tOrg.organisationId || tOrg.id;
|
||||
const orgName = org?.name || 'Unnamed Organisation';
|
||||
const regNo = org?.registrationNo || '—';
|
||||
const orgType = org?.type ?? tOrg.status;
|
||||
const orgEmail = org?.email || '—';
|
||||
const linkedUserId = org?.userId;
|
||||
|
||||
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;
|
||||
|
||||
const userEmailDisplay =
|
||||
@@ -199,8 +183,8 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
||||
(linkedUserId && String(linkedUserId).includes('@') ? String(linkedUserId) : null) ||
|
||||
org?.email ||
|
||||
'—';
|
||||
const status = org?.status ?? tOrg.organisation?.status ?? tOrg.status;
|
||||
const createdAt = org?.createdAt || tOrg.createdAt || tOrg.organisation?.createdAt;
|
||||
const status = org?.status ?? tOrg.status;
|
||||
const createdAt = org?.createdAt || tOrg.createdAt;
|
||||
|
||||
return (
|
||||
<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;
|
||||
onManageUser: (user: User) => void;
|
||||
}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => {
|
||||
const { data: usersDirectory = [], isLoading, refetch } = useQuery<User[]>({
|
||||
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
queryFn: () => getTenantUsers(tenantId),
|
||||
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 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>
|
||||
</tr>
|
||||
) : (
|
||||
tenantUsers.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-slate-800/30 transition">
|
||||
<td className="py-2 px-3 text-white font-medium">{u.email || '—'}</td>
|
||||
<td className="py-2 px-3 font-mono text-slate-400 text-[11px]">{u.id}</td>
|
||||
<td className="py-2 px-3 font-mono text-slate-300 text-[11px]">{u.idNumber || '—'}</td>
|
||||
<td className="py-2 px-3 text-slate-300">{u.role || 'Standard User'}</td>
|
||||
<td className="py-2 px-3"><ActivityStatusBadge status={u.status} /></td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<button
|
||||
onClick={() => onManageUser(u)}
|
||||
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 & Roles
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
tenantUsers.map((tu) => {
|
||||
const u = tu.user;
|
||||
const userId = u?.id || tu.userId;
|
||||
const email = u?.email || '—';
|
||||
const role = u?.role || 'Standard User';
|
||||
const idNumber = u?.idNumber;
|
||||
const status = u?.status ?? tu.status;
|
||||
|
||||
return (
|
||||
<tr key={String(tu.id) + '-' + String(userId)} className="hover:bg-slate-800/30 transition">
|
||||
<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>
|
||||
<td className="py-2 px-3 font-mono text-slate-300 text-[11px]">
|
||||
<ObfuscatedIdNumber idNumber={idNumber} />
|
||||
</td>
|
||||
<td className="py-2 px-3 text-slate-300">{role}</td>
|
||||
<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 & Roles
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -541,9 +537,24 @@ export const TenantManagementPage: React.FC = () => {
|
||||
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
|
||||
const filteredTenants = useMemo(() => {
|
||||
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 matchesSearch =
|
||||
!searchTerm.trim() ||
|
||||
@@ -559,16 +570,16 @@ export const TenantManagementPage: React.FC = () => {
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [tenants, searchTerm, statusFilter]);
|
||||
}, [tenants, searchTerm, statusFilter, canManageTenants, currentTenantId]);
|
||||
|
||||
// Sorting
|
||||
const { sortedData, sortColumn, sortDirection, handleSort } = useTableSort(filteredTenants, 'id', 'asc');
|
||||
|
||||
// Metrics
|
||||
const totalCount = tenants.length;
|
||||
const activeCount = tenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length;
|
||||
const inactiveCount = tenants.filter((t) => Number(t.status) === 2).length;
|
||||
const disabledCount = tenants.filter((t) => Number(t.status) === 3).length;
|
||||
// Metrics (scoped to visible partitions)
|
||||
const totalCount = filteredTenants.length;
|
||||
const activeCount = filteredTenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length;
|
||||
const inactiveCount = filteredTenants.filter((t) => Number(t.status) === 2).length;
|
||||
const disabledCount = filteredTenants.filter((t) => Number(t.status) === 3).length;
|
||||
|
||||
// Export columns (using integer ID only, no hashid)
|
||||
const exportColumns: ExportColumn<Tenant>[] = [
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
ActivityStatus,
|
||||
Tenant,
|
||||
TenantOrganisation,
|
||||
TenantUser,
|
||||
CreateTenantRequest,
|
||||
UpdateTenantRequest,
|
||||
Beneficiary,
|
||||
@@ -227,7 +228,7 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
|
||||
name: payload.name,
|
||||
registrationNo: payload.registrationNo,
|
||||
email: payload.email,
|
||||
idNumber: payload.idNumber,
|
||||
idNumber: payload.idNumber || null,
|
||||
organisationUser: payload.organisationUser,
|
||||
type: payload.type !== undefined ? Number(payload.type) : 5,
|
||||
});
|
||||
@@ -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
|
||||
// ==========================================
|
||||
|
||||
@@ -372,7 +372,7 @@ export interface CreateOrganisationRequest {
|
||||
name: string;
|
||||
registrationNo: string;
|
||||
email: string;
|
||||
idNumber: string;
|
||||
idNumber?: string | null;
|
||||
organisationUser: string;
|
||||
type: OrganisationType;
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export interface UpdateOrganisationRequest {
|
||||
name: string;
|
||||
registrationNo: string;
|
||||
email: string;
|
||||
idNumber: string;
|
||||
idNumber?: string | null;
|
||||
organisationUser: string;
|
||||
type: OrganisationType;
|
||||
status?: ActivityStatus;
|
||||
@@ -400,6 +400,18 @@ export interface TenantOrganisation {
|
||||
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 {
|
||||
id: number | string;
|
||||
tenantId?: number | string;
|
||||
|
||||
@@ -928,6 +928,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
|
||||
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
|
||||
|
||||
"@types/react-dom@^19.3.0":
|
||||
version "19.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.3.0.tgz#90294a9266efd928a5266f2110d60d5dba5015d2"
|
||||
integrity sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==
|
||||
|
||||
"@types/react@^19.3.0":
|
||||
version "19.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.3.0.tgz#3b3ce8d8c1627fb5ccd4dadd57ef445c5a153b77"
|
||||
integrity sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==
|
||||
dependencies:
|
||||
csstype "^3.2.2"
|
||||
|
||||
"@types/retry@0.12.0":
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
|
||||
@@ -1198,6 +1210,11 @@ css-line-break@^2.1.0:
|
||||
dependencies:
|
||||
utrie "^1.0.2"
|
||||
|
||||
csstype@^3.2.2:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
|
||||
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
|
||||
|
||||
data-uri-to-buffer@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace PostFundManagement.Domain.Entities;
|
||||
namespace PostFundManagement.Domain.Entities;
|
||||
|
||||
[EntityTypeConfiguration<TenantOrganisationConfiguration, TenantOrganisation>]
|
||||
public class TenantOrganisation : Models.TenantOrganisation
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace PostFundManagement.Domain.Entities;
|
||||
namespace PostFundManagement.Domain.Entities;
|
||||
|
||||
[EntityTypeConfiguration<TenantUserConfiguration, TenantUser>]
|
||||
public class TenantUser : Models.TenantUser
|
||||
@@ -9,7 +9,7 @@ public class TenantUser : Models.TenantUser
|
||||
public virtual User? Creator { get; set; }
|
||||
|
||||
[ForeignKey(nameof(UserId))]
|
||||
public virtual User? User { get; set; }
|
||||
public new virtual User? User { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TenantUserConfiguration : IEntityTypeConfiguration<TenantUser>
|
||||
|
||||
@@ -11,7 +11,8 @@ public static class Mappers
|
||||
CreatedAt = entity.CreatedAt,
|
||||
CreatedBy = entity.CreatedBy,
|
||||
Status = entity.Status,
|
||||
UserId = entity.UserId
|
||||
UserId = entity.UserId,
|
||||
User = entity.User?.Map()
|
||||
};
|
||||
|
||||
public static TenantOrganisation Map(this Entities.TenantOrganisation entity) => new()
|
||||
@@ -21,7 +22,8 @@ public static class Mappers
|
||||
CreatedAt = entity.CreatedAt,
|
||||
CreatedBy = entity.CreatedBy,
|
||||
OrganisationId = entity.OrganisationId,
|
||||
TenantId = entity.TenantId
|
||||
TenantId = entity.TenantId,
|
||||
Organisation = entity.Organisation?.Map()
|
||||
};
|
||||
|
||||
public static Tenant Map(this Entities.Tenant entity) => new()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace PostFundManagement.Domain.Models;
|
||||
namespace PostFundManagement.Domain.Models;
|
||||
|
||||
public class TenantOrganisation
|
||||
{
|
||||
@@ -13,4 +13,6 @@ public class TenantOrganisation
|
||||
public long OrganisationId { 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
|
||||
{
|
||||
@@ -13,4 +13,6 @@ public class TenantUser
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public ActivityStatus Status { get; set; }
|
||||
|
||||
public User? User { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
|
||||
namespace PostFundManagement.Infrastructure.Database.Services;
|
||||
|
||||
@@ -17,18 +17,18 @@ public sealed class TenantContextService : ITenantContextService
|
||||
public TenantContextService(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
|
||||
IsAdmin = user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true;
|
||||
ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true;
|
||||
|
||||
ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin;
|
||||
|
||||
HasTenantFilter = !IsAdmin;
|
||||
|
||||
var tenantIdClaimVal = user?.FindFirst("tenant_id")?.Value ?? user?.FindFirst("TenantId")?.Value;
|
||||
TenantId = long.TryParse(tenantIdClaimVal, out var id) ? id : 0L;
|
||||
|
||||
var tenantNameClaimVal = user?.FindFirst("tenant")?.Value ?? user?.FindFirst("Tenant")?.Value;
|
||||
TenantName = !string.IsNullOrWhiteSpace(tenantNameClaimVal) ? tenantNameClaimVal : null;
|
||||
|
||||
IsAdmin = (user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true) && TenantId == 1;
|
||||
ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true;
|
||||
|
||||
ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin;
|
||||
|
||||
HasTenantFilter = !IsAdmin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user