Fixed tenant management
This commit is contained in:
@@ -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>[] = [
|
||||
|
||||
Reference in New Issue
Block a user