Create Organisation Fix

This commit is contained in:
2026-10-20 12:47:06 +02:00
parent 28a821ae91
commit d7a168c9fa
17 changed files with 527 additions and 155 deletions
@@ -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",
@@ -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';
@@ -298,10 +299,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>
@@ -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>
)}
@@ -227,7 +227,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,
});
+2 -2
View File
@@ -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;
+17
View File
@@ -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"