Refactor Layout and Sidebar components for improved responsiveness and permission handling. Update layout styles for better spacing and adjust Sidebar menu items to include permission checks for tenant users. Remove unused EditTenantModal and NewModuleModal components to streamline shared components.

This commit is contained in:
Yashwin 2026-01-30 18:16:19 +05:30
parent 0ed7bc5064
commit 55b0d9c8c1
35 changed files with 2990 additions and 2405 deletions

View File

@ -32,7 +32,7 @@ export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: Layou
<div className="absolute top-0 left-[-80px] w-full md:left-[-80px] md:w-[1440px] h-full bg-[#f6f9ff] z-0" />
{/* Content Wrapper */}
<div className="absolute inset-0 flex gap-0 md:gap-3 p-0 md:p-3 max-w-full md:max-w-[1280px] lg:max-w-none h-full mx-auto lg:mx-0 z-10">
<div className="absolute inset-0 flex gap-0 md:gap-2 lg:gap-3 p-0 md:p-2 lg:p-3 max-w-full md:max-w-full lg:max-w-[1280px] xl:max-w-none h-full mx-auto lg:mx-auto xl:mx-0 z-10">
{/* Mobile Overlay */}
{isSidebarOpen && (
<div
@ -46,12 +46,12 @@ export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: Layou
<Sidebar isOpen={isSidebarOpen} onClose={closeSidebar} />
{/* Main Content */}
<main className="flex-1 min-w-0 min-h-0 bg-white border-0 md:border border-[rgba(0,0,0,0.08)] rounded-none md:rounded-xl shadow-none md:shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] flex flex-col overflow-hidden w-full">
<main className="flex-1 min-w-0 min-h-0 max-w-full bg-white border-0 md:border border-[rgba(0,0,0,0.08)] rounded-none md:rounded-xl shadow-none md:shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] flex flex-col overflow-hidden">
{/* Top Header */}
<Header currentPage={currentPage} breadcrumbs={breadcrumbs} onMenuClick={toggleSidebar} />
{/* Main Content Area */}
<div className="flex-1 min-h-0 p-4 md:p-6 overflow-y-auto relative z-0">
<div className="flex-1 min-h-0 p-4 md:p-4 lg:p-6 overflow-y-auto relative z-0">
{/* Page Header */}
{pageHeader && (
<PageHeader

View File

@ -18,6 +18,10 @@ interface MenuItem {
icon: React.ComponentType<{ className?: string }>;
label: string;
path: string;
requiredPermission?: {
resource: string;
action?: string; // If not provided, checks for '*' or 'read'
};
}
interface SidebarProps {
@ -29,32 +33,32 @@ interface SidebarProps {
const superAdminPlatformMenu: MenuItem[] = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
{ icon: Building2, label: 'Tenants', path: '/tenants' },
{ icon: Users, label: 'User Management', path: '/users' },
{ icon: Shield, label: 'Roles', path: '/roles' },
// { icon: Users, label: 'User Management', path: '/users' },
// { icon: Shield, label: 'Roles', path: '/roles' },
{ icon: Package, label: 'Modules', path: '/modules' },
];
const superAdminSystemMenu: MenuItem[] = [
{ icon: FileText, label: 'Audit Logs', path: '/audit-logs' },
{ icon: Settings, label: 'Settings', path: '/settings' },
// { icon: Settings, label: 'Settings', path: '/settings' },
];
// Tenant Admin menu items
const tenantAdminPlatformMenu: MenuItem[] = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' },
{ icon: Shield, label: 'Roles', path: '/tenant/roles' },
{ icon: Users, label: 'Users', path: '/tenant/users' },
{ icon: Package, label: 'Modules', path: '/tenant/modules' },
{ icon: Shield, label: 'Roles', path: '/tenant/roles', requiredPermission: { resource: 'roles' } },
{ icon: Users, label: 'Users', path: '/tenant/users', requiredPermission: { resource: 'users' } },
{ icon: Package, label: 'TenantModules', path: '/tenant/modules', requiredPermission: { resource: 'tenants' } },
];
const tenantAdminSystemMenu: MenuItem[] = [
{ icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs' },
{ icon: Settings, label: 'Settings', path: '/tenant/settings' },
{ icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs', requiredPermission: { resource: 'audit_logs' } },
{ icon: Settings, label: 'Settings', path: '/tenant/settings', requiredPermission: { resource: 'tenants' } },
];
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const location = useLocation();
const { roles } = useAppSelector((state) => state.auth);
const { roles, permissions } = useAppSelector((state) => state.auth);
const { theme, logoUrl } = useAppSelector((state) => state.theme);
// Fetch theme for tenant admin
@ -79,12 +83,56 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
useTenantTheme();
}
// Select menu items based on role
const platformMenu = isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu;
const systemMenu = isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu;
// Helper function to check if user has permission for a resource
const hasPermission = (resource: string, requiredAction?: string): boolean => {
if (isSuperAdmin) {
return true; // Super admin has all permissions
}
const allowedActions = requiredAction ? [requiredAction] : ['*', 'read'];
return permissions.some((perm) => {
// Check if resource matches (exact match or wildcard)
const resourceMatches = perm.resource === resource || perm.resource === '*';
// Check if action matches (exact match or wildcard)
const actionMatches = allowedActions.some(
(allowedAction) => perm.action === allowedAction || perm.action === '*'
);
return resourceMatches && actionMatches;
});
};
// Filter menu items based on permissions for tenant users
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
if (isSuperAdmin) {
return items; // Show all items for super admin
}
return items.filter((item) => {
// If no required permission, always show (e.g., Dashboard, Modules, Settings)
if (!item.requiredPermission) {
return true;
}
return hasPermission(
item.requiredPermission.resource,
item.requiredPermission.action
);
});
};
// Select and filter menu items based on role and permissions
const platformMenu = filterMenuItems(
isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu
);
const systemMenu = filterMenuItems(
isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu
);
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
<div className="w-full md:w-[206px]">
<div className="w-full">
<div className="flex flex-col gap-1">
<div className="pb-1 px-3">
<div className="text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]">
@ -193,9 +241,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</aside>
{/* Desktop Sidebar */}
<aside className="hidden md:flex bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-[17px] w-[240px] h-full max-h-screen flex-col gap-6 shrink-0 overflow-hidden">
<aside className="hidden md:flex bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-3 md:p-3 lg:p-[17px] w-[220px] md:w-[220px] lg:w-[240px] h-full max-h-screen flex-col gap-4 md:gap-4 lg:gap-6 shrink-0 overflow-hidden">
{/* Logo */}
<div className="w-[206px] shrink-0">
<div className="w-full md:w-[190px] lg:w-[206px] shrink-0">
<div className="flex gap-3 items-center px-2">
{!isSuperAdmin && logoUrl ? (
<img
@ -225,13 +273,17 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</div>
{/* Platform Menu */}
<MenuSection title="Platform" items={platformMenu} />
{platformMenu.length > 0 && (
<MenuSection title="Platform" items={platformMenu} />
)}
{/* System Menu */}
<MenuSection title="System" items={systemMenu} />
{systemMenu.length > 0 && (
<MenuSection title="System" items={systemMenu} />
)}
{/* Support Center */}
<div className="mt-auto w-[206px]">
<div className="mt-auto w-full">
<button className="w-full bg-white border border-[rgba(0,0,0,0.08)] rounded-md px-[13px] py-[9px] flex gap-2.5 items-center hover:bg-gray-50 transition-colors">
<HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" />
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span>

View File

@ -7,6 +7,7 @@ import { Loader2, ChevronDown, ChevronRight } from 'lucide-react';
import { Modal, FormField, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
import type { Role, UpdateRoleRequest } from '@/types/role';
import { useAppSelector } from '@/hooks/redux-hooks';
import { moduleService } from '@/services/module-service';
// Utility function to generate code from name
const generateCodeFromName = (name: string): string => {
@ -68,7 +69,7 @@ const editRoleSchema = z.object({
"Code must be lowercase and use '_' for separation (e.g. abc_def)"
),
description: z.string().min(1, 'Description is required'),
module_ids: z.array(z.string().uuid()).optional().nullable(),
modules: z.array(z.string().uuid()).optional().nullable(),
permissions: z.array(z.object({
resource: z.string(),
action: z.string(),
@ -101,11 +102,11 @@ export const EditRoleModal = ({
const loadedRoleIdRef = useRef<string | null>(null);
const permissions = useAppSelector((state) => state.auth.permissions);
const roles = useAppSelector((state) => state.auth.roles);
const tenant = useAppSelector((state) => state.auth.tenant);
const tenantIdFromAuth = useAppSelector((state) => state.auth.tenantId);
const isSuperAdmin = roles.includes('super_admin');
const [selectedModules, setSelectedModules] = useState<string[]>([]);
const [selectedAvailableModules, setSelectedAvailableModules] = useState<string[]>([]);
const [selectedPermissions, setSelectedPermissions] = useState<Array<{ resource: string; action: string }>>([]);
const [initialModuleOptions, setInitialModuleOptions] = useState<Array<{ value: string; label: string }>>([]);
const [initialAvailableModuleOptions, setInitialAvailableModuleOptions] = useState<Array<{ value: string; label: string }>>([]);
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
const {
@ -120,7 +121,7 @@ export const EditRoleModal = ({
} = useForm<EditRoleFormData>({
resolver: zodResolver(editRoleSchema),
defaultValues: {
module_ids: [],
modules: [],
permissions: [],
},
});
@ -135,25 +136,18 @@ export const EditRoleModal = ({
}
}, [nameValue, setValue]);
// Load modules from tenant assignedModules
const loadModules = async (page: number, limit: number) => {
const assignedModules = tenant?.assignedModules || [];
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedModules = assignedModules.slice(startIndex, endIndex);
// Load available modules from /modules/available endpoint
// For super_admin, send tenant_id if defaultTenantId is provided
// For tenant users, send tenant_id from auth state
const loadAvailableModules = async (page: number, limit: number) => {
const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth;
const response = await moduleService.getAvailable(page, limit, tenantId);
return {
options: paginatedModules.map((module) => ({
options: response.data.map((module) => ({
value: module.id,
label: module.name,
})),
pagination: {
page,
limit,
total: assignedModules.length,
totalPages: Math.ceil(assignedModules.length / limit),
hasMore: endIndex < assignedModules.length,
},
pagination: response.pagination,
};
};
@ -248,6 +242,11 @@ export const EditRoleModal = ({
setValue('permissions', selectedPermissions.length > 0 ? selectedPermissions : []);
}, [selectedPermissions, setValue]);
// Update form value when available modules change
useEffect(() => {
setValue('modules', selectedAvailableModules.length > 0 ? selectedAvailableModules : []);
}, [selectedAvailableModules, setValue]);
// Expand resources that have selected permissions when role is loaded
useEffect(() => {
if (selectedPermissions.length > 0 && availableResourcesAndActions.size > 0) {
@ -292,34 +291,66 @@ export const EditRoleModal = ({
const role = await onLoadRole(roleId);
loadedRoleIdRef.current = roleId;
// Extract module_ids and permissions from role
const roleModuleIds = role.module_ids || [];
// Extract modules and permissions from role
const roleModules = role.modules || [];
const rolePermissions = role.permissions || [];
// Set modules if exists and user is not super_admin
if (roleModuleIds.length > 0 && !isSuperAdmin) {
setSelectedModules(roleModuleIds);
setValue('module_ids', roleModuleIds);
// Set available modules if exists
if (roleModules.length > 0) {
setSelectedAvailableModules(roleModules);
setValue('modules', roleModules);
// Load module names from tenant assignedModules
const assignedModules = tenant?.assignedModules || [];
const moduleOptions = roleModuleIds
.map((moduleId: string) => {
const module = assignedModules.find((m) => m.id === moduleId);
if (module) {
return {
value: moduleId,
label: module.name,
};
// Load module names from available modules API
// Use tenant_id from auth for tenant users, or defaultTenantId for super_admin
const loadModuleNames = async () => {
try {
const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth;
// Load first page of available modules to get module names
const availableModulesResponse = await moduleService.getAvailable(1, 100, tenantId);
// Map role modules to options from available modules
const moduleOptions = roleModules
.map((moduleId: string) => {
const module = availableModulesResponse.data.find((m) => m.id === moduleId);
if (module) {
return {
value: moduleId,
label: module.name,
};
}
return null;
})
.filter((opt) => opt !== null) as Array<{ value: string; label: string }>;
setInitialAvailableModuleOptions(moduleOptions);
} catch (err) {
console.warn('Failed to load available module names:', err);
// Fallback: try to load individual modules if available modules endpoint fails
try {
const moduleOptionsPromises = roleModules.map(async (moduleId: string) => {
try {
const moduleResponse = await moduleService.getById(moduleId);
return {
value: moduleId,
label: moduleResponse.data.name,
};
} catch {
return null;
}
});
const moduleOptions = (await Promise.all(moduleOptionsPromises)).filter(
(opt) => opt !== null
) as Array<{ value: string; label: string }>;
setInitialAvailableModuleOptions(moduleOptions);
} catch (fallbackErr) {
console.warn('Fallback loading also failed:', fallbackErr);
}
return null;
})
.filter((opt) => opt !== null) as Array<{ value: string; label: string }>;
setInitialModuleOptions(moduleOptions);
}
};
loadModuleNames();
} else {
// Clear modules if super_admin or no modules
setSelectedModules([]);
setInitialModuleOptions([]);
setSelectedAvailableModules([]);
setInitialAvailableModuleOptions([]);
}
// Set permissions (always set, even if empty array)
@ -333,8 +364,7 @@ export const EditRoleModal = ({
name: role.name,
code: role.code,
description: role.description || '',
// Only set module_ids if user is not super_admin
module_ids: isSuperAdmin ? [] : roleModuleIds,
modules: roleModules,
permissions: rolePermissions,
});
} catch (err: any) {
@ -348,20 +378,20 @@ export const EditRoleModal = ({
} else if (!isOpen) {
// Only reset when modal is closed
loadedRoleIdRef.current = null;
setSelectedModules([]);
setSelectedAvailableModules([]);
setSelectedPermissions([]);
setInitialModuleOptions([]);
setInitialAvailableModuleOptions([]);
reset({
name: '',
code: '',
description: '',
module_ids: [],
modules: [],
permissions: [],
});
setLoadError(null);
clearErrors();
}
}, [isOpen, roleId, onLoadRole, reset, clearErrors, setValue, tenant, isSuperAdmin]);
}, [isOpen, roleId, onLoadRole, reset, clearErrors, setValue, isSuperAdmin, defaultTenantId, tenantIdFromAuth]);
const handleFormSubmit = async (data: EditRoleFormData): Promise<void> => {
if (!roleId) return;
@ -370,10 +400,10 @@ export const EditRoleModal = ({
try {
const submitData = {
...data,
// Include tenant_id if defaultTenantId is provided
tenant_id: defaultTenantId || undefined,
// Only include module_ids if user is not super_admin
module_ids: isSuperAdmin ? undefined : (selectedModules.length > 0 ? selectedModules : undefined),
// For super_admin, always include tenant_id if defaultTenantId is provided
tenant_id: isSuperAdmin ? (defaultTenantId || undefined) : (defaultTenantId || undefined),
// Include modules from available modules endpoint
modules: selectedAvailableModules.length > 0 ? selectedAvailableModules : undefined,
permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined,
};
await onSubmit(roleId, submitData as UpdateRoleRequest);
@ -388,7 +418,7 @@ export const EditRoleModal = ({
detail.path === 'name' ||
detail.path === 'code' ||
detail.path === 'description' ||
detail.path === 'module_ids' ||
detail.path === 'modules' ||
detail.path === 'permissions'
) {
setError(detail.path as keyof EditRoleFormData, {
@ -465,7 +495,7 @@ export const EditRoleModal = ({
)}
{!isLoadingRole && (
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0 max-h-[70vh] overflow-y-auto">
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
{/* Role Name and Role Code Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<FormField
@ -496,21 +526,19 @@ export const EditRoleModal = ({
{...register('description')}
/>
{/* Module Selection - Only show if user is not super_admin */}
{!isSuperAdmin && (
<MultiselectPaginatedSelect
label="Modules"
placeholder="Select modules"
value={selectedModules}
onValueChange={(values) => {
setSelectedModules(values);
setValue('module_ids', values.length > 0 ? values : []);
}}
onLoadOptions={loadModules}
initialOptions={initialModuleOptions}
error={errors.module_ids?.message}
/>
)}
{/* Available Modules Selection */}
<MultiselectPaginatedSelect
label="Available Modules"
placeholder="Select available modules"
value={selectedAvailableModules}
onValueChange={(values) => {
setSelectedAvailableModules(values);
setValue('modules', values.length > 0 ? values : []);
}}
onLoadOptions={loadAvailableModules}
initialOptions={initialAvailableModuleOptions}
error={errors.modules?.message}
/>
{/* Permissions Section */}
<div className="pb-4">
@ -520,7 +548,7 @@ export const EditRoleModal = ({
{errors.permissions && (
<p className="text-sm text-[#ef4444] mb-2">{errors.permissions.message}</p>
)}
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4 max-h-96 overflow-y-auto">
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4">
{Array.from(availableResourcesAndActions.entries()).length === 0 ? (
<p className="text-sm text-[#6b7280]">No permissions available</p>
) : (

File diff suppressed because it is too large Load Diff

View File

@ -112,10 +112,10 @@ export const FormSelect = ({
<div className="flex flex-col gap-2 pb-4">
<label
htmlFor={fieldId}
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]"
>
<span>{label}</span>
{required && <span className="text-[#e02424] text-[8px]">*</span>}
{required && <span className="text-[#e02424]">*</span>}
</label>
<div className="relative" ref={dropdownRef}>
<button

View File

@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
import { Modal, FormField, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
import type { CreateRoleRequest } from '@/types/role';
import { useAppSelector } from '@/hooks/redux-hooks';
import { moduleService } from '@/services/module-service';
// Utility function to generate code from name
const generateCodeFromName = (name: string): string => {
@ -68,7 +69,7 @@ const newRoleSchema = z.object({
"Code must be lowercase and use '_' for separation (e.g. abc_def)"
),
description: z.string().min(1, 'Description is required'),
module_ids: z.array(z.string().uuid()).optional().nullable(),
modules: z.array(z.string().uuid()).optional().nullable(),
permissions: z.array(z.object({
resource: z.string(),
action: z.string(),
@ -94,9 +95,8 @@ export const NewRoleModal = ({
}: NewRoleModalProps): ReactElement | null => {
const permissions = useAppSelector((state) => state.auth.permissions);
const roles = useAppSelector((state) => state.auth.roles);
const tenant = useAppSelector((state) => state.auth.tenant);
const isSuperAdmin = roles.includes('super_admin');
const [selectedModules, setSelectedModules] = useState<string[]>([]);
const [selectedAvailableModules, setSelectedAvailableModules] = useState<string[]>([]);
const [selectedPermissions, setSelectedPermissions] = useState<Array<{ resource: string; action: string }>>([]);
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
@ -113,7 +113,7 @@ export const NewRoleModal = ({
resolver: zodResolver(newRoleSchema),
defaultValues: {
code: undefined,
module_ids: [],
modules: [],
permissions: [],
},
});
@ -135,34 +135,26 @@ export const NewRoleModal = ({
name: '',
code: undefined,
description: '',
module_ids: [],
modules: [],
permissions: [],
});
setSelectedModules([]);
setSelectedAvailableModules([]);
setSelectedPermissions([]);
clearErrors();
}
}, [isOpen, reset, clearErrors]);
// Load modules from tenant assignedModules
const loadModules = async (page: number, limit: number) => {
const assignedModules = tenant?.assignedModules || [];
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedModules = assignedModules.slice(startIndex, endIndex);
// Load available modules from /modules/available endpoint
// For super_admin, send tenant_id if defaultTenantId is provided
const loadAvailableModules = async (page: number, limit: number) => {
const tenantId = isSuperAdmin ? defaultTenantId : undefined;
const response = await moduleService.getAvailable(page, limit, tenantId);
return {
options: paginatedModules.map((module) => ({
options: response.data.map((module) => ({
value: module.id,
label: module.name,
})),
pagination: {
page,
limit,
total: assignedModules.length,
totalPages: Math.ceil(assignedModules.length / limit),
hasMore: endIndex < assignedModules.length,
},
pagination: response.pagination,
};
};
@ -257,15 +249,20 @@ export const NewRoleModal = ({
setValue('permissions', selectedPermissions.length > 0 ? selectedPermissions : []);
}, [selectedPermissions, setValue]);
// Update form value when available modules change
useEffect(() => {
setValue('modules', selectedAvailableModules.length > 0 ? selectedAvailableModules : []);
}, [selectedAvailableModules, setValue]);
const handleFormSubmit = async (data: NewRoleFormData): Promise<void> => {
clearErrors();
try {
const submitData = {
...data,
// Include tenant_id if defaultTenantId is provided
tenant_id: defaultTenantId || undefined,
// Only include module_ids if user is not super_admin
module_ids: isSuperAdmin ? undefined : (selectedModules.length > 0 ? selectedModules : undefined),
// For super_admin, always include tenant_id if defaultTenantId is provided
tenant_id: isSuperAdmin ? (defaultTenantId || undefined) : (defaultTenantId || undefined),
// Include modules from available modules endpoint
modules: selectedAvailableModules.length > 0 ? selectedAvailableModules : undefined,
permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined,
};
await onSubmit(submitData as CreateRoleRequest);
@ -278,7 +275,7 @@ export const NewRoleModal = ({
detail.path === 'name' ||
detail.path === 'code' ||
detail.path === 'description' ||
detail.path === 'module_ids' ||
detail.path === 'modules' ||
detail.path === 'permissions'
) {
setError(detail.path as keyof NewRoleFormData, {
@ -333,7 +330,7 @@ export const NewRoleModal = ({
</>
}
>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0 max-h-[70vh] overflow-y-auto">
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
{/* General Error Display */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
@ -371,20 +368,18 @@ export const NewRoleModal = ({
{...register('description')}
/>
{/* Module Selection - Only show if user is not super_admin */}
{!isSuperAdmin && (
<MultiselectPaginatedSelect
label="Modules"
placeholder="Select modules"
value={selectedModules}
onValueChange={(values) => {
setSelectedModules(values);
setValue('module_ids', values.length > 0 ? values : []);
}}
onLoadOptions={loadModules}
error={errors.module_ids?.message}
/>
)}
{/* Available Modules Selection */}
<MultiselectPaginatedSelect
label="Available Modules"
placeholder="Select available modules"
value={selectedAvailableModules}
onValueChange={(values) => {
setSelectedAvailableModules(values);
setValue('modules', values.length > 0 ? values : []);
}}
onLoadOptions={loadAvailableModules}
error={errors.modules?.message}
/>
{/* Permissions Section */}
<div className="pb-4">
@ -394,7 +389,7 @@ export const NewRoleModal = ({
{errors.permissions && (
<p className="text-sm text-[#ef4444] mb-2">{errors.permissions.message}</p>
)}
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4 max-h-96 overflow-y-auto">
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4">
{Array.from(availableResourcesAndActions.entries()).length === 0 ? (
<p className="text-sm text-[#6b7280]">No permissions available</p>
) : (

View File

@ -195,10 +195,10 @@ export const PaginatedSelect = ({
<div className="flex flex-col gap-2 pb-4">
<label
htmlFor={fieldId}
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]"
>
<span>{label}</span>
{required && <span className="text-[#e02424] text-[8px]">*</span>}
{required && <span className="text-[#e02424]">*</span>}
</label>
<div className="relative" ref={dropdownRef}>
<button

View File

@ -12,9 +12,6 @@ export { DataTable } from './DataTable';
export type { Column } from './DataTable';
export { Pagination } from './Pagination';
export { FilterDropdown } from './FilterDropdown';
// export { NewTenantModal } from './NewTenantModal';
export { ViewTenantModal } from './ViewTenantModal';
export { EditTenantModal } from './EditTenantModal';
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { NewUserModal } from './NewUserModal';
export { ViewUserModal } from './ViewUserModal';
@ -22,10 +19,6 @@ export { EditUserModal } from './EditUserModal';
export { NewRoleModal } from './NewRoleModal';
export { ViewRoleModal } from './ViewRoleModal';
export { EditRoleModal } from './EditRoleModal';
export { ViewModuleModal } from './ViewModuleModal';
export { NewModuleModal } from './NewModuleModal';
export { ViewAuditLogModal } from './ViewAuditLogModal';
export { PageHeader } from './PageHeader';
export type { TabItem } from './PageHeader';
export { UsersTable } from './UsersTable';
export { RolesTable } from './RolesTable';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
// NewTenantModal is commented out and not exported - using CreateTenantWizard instead
export { ViewTenantModal } from './ViewTenantModal';
// export { EditTenantModal } from './EditTenantModal';
export { NewModuleModal } from './NewModuleModal';
export { ViewModuleModal } from './ViewModuleModal';
export { UsersTable } from './UsersTable';
export { RolesTable } from './RolesTable';

View File

@ -0,0 +1,91 @@
import { useMemo } from 'react';
import { useAppSelector } from './redux-hooks';
/**
* Hook to check user permissions
* @returns Object with permission check functions
*/
export const usePermissions = () => {
const { roles, permissions } = useAppSelector((state) => state.auth);
const isSuperAdmin = useMemo(() => {
const rolesArray = Array.isArray(roles) ? roles : [];
return rolesArray.includes('super_admin');
}, [roles]);
/**
* Check if user has permission for a specific resource and action
* @param resource - The resource name (e.g., 'roles', 'users', 'audit_logs')
* @param action - The action name (e.g., 'create', 'read', 'update', 'delete', '*')
* @returns boolean - true if user has permission, false otherwise
*/
const hasPermission = useMemo(
() => (resource: string, action: string): boolean => {
// Super admin has all permissions
if (isSuperAdmin) {
return true;
}
// Check if user has permission with exact match or wildcard
return permissions.some((perm) => {
// Check resource match (exact or wildcard)
const resourceMatches = perm.resource === resource || perm.resource === '*';
// Check action match (exact or wildcard)
const actionMatches = perm.action === action || perm.action === '*';
return resourceMatches && actionMatches;
});
},
[permissions, isSuperAdmin]
);
/**
* Check if user can create a resource
*/
const canCreate = useMemo(
() => (resource: string): boolean => {
return hasPermission(resource, '*') || hasPermission(resource, 'create');
},
[hasPermission]
);
/**
* Check if user can read a resource
*/
const canRead = useMemo(
() => (resource: string): boolean => {
return hasPermission(resource, '*') || hasPermission(resource, 'read');
},
[hasPermission]
);
/**
* Check if user can update a resource
*/
const canUpdate = useMemo(
() => (resource: string): boolean => {
return hasPermission(resource, '*') || hasPermission(resource, 'update');
},
[hasPermission]
);
/**
* Check if user can delete a resource
*/
const canDelete = useMemo(
() => (resource: string): boolean => {
return hasPermission(resource, '*') || hasPermission(resource, 'delete');
},
[hasPermission]
);
return {
hasPermission,
canCreate,
canRead,
canUpdate,
canDelete,
isSuperAdmin,
};
};

View File

@ -1,443 +0,0 @@
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import {
PrimaryButton,
StatusBadge,
ActionDropdown,
NewRoleModal,
ViewRoleModal,
EditRoleModal,
DeleteConfirmationModal,
DataTable,
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { roleService } from '@/services/role-service';
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
import { showToast } from '@/utils/toast';
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
// Helper function to get scope badge variant
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
switch (scope.toLowerCase()) {
case 'platform':
return 'success';
case 'tenant':
return 'process';
case 'module':
return 'failure';
default:
return 'success';
}
};
const Roles = (): ReactElement => {
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const [pagination, setPagination] = useState<{
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}>({
page: 1,
limit: 5,
total: 0,
totalPages: 1,
hasMore: false,
});
// Filter state
const [scopeFilter, setScopeFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
const [selectedRoleName, setSelectedRoleName] = useState<string>('');
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchRoles = async (
page: number,
itemsPerPage: number,
scope: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await roleService.getAll(page, itemsPerPage, scope, sortBy);
if (response.success) {
setRoles(response.data);
setPagination(response.pagination);
} else {
setError('Failed to load roles');
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load roles');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchRoles(currentPage, limit, scopeFilter, orderBy);
}, [currentPage, limit, scopeFilter, orderBy]);
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
try {
setIsCreating(true);
const response = await roleService.create(data);
const message = response.message || `Role created successfully`;
const description = response.message ? undefined : `${data.name} has been added`;
showToast.success(message, description);
setIsModalOpen(false);
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsCreating(false);
}
};
// View role handler
const handleViewRole = (roleId: string): void => {
setSelectedRoleId(roleId);
setViewModalOpen(true);
};
// Edit role handler
const handleEditRole = (roleId: string, roleName: string): void => {
setSelectedRoleId(roleId);
setSelectedRoleName(roleName);
setEditModalOpen(true);
};
// Update role handler
const handleUpdateRole = async (
id: string,
data: UpdateRoleRequest
): Promise<void> => {
try {
setIsUpdating(true);
const response = await roleService.update(id, data);
const message = response.message || `Role updated successfully`;
const description = response.message ? undefined : `${data.name} has been updated`;
showToast.success(message, description);
setEditModalOpen(false);
setSelectedRoleId(null);
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsUpdating(false);
}
};
// Delete role handler
const handleDeleteRole = (roleId: string, roleName: string): void => {
setSelectedRoleId(roleId);
setSelectedRoleName(roleName);
setDeleteModalOpen(true);
};
// Confirm delete handler
const handleConfirmDelete = async (): Promise<void> => {
if (!selectedRoleId) return;
try {
setIsDeleting(true);
await roleService.delete(selectedRoleId);
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsDeleting(false);
}
};
// Load role for view/edit
const loadRole = async (id: string): Promise<Role> => {
const response = await roleService.getById(id);
return response.data;
};
// Table columns
const columns: Column<Role>[] = [
{
key: 'name',
label: 'Name',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
),
},
{
key: 'code',
label: 'Code',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
),
},
{
key: 'scope',
label: 'Scope',
render: (role) => (
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
),
},
{
key: 'description',
label: 'Description',
render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">
{role.description || 'N/A'}
</span>
),
},
{
key: 'is_system',
label: 'System Role',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">
{role.is_system ? 'Yes' : 'No'}
</span>
),
},
{
key: 'created_at',
label: 'Created Date',
render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (role) => (
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)}
onDelete={() => handleDeleteRole(role.id, role.name)}
/>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (role: Role) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
</div>
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)}
onDelete={() => handleDeleteRole(role.id, role.name)}
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Scope:</span>
<div className="mt-1">
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Created:</span>
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
</div>
{role.description && (
<div className="col-span-2">
<span className="text-[#9aa6b2]">Description:</span>
<p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
</div>
)}
</div>
</div>
);
return (
<Layout
currentPage="Roles"
pageHeader={{
title: 'Role List',
description: 'Define and manage roles to control user access based on job responsibilities',
}}
>
{/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Scope Filter */}
<FilterDropdown
label="Scope"
options={[
{ value: 'platform', label: 'Platform' },
{ value: 'tenant', label: 'Tenant' },
{ value: 'module', label: 'Module' },
]}
value={scopeFilter}
onChange={(value) => {
setScopeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
{ value: ['code', 'asc'], label: 'Code (A-Z)' },
{ value: ['code', 'desc'], label: 'Code (Z-A)' },
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
{/* New Role Button */}
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New Role</span>
</PrimaryButton>
</div>
</div>
{/* Table */}
<DataTable
data={roles}
columns={columns}
keyExtractor={(role) => role.id}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No roles found"
isLoading={isLoading}
error={error}
/>
{/* Table Footer with Pagination */}
{pagination.total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => {
setCurrentPage(page);
}}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1);
}}
/>
)}
</div>
{/* New Role Modal */}
<NewRoleModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateRole}
isLoading={isCreating}
/>
{/* View Role Modal */}
<ViewRoleModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedRoleId(null);
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
/>
{/* Edit Role Modal */}
<EditRoleModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
onSubmit={handleUpdateRole}
isLoading={isUpdating}
/>
{/* Delete Confirmation Modal */}
<DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
}}
onConfirm={handleConfirmDelete}
title="Delete Role"
message={`Are you sure you want to delete this role`}
itemName={selectedRoleName}
isLoading={isDeleting}
/>
</Layout>
);
};
export default Roles;

View File

@ -1,476 +0,0 @@
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import {
PrimaryButton,
StatusBadge,
ActionDropdown,
NewUserModal,
ViewUserModal,
EditUserModal,
DeleteConfirmationModal,
DataTable,
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { userService } from '@/services/user-service';
import type { User } from '@/types/user';
import { showToast } from '@/utils/toast';
// Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => {
return `${firstName[0]}${lastName[0]}`.toUpperCase();
};
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
// Helper function to get status badge variant
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
switch (status.toLowerCase()) {
case 'active':
return 'success';
case 'pending_verification':
return 'process';
case 'inactive':
return 'failure';
case 'deleted':
return 'failure';
case 'suspended':
return 'process';
default:
return 'success';
}
};
const Users = (): ReactElement => {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const [pagination, setPagination] = useState<{
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}>({
page: 1,
limit: 5,
total: 0,
totalPages: 1,
hasMore: false,
});
// Filter state
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedUserName, setSelectedUserName] = useState<string>('');
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchUsers = async (
page: number,
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await userService.getAll(page, itemsPerPage, status, sortBy);
if (response.success) {
setUsers(response.data);
setPagination(response.pagination);
} else {
setError('Failed to load users');
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load users');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy);
}, [currentPage, limit, statusFilter, orderBy]);
const handleCreateUser = async (data: {
email: string;
password: string;
first_name: string;
last_name: string;
status: 'active' | 'suspended' | 'deleted';
auth_provider: 'local';
role_id: string;
}): Promise<void> => {
try {
setIsCreating(true);
const response = await userService.create(data);
const message = response.message || `User created successfully`;
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
showToast.success(message, description);
setIsModalOpen(false);
await fetchUsers(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsCreating(false);
}
};
// View user handler
const handleViewUser = (userId: string): void => {
setSelectedUserId(userId);
setViewModalOpen(true);
};
// Edit user handler
const handleEditUser = (userId: string, userName: string): void => {
setSelectedUserId(userId);
setSelectedUserName(userName);
setEditModalOpen(true);
};
// Update user handler
const handleUpdateUser = async (
id: string,
data: {
email: string;
first_name: string;
last_name: string;
status: 'active' | 'suspended' | 'deleted';
tenant_id: string;
role_id: string;
}
): Promise<void> => {
try {
setIsUpdating(true);
const response = await userService.update(id, data);
const message = response.message || `User updated successfully`;
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been updated`;
showToast.success(message, description);
setEditModalOpen(false);
setSelectedUserId(null);
await fetchUsers(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsUpdating(false);
}
};
// Delete user handler
const handleDeleteUser = (userId: string, userName: string): void => {
setSelectedUserId(userId);
setSelectedUserName(userName);
setDeleteModalOpen(true);
};
// Confirm delete handler
const handleConfirmDelete = async (): Promise<void> => {
if (!selectedUserId) return;
try {
setIsDeleting(true);
await userService.delete(selectedUserId);
setDeleteModalOpen(false);
setSelectedUserId(null);
setSelectedUserName('');
await fetchUsers(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsDeleting(false);
}
};
// Load user for view/edit
const loadUser = async (id: string): Promise<User> => {
const response = await userService.getById(id);
return response.data;
};
// Define table columns
const columns: Column<User>[] = [
{
key: 'name',
label: 'User Name',
render: (user) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
<span className="text-xs font-normal text-[#9aa6b2]">
{getUserInitials(user.first_name, user.last_name)}
</span>
</div>
<span className="text-sm font-normal text-[#0f1724]">
{user.first_name} {user.last_name}
</span>
</div>
),
mobileLabel: 'Name',
},
{
key: 'email',
label: 'Email',
render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.email}</span>,
},
{
key: 'status',
label: 'Status',
render: (user) => (
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
),
},
{
key: 'auth_provider',
label: 'Auth Provider',
render: (user) => (
<span className="text-sm font-normal text-[#0f1724]">{user.auth_provider}</span>
),
},
{
key: 'created_at',
label: 'Joined Date',
render: (user) => (
<span className="text-sm font-normal text-[#6b7280]">{formatDate(user.created_at)}</span>
),
mobileLabel: 'Joined',
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (user) => (
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
/>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (user: User) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
<span className="text-xs font-normal text-[#9aa6b2]">
{getUserInitials(user.first_name, user.last_name)}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">
{user.first_name} {user.last_name}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5 truncate">{user.email}</p>
</div>
</div>
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Status:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Auth Provider:</span>
<p className="text-[#0f1724] font-normal mt-1">{user.auth_provider}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Joined:</span>
<p className="text-[#6b7280] font-normal mt-1">{formatDate(user.created_at)}</p>
</div>
</div>
</div>
);
return (
<Layout
currentPage="Users"
pageHeader={{
title: 'User List',
description: 'View and manage all users in your QAssure platform from a single place.',
}}
>
{/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Status Filter */}
<FilterDropdown
label="Status"
options={[
{ value: 'active', label: 'Active' },
{ value: 'pending_verification', label: 'Pending Verification' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'deleted', label: 'Deleted' },
]}
value={statusFilter}
onChange={(value) => {
setStatusFilter(value as string | null);
setCurrentPage(1); // Reset to first page when filter changes
}}
placeholder="All"
/>
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ['first_name', 'asc'], label: 'First Name (A-Z)' },
{ value: ['first_name', 'desc'], label: 'First Name (Z-A)' },
{ value: ['last_name', 'asc'], label: 'Last Name (A-Z)' },
{ value: ['last_name', 'desc'], label: 'Last Name (Z-A)' },
{ value: ['email', 'asc'], label: 'Email (A-Z)' },
{ value: ['email', 'desc'], label: 'Email (Z-A)' },
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1); // Reset to first page when sort changes
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
{/* New User Button */}
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New User</span>
</PrimaryButton>
</div>
</div>
{/* Data Table */}
<DataTable
data={users}
columns={columns}
keyExtractor={(user) => user.id}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No users found"
isLoading={isLoading}
error={error}
/>
{/* Table Footer with Pagination */}
{pagination.total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => {
setCurrentPage(page);
}}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1); // Reset to first page when limit changes
}}
/>
)}
</div>
{/* New User Modal */}
<NewUserModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateUser}
isLoading={isCreating}
/>
{/* View User Modal */}
<ViewUserModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedUserId(null);
}}
userId={selectedUserId}
onLoadUser={loadUser}
/>
{/* Edit User Modal */}
<EditUserModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setSelectedUserId(null);
setSelectedUserName('');
}}
userId={selectedUserId}
onLoadUser={loadUser}
onSubmit={handleUpdateUser}
isLoading={isUpdating}
/>
{/* Delete Confirmation Modal */}
<DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setSelectedUserId(null);
setSelectedUserName('');
}}
onConfirm={handleConfirmDelete}
title="Delete User"
message="Are you sure you want to delete this user"
itemName={selectedUserName}
isLoading={isDeleting}
/>
</Layout>
);
};
export default Users;

View File

@ -10,7 +10,7 @@ import { tenantService } from '@/services/tenant-service';
import { moduleService } from '@/services/module-service';
import { fileService } from '@/services/file-service';
import { showToast } from '@/utils/toast';
import { ChevronRight, ChevronLeft, Image as ImageIcon } from 'lucide-react';
import { ChevronRight, ChevronLeft, Image as ImageIcon, X } from 'lucide-react';
// Step 1: Tenant Details Schema - matches NewTenantModal
const tenantDetailsSchema = z.object({
@ -52,12 +52,10 @@ const contactDetailsSchema = z
.refine(
(val) => {
if (!val || val.trim() === '') return true; // Optional field, empty is valid
// Phone number regex: accepts formats like +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, etc.
const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}[-\s\.]?[0-9]{1,9}$/;
return phoneRegex.test(val.replace(/\s/g, ''));
return /^\d{10}$/.test(val);
},
{
message: 'Please enter a valid phone number (e.g., +1234567890, (123) 456-7890, 123-456-7890)',
message: 'Phone number must be exactly 10 digits',
}
),
address_line1: z.string().min(1, 'Address is required'),
@ -66,8 +64,7 @@ const contactDetailsSchema = z
state: z.string().min(1, 'State is required'),
postal_code: z
.string()
.min(1, 'Postal code is required')
.regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'),
.regex(/^[1-9]\d{5}$/, 'Postal code must be a valid 6-digit PIN code'),
country: z.string().min(1, 'Country is required'),
})
.refine((data) => data.password === data.confirmPassword, {
@ -172,14 +169,16 @@ const CreateTenantWizard = (): ReactElement => {
// File upload state for branding
const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
// const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
// const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [faviconError, setFaviconError] = useState<string | null>(null);
// Auto-generate slug and domain from name
const nameValue = tenantDetailsForm.watch('name');
@ -204,7 +203,7 @@ const CreateTenantWizard = (): ReactElement => {
const host = baseUrlObj.host; // e.g., "localhost:5173"
const protocol = baseUrlObj.protocol; // e.g., "http:" or "https:"
const autoGeneratedDomain = `${protocol}//${slug}.${host}/tenant`;
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
} catch {
// Fallback if URL parsing fails
const autoGeneratedDomain = `${baseUrlWithProtocol.replace(/\/$/, '')}/${slug}/tenant`;
@ -294,10 +293,56 @@ const CreateTenantWizard = (): ReactElement => {
}
};
const handleDeleteLogo = (): void => {
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
}
setLogoFile(null);
setLogoPreviewUrl(null);
setLogoFileUrl(null);
setLogoError(null);
// Reset the file input
const fileInput = document.getElementById('logo-upload-wizard') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
};
const handleDeleteFavicon = (): void => {
if (faviconPreviewUrl) {
URL.revokeObjectURL(faviconPreviewUrl);
}
setFaviconFile(null);
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconError(null);
// Reset the file input
const fileInput = document.getElementById('favicon-upload-wizard') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
};
const handleSubmit = async (): Promise<void> => {
const isValid = await settingsForm.trigger();
if (!isValid) return;
// Validate logo and favicon are uploaded
setLogoError(null);
setFaviconError(null);
if (!logoFileUrl && !logoFile) {
setLogoError('Logo is required');
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
if (!faviconFileUrl && !faviconFile) {
setFaviconError('Favicon is required');
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
try {
setIsSubmitting(true);
const tenantDetails = tenantDetailsForm.getValues();
@ -323,8 +368,8 @@ const CreateTenantWizard = (): ReactElement => {
primary_color: primary_color || undefined,
secondary_color: secondary_color || undefined,
accent_color: accent_color || undefined,
logo_file_path: logoFilePath || undefined,
favicon_file_path: faviconFilePath || undefined,
logo_file_path: logoFileUrl || undefined,
favicon_file_path: faviconFileUrl || undefined,
},
},
};
@ -433,26 +478,23 @@ const CreateTenantWizard = (): ReactElement => {
{steps.map((step) => (
<div
key={step.number}
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${
step.isActive ? 'bg-[#f5f7fa]' : ''
}`}
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${step.isActive ? 'bg-[#f5f7fa]' : ''
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step.isActive
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
: step.isCompleted
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${step.isActive
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
: step.isCompleted
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
: 'bg-white border border-[rgba(0,0,0,0.08)] text-[#6b7280]'
}`}
}`}
>
{step.number}
</div>
<div className="flex-1">
<div
className={`text-sm font-medium ${
step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
}`}
className={`text-sm font-medium ${step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
}`}
>
{step.title}
</div>
@ -655,9 +697,16 @@ const CreateTenantWizard = (): ReactElement => {
<FormField
label="Contact Phone"
type="tel"
placeholder="Enter contact phone"
placeholder="Enter 10-digit phone number"
maxLength={10}
error={contactDetailsForm.formState.errors.contact_phone?.message}
{...contactDetailsForm.register('contact_phone')}
{...contactDetailsForm.register('contact_phone', {
onChange: (e) => {
// Only allow digits and limit to 10 characters
const value = e.target.value.replace(/\D/g, '').slice(0, 10);
contactDetailsForm.setValue('contact_phone', value, { shouldValidate: true });
},
})}
/>
</div>
</div>
@ -700,7 +749,11 @@ const CreateTenantWizard = (): ReactElement => {
required
placeholder="Enter postal code"
error={contactDetailsForm.formState.errors.postal_code?.message}
{...contactDetailsForm.register('postal_code')}
{...contactDetailsForm.register('postal_code', {
onChange: (e) => {
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
},
})}
/>
<FormField
label="Country"
@ -740,7 +793,9 @@ const CreateTenantWizard = (): ReactElement => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
<label className="text-sm font-medium text-[#0f1724]">
Company Logo <span className="text-[#e02424]">*</span>
</label>
<label
htmlFor="logo-upload-wizard"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
@ -782,8 +837,9 @@ const CreateTenantWizard = (): ReactElement => {
setIsUploadingLogo(true);
try {
const response = await fileService.uploadSimple(file);
setLogoFilePath(response.data.file_path);
// setLogoFilePath(response.data.file_path);
setLogoFileUrl(response.data.file_url);
setLogoError(null); // Clear error on successful upload
// Keep preview URL as fallback, will be cleaned up on component unmount or file change
showToast.success('Logo uploaded successfully');
} catch (err: any) {
@ -798,7 +854,7 @@ const CreateTenantWizard = (): ReactElement => {
URL.revokeObjectURL(previewUrl);
setLogoPreviewUrl(null);
setLogoFileUrl(null);
setLogoFilePath(null);
// setLogoFilePath(null);
} finally {
setIsUploadingLogo(false);
}
@ -807,13 +863,18 @@ const CreateTenantWizard = (): ReactElement => {
className="hidden"
/>
</label>
{logoFile && (
{logoError && (
<p className="text-sm text-[#ef4444]">{logoError}</p>
)}
{(logoFile || logoFileUrl) && (
<div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]">
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
{logoFile && (
<div className="text-xs text-[#6b7280]">
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
)}
{(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2">
<div className="mt-2 relative inline-block">
<img
key={logoPreviewUrl || logoFileUrl}
src={logoPreviewUrl || logoFileUrl || ''}
@ -835,6 +896,14 @@ const CreateTenantWizard = (): ReactElement => {
});
}}
/>
<button
type="button"
onClick={handleDeleteLogo}
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
aria-label="Delete logo"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
@ -843,7 +912,9 @@ const CreateTenantWizard = (): ReactElement => {
{/* Favicon */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
<label className="text-sm font-medium text-[#0f1724]">
Favicon <span className="text-[#e02424]">*</span>
</label>
<label
htmlFor="favicon-upload-wizard"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
@ -885,8 +956,9 @@ const CreateTenantWizard = (): ReactElement => {
setIsUploadingFavicon(true);
try {
const response = await fileService.uploadSimple(file);
setFaviconFilePath(response.data.file_path);
// setFaviconFilePath(response.data.file_path);
setFaviconFileUrl(response.data.file_url);
setFaviconError(null); // Clear error on successful upload
// Keep preview URL as fallback, will be cleaned up on component unmount or file change
showToast.success('Favicon uploaded successfully');
} catch (err: any) {
@ -901,7 +973,7 @@ const CreateTenantWizard = (): ReactElement => {
URL.revokeObjectURL(previewUrl);
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
// setFaviconFilePath(null);
} finally {
setIsUploadingFavicon(false);
}
@ -910,13 +982,18 @@ const CreateTenantWizard = (): ReactElement => {
className="hidden"
/>
</label>
{faviconFile && (
{faviconError && (
<p className="text-sm text-[#ef4444]">{faviconError}</p>
)}
{(faviconFile || faviconFileUrl) && (
<div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]">
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
{faviconFile && (
<div className="text-xs text-[#6b7280]">
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
)}
{(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2">
<div className="mt-2 relative inline-block">
<img
key={faviconFileUrl || faviconPreviewUrl || ''}
src={faviconPreviewUrl || faviconFileUrl || ''}
@ -938,6 +1015,14 @@ const CreateTenantWizard = (): ReactElement => {
});
}}
/>
<button
type="button"
onClick={handleDeleteFavicon}
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
aria-label="Delete favicon"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
@ -1055,14 +1140,12 @@ const CreateTenantWizard = (): ReactElement => {
checked={settingsForm.watch('enable_sso')}
/>
<div
className={`w-10 h-5 rounded-full transition-colors ${
settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
}`}
className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
}`}
>
<div
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${
settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0'
}`}
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0'
}`}
></div>
</div>
</label>
@ -1084,14 +1167,12 @@ const CreateTenantWizard = (): ReactElement => {
checked={settingsForm.watch('enable_2fa')}
/>
<div
className={`w-10 h-5 rounded-full transition-colors ${
settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
}`}
className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
}`}
>
<div
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${
settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0'
}`}
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0'
}`}
></div>
</div>
</label>

View File

@ -10,7 +10,7 @@ import { tenantService } from '@/services/tenant-service';
import { moduleService } from '@/services/module-service';
import { fileService } from '@/services/file-service';
import { showToast } from '@/utils/toast';
import { ChevronRight, ChevronLeft, Image as ImageIcon, Loader2 } from 'lucide-react';
import { ChevronRight, ChevronLeft, Image as ImageIcon, Loader2, X } from 'lucide-react';
// Step 1: Tenant Details Schema
const tenantDetailsSchema = z.object({
@ -48,12 +48,11 @@ const contactDetailsSchema = z.object({
.nullable()
.refine(
(val) => {
if (!val || val.trim() === '') return true;
const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}[-\s\.]?[0-9]{1,9}$/;
return phoneRegex.test(val.replace(/\s/g, ''));
if (!val || val.trim() === '') return true; // Optional field, empty is valid
return /^\d{10}$/.test(val);
},
{
message: 'Please enter a valid phone number (e.g., +1234567890, (123) 456-7890, 123-456-7890)',
message: 'Phone number must be exactly 10 digits',
}
),
address_line1: z.string().min(1, 'Address is required'),
@ -62,8 +61,7 @@ const contactDetailsSchema = z.object({
state: z.string().min(1, 'State is required'),
postal_code: z
.string()
.min(1, 'Postal code is required')
.regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'),
.regex(/^[1-9]\d{5}$/, 'Postal code must be a valid 6-digit PIN code'),
country: z.string().min(1, 'Country is required'),
});
@ -118,6 +116,8 @@ const EditTenant = (): ReactElement => {
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [faviconError, setFaviconError] = useState<string | null>(null);
// Form instances for each step
const tenantDetailsForm = useForm<TenantDetailsForm>({
@ -243,19 +243,21 @@ const EditTenant = (): ReactElement => {
setLogoFilePath(logoPath);
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
setLogoPreviewUrl(logoPath);
setLogoError(null); // Clear error if existing logo is found
}
if (faviconPath) {
setFaviconFilePath(faviconPath);
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
setFaviconFileUrl(faviconPath);
setFaviconPreviewUrl(faviconPath);
setFaviconError(null); // Clear error if existing favicon is found
}
// Validate subscription_tier
const validSubscriptionTier =
tenant.subscription_tier === 'basic' ||
tenant.subscription_tier === 'professional' ||
tenant.subscription_tier === 'enterprise'
tenant.subscription_tier === 'professional' ||
tenant.subscription_tier === 'enterprise'
? tenant.subscription_tier
: null;
@ -267,9 +269,9 @@ const EditTenant = (): ReactElement => {
// Create initial options from assignedModules
const initialOptions = tenant.assignedModules
? tenant.assignedModules.map((module) => ({
value: module.id,
label: module.name,
}))
value: module.id,
label: module.name,
}))
: [];
setSelectedModules(tenantModules);
@ -390,12 +392,59 @@ const EditTenant = (): ReactElement => {
}
};
const handleDeleteLogo = (): void => {
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
}
setLogoFile(null);
setLogoPreviewUrl(null);
setLogoFilePath(null);
setLogoError(null); // Clear error on delete
// Reset the file input
const fileInput = document.getElementById('logo-upload-edit-page') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
};
const handleDeleteFavicon = (): void => {
if (faviconPreviewUrl) {
URL.revokeObjectURL(faviconPreviewUrl);
}
setFaviconFile(null);
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
setFaviconError(null); // Clear error on delete
// Reset the file input
const fileInput = document.getElementById('favicon-upload-edit-page') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
};
const handleSubmit = async (): Promise<void> => {
if (!id) return;
const isValid = await settingsForm.trigger();
if (!isValid) return;
// Validate logo and favicon are uploaded
setLogoError(null);
setFaviconError(null);
if (!logoFilePath) {
setLogoError('Logo is required');
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
if (!faviconFilePath) {
setFaviconError('Favicon is required');
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
try {
setIsSubmitting(true);
const tenantDetails = tenantDetailsForm.getValues();
@ -563,26 +612,23 @@ const EditTenant = (): ReactElement => {
{steps.map((step) => (
<div
key={step.number}
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${
step.isActive ? 'bg-[#f5f7fa]' : ''
}`}
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${step.isActive ? 'bg-[#f5f7fa]' : ''
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step.isActive
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
: step.isCompleted
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${step.isActive
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
: step.isCompleted
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
: 'bg-white border border-[rgba(0,0,0,0.08)] text-[#6b7280]'
}`}
}`}
>
{step.number}
</div>
<div className="flex-1">
<div
className={`text-sm font-medium ${
step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
}`}
className={`text-sm font-medium ${step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
}`}
>
{step.title}
</div>
@ -753,9 +799,16 @@ const EditTenant = (): ReactElement => {
<FormField
label="Contact Phone"
type="tel"
placeholder="Enter contact phone"
placeholder="Enter 10-digit phone number"
maxLength={10}
error={contactDetailsForm.formState.errors.contact_phone?.message}
{...contactDetailsForm.register('contact_phone')}
{...contactDetailsForm.register('contact_phone', {
onChange: (e) => {
// Only allow digits and limit to 10 characters
const value = e.target.value.replace(/\D/g, '').slice(0, 10);
contactDetailsForm.setValue('contact_phone', value, { shouldValidate: true });
},
})}
/>
</div>
</div>
@ -797,8 +850,13 @@ const EditTenant = (): ReactElement => {
required
placeholder="Enter postal code"
error={contactDetailsForm.formState.errors.postal_code?.message}
{...contactDetailsForm.register('postal_code')}
{...contactDetailsForm.register('postal_code', {
onChange: (e) => {
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
},
})}
/>
<FormField
label="Country"
required
@ -834,7 +892,9 @@ const EditTenant = (): ReactElement => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
<label className="text-sm font-medium text-[#0f1724]">
Company Logo <span className="text-[#e02424]">*</span>
</label>
<label
htmlFor="logo-upload-edit-page"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
@ -872,6 +932,7 @@ const EditTenant = (): ReactElement => {
try {
const response = await fileService.uploadSimple(file);
setLogoFilePath(response.data.file_url);
setLogoError(null); // Clear error on successful upload
showToast.success('Logo uploaded successfully');
} catch (err: any) {
const errorMessage =
@ -892,13 +953,18 @@ const EditTenant = (): ReactElement => {
className="hidden"
/>
</label>
{logoFile && (
{logoError && (
<p className="text-sm text-[#ef4444]">{logoError}</p>
)}
{(logoFile || logoPreviewUrl) && (
<div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]">
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
{(logoPreviewUrl ) && (
<div className="mt-2">
{logoFile && (
<div className="text-xs text-[#6b7280]">
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
)}
{logoPreviewUrl && (
<div className="mt-2 relative inline-block">
<img
key={logoPreviewUrl}
src={logoPreviewUrl || ''}
@ -912,25 +978,25 @@ const EditTenant = (): ReactElement => {
});
}}
/>
<button
type="button"
onClick={handleDeleteLogo}
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
aria-label="Delete logo"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
)}
{!logoFile && (
<div className="mt-2">
<img
src={logoPreviewUrl || ''}
alt="Current logo"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', maxHeight: '80px' }}
/>
</div>
)}
</div>
{/* Favicon */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
<label className="text-sm font-medium text-[#0f1724]">
Favicon <span className="text-[#e02424]">*</span>
</label>
<label
htmlFor="favicon-upload-edit-page"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
@ -969,6 +1035,7 @@ const EditTenant = (): ReactElement => {
const response = await fileService.uploadSimple(file);
setFaviconFilePath(response.data.file_url);
setFaviconFileUrl(response.data.file_url);
setFaviconError(null); // Clear error on successful upload
showToast.success('Favicon uploaded successfully');
} catch (err: any) {
const errorMessage =
@ -990,34 +1057,44 @@ const EditTenant = (): ReactElement => {
className="hidden"
/>
</label>
{faviconFile && (
{faviconError && (
<p className="text-sm text-[#ef4444]">{faviconError}</p>
)}
{(faviconFile || faviconFileUrl || faviconPreviewUrl) && (
<div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]">
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
{faviconFile && (
<div className="text-xs text-[#6b7280]">
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
)}
{(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2">
<div className="mt-2 relative inline-block">
<img
key={faviconPreviewUrl || faviconFileUrl}
src={faviconPreviewUrl || faviconFileUrl || ''}
alt="Favicon preview"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', maxHeight: '80px' }}
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', width: '64px', height: '64px' }}
onError={(e) => {
console.error('Failed to load favicon preview image', {
faviconFileUrl,
faviconPreviewUrl,
src: e.currentTarget.src,
});
}}
/>
<button
type="button"
onClick={handleDeleteFavicon}
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
aria-label="Delete favicon"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
)}
{!faviconFile && faviconFileUrl && (
<div className="mt-2">
<img
src={faviconFileUrl}
alt="Current favicon"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', maxHeight: '80px' }}
/>
</div>
)}
</div>
</div>
@ -1121,14 +1198,12 @@ const EditTenant = (): ReactElement => {
checked={settingsForm.watch('enable_sso')}
/>
<div
className={`w-10 h-5 rounded-full transition-colors ${
settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
}`}
className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
}`}
>
<div
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${
settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0'
}`}
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0'
}`}
></div>
</div>
</label>
@ -1146,14 +1221,12 @@ const EditTenant = (): ReactElement => {
checked={settingsForm.watch('enable_2fa')}
/>
<div
className={`w-10 h-5 rounded-full transition-colors ${
settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
}`}
className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
}`}
>
<div
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${
settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0'
}`}
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0'
}`}
></div>
</div>
</label>

View File

@ -3,14 +3,13 @@ import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import {
StatusBadge,
ViewModuleModal,
NewModuleModal,
PrimaryButton,
DataTable,
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
import { ViewModuleModal, NewModuleModal } from '@/components/superadmin';
import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { moduleService } from '@/services/module-service';
import type { Module } from '@/types/module';

View File

@ -0,0 +1,443 @@
// import { useState, useEffect } from 'react';
// import type { ReactElement } from 'react';
// import { Layout } from '@/components/layout/Layout';
// import {
// PrimaryButton,
// StatusBadge,
// ActionDropdown,
// NewRoleModal,
// ViewRoleModal,
// EditRoleModal,
// DeleteConfirmationModal,
// DataTable,
// Pagination,
// FilterDropdown,
// type Column,
// } from '@/components/shared';
// import { Plus, Download, ArrowUpDown } from 'lucide-react';
// import { roleService } from '@/services/role-service';
// import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
// import { showToast } from '@/utils/toast';
// // Helper function to format date
// const formatDate = (dateString: string): string => {
// const date = new Date(dateString);
// return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// };
// // Helper function to get scope badge variant
// const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
// switch (scope.toLowerCase()) {
// case 'platform':
// return 'success';
// case 'tenant':
// return 'process';
// case 'module':
// return 'failure';
// default:
// return 'success';
// }
// };
// const Roles = (): ReactElement => {
// const [roles, setRoles] = useState<Role[]>([]);
// const [isLoading, setIsLoading] = useState<boolean>(true);
// const [error, setError] = useState<string | null>(null);
// const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
// const [isCreating, setIsCreating] = useState<boolean>(false);
// // Pagination state
// const [currentPage, setCurrentPage] = useState<number>(1);
// const [limit, setLimit] = useState<number>(5);
// const [pagination, setPagination] = useState<{
// page: number;
// limit: number;
// total: number;
// totalPages: number;
// hasMore: boolean;
// }>({
// page: 1,
// limit: 5,
// total: 0,
// totalPages: 1,
// hasMore: false,
// });
// // Filter state
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
// const [orderBy, setOrderBy] = useState<string[] | null>(null);
// // View, Edit, Delete modals
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
// const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
// const [selectedRoleName, setSelectedRoleName] = useState<string>('');
// const [isUpdating, setIsUpdating] = useState<boolean>(false);
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
// const fetchRoles = async (
// page: number,
// itemsPerPage: number,
// scope: string | null = null,
// sortBy: string[] | null = null
// ): Promise<void> => {
// try {
// setIsLoading(true);
// setError(null);
// const response = await roleService.getAll(page, itemsPerPage, scope, sortBy);
// if (response.success) {
// setRoles(response.data);
// setPagination(response.pagination);
// } else {
// setError('Failed to load roles');
// }
// } catch (err: any) {
// setError(err?.response?.data?.error?.message || 'Failed to load roles');
// } finally {
// setIsLoading(false);
// }
// };
// useEffect(() => {
// fetchRoles(currentPage, limit, scopeFilter, orderBy);
// }, [currentPage, limit, scopeFilter, orderBy]);
// const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
// try {
// setIsCreating(true);
// const response = await roleService.create(data);
// const message = response.message || `Role created successfully`;
// const description = response.message ? undefined : `${data.name} has been added`;
// showToast.success(message, description);
// setIsModalOpen(false);
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
// } catch (err: any) {
// throw err;
// } finally {
// setIsCreating(false);
// }
// };
// // View role handler
// const handleViewRole = (roleId: string): void => {
// setSelectedRoleId(roleId);
// setViewModalOpen(true);
// };
// // Edit role handler
// const handleEditRole = (roleId: string, roleName: string): void => {
// setSelectedRoleId(roleId);
// setSelectedRoleName(roleName);
// setEditModalOpen(true);
// };
// // Update role handler
// const handleUpdateRole = async (
// id: string,
// data: UpdateRoleRequest
// ): Promise<void> => {
// try {
// setIsUpdating(true);
// const response = await roleService.update(id, data);
// const message = response.message || `Role updated successfully`;
// const description = response.message ? undefined : `${data.name} has been updated`;
// showToast.success(message, description);
// setEditModalOpen(false);
// setSelectedRoleId(null);
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
// } catch (err: any) {
// throw err;
// } finally {
// setIsUpdating(false);
// }
// };
// // Delete role handler
// const handleDeleteRole = (roleId: string, roleName: string): void => {
// setSelectedRoleId(roleId);
// setSelectedRoleName(roleName);
// setDeleteModalOpen(true);
// };
// // Confirm delete handler
// const handleConfirmDelete = async (): Promise<void> => {
// if (!selectedRoleId) return;
// try {
// setIsDeleting(true);
// await roleService.delete(selectedRoleId);
// setDeleteModalOpen(false);
// setSelectedRoleId(null);
// setSelectedRoleName('');
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
// } catch (err: any) {
// throw err;
// } finally {
// setIsDeleting(false);
// }
// };
// // Load role for view/edit
// const loadRole = async (id: string): Promise<Role> => {
// const response = await roleService.getById(id);
// return response.data;
// };
// // Table columns
// const columns: Column<Role>[] = [
// {
// key: 'name',
// label: 'Name',
// render: (role) => (
// <span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
// ),
// },
// {
// key: 'code',
// label: 'Code',
// render: (role) => (
// <span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
// ),
// },
// {
// key: 'scope',
// label: 'Scope',
// render: (role) => (
// <StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
// ),
// },
// {
// key: 'description',
// label: 'Description',
// render: (role) => (
// <span className="text-sm font-normal text-[#6b7280]">
// {role.description || 'N/A'}
// </span>
// ),
// },
// {
// key: 'is_system',
// label: 'System Role',
// render: (role) => (
// <span className="text-sm font-normal text-[#0f1724]">
// {role.is_system ? 'Yes' : 'No'}
// </span>
// ),
// },
// {
// key: 'created_at',
// label: 'Created Date',
// render: (role) => (
// <span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
// ),
// },
// {
// key: 'actions',
// label: 'Actions',
// align: 'right',
// render: (role) => (
// <div className="flex justify-end">
// <ActionDropdown
// onView={() => handleViewRole(role.id)}
// onEdit={() => handleEditRole(role.id, role.name)}
// onDelete={() => handleDeleteRole(role.id, role.name)}
// />
// </div>
// ),
// },
// ];
// // Mobile card renderer
// const mobileCardRenderer = (role: Role) => (
// <div className="p-4">
// <div className="flex items-start justify-between gap-3 mb-3">
// <div className="flex-1 min-w-0">
// <h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
// <p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
// </div>
// <ActionDropdown
// onView={() => handleViewRole(role.id)}
// onEdit={() => handleEditRole(role.id, role.name)}
// onDelete={() => handleDeleteRole(role.id, role.name)}
// />
// </div>
// <div className="grid grid-cols-2 gap-3 text-xs">
// <div>
// <span className="text-[#9aa6b2]">Scope:</span>
// <div className="mt-1">
// <StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
// </div>
// </div>
// <div>
// <span className="text-[#9aa6b2]">Created:</span>
// <p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
// </div>
// {role.description && (
// <div className="col-span-2">
// <span className="text-[#9aa6b2]">Description:</span>
// <p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
// </div>
// )}
// </div>
// </div>
// );
// return (
// <Layout
// currentPage="Roles"
// pageHeader={{
// title: 'Role List',
// description: 'Define and manage roles to control user access based on job responsibilities',
// }}
// >
// {/* Table Container */}
// <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
// {/* Table Header with Filters */}
// <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
// {/* Filters */}
// <div className="flex flex-wrap items-center gap-3">
// {/* Scope Filter */}
// <FilterDropdown
// label="Scope"
// options={[
// { value: 'platform', label: 'Platform' },
// { value: 'tenant', label: 'Tenant' },
// { value: 'module', label: 'Module' },
// ]}
// value={scopeFilter}
// onChange={(value) => {
// setScopeFilter(value as string | null);
// setCurrentPage(1);
// }}
// placeholder="All"
// />
// {/* Sort Filter */}
// <FilterDropdown
// label="Sort by"
// options={[
// { value: ['name', 'asc'], label: 'Name (A-Z)' },
// { value: ['name', 'desc'], label: 'Name (Z-A)' },
// { value: ['code', 'asc'], label: 'Code (A-Z)' },
// { value: ['code', 'desc'], label: 'Code (Z-A)' },
// { value: ['created_at', 'asc'], label: 'Created (Oldest)' },
// { value: ['created_at', 'desc'], label: 'Created (Newest)' },
// { value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
// { value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
// ]}
// value={orderBy}
// onChange={(value) => {
// setOrderBy(value as string[] | null);
// setCurrentPage(1);
// }}
// placeholder="Default"
// showIcon
// icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
// />
// </div>
// {/* Actions */}
// <div className="flex items-center gap-2">
// {/* Export Button */}
// <button
// type="button"
// className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
// >
// <Download className="w-3.5 h-3.5" />
// <span>Export</span>
// </button>
// {/* New Role Button */}
// <PrimaryButton
// size="default"
// className="flex items-center gap-2"
// onClick={() => setIsModalOpen(true)}
// >
// <Plus className="w-3.5 h-3.5" />
// <span className="text-xs">New Role</span>
// </PrimaryButton>
// </div>
// </div>
// {/* Table */}
// <DataTable
// data={roles}
// columns={columns}
// keyExtractor={(role) => role.id}
// mobileCardRenderer={mobileCardRenderer}
// emptyMessage="No roles found"
// isLoading={isLoading}
// error={error}
// />
// {/* Table Footer with Pagination */}
// {pagination.total > 0 && (
// <Pagination
// currentPage={currentPage}
// totalPages={pagination.totalPages}
// totalItems={pagination.total}
// limit={limit}
// onPageChange={(page: number) => {
// setCurrentPage(page);
// }}
// onLimitChange={(newLimit: number) => {
// setLimit(newLimit);
// setCurrentPage(1);
// }}
// />
// )}
// </div>
// {/* New Role Modal */}
// <NewRoleModal
// isOpen={isModalOpen}
// onClose={() => setIsModalOpen(false)}
// onSubmit={handleCreateRole}
// isLoading={isCreating}
// />
// {/* View Role Modal */}
// <ViewRoleModal
// isOpen={viewModalOpen}
// onClose={() => {
// setViewModalOpen(false);
// setSelectedRoleId(null);
// }}
// roleId={selectedRoleId}
// onLoadRole={loadRole}
// />
// {/* Edit Role Modal */}
// <EditRoleModal
// isOpen={editModalOpen}
// onClose={() => {
// setEditModalOpen(false);
// setSelectedRoleId(null);
// setSelectedRoleName('');
// }}
// roleId={selectedRoleId}
// onLoadRole={loadRole}
// onSubmit={handleUpdateRole}
// isLoading={isUpdating}
// />
// {/* Delete Confirmation Modal */}
// <DeleteConfirmationModal
// isOpen={deleteModalOpen}
// onClose={() => {
// setDeleteModalOpen(false);
// setSelectedRoleId(null);
// setSelectedRoleName('');
// }}
// onConfirm={handleConfirmDelete}
// title="Delete Role"
// message={`Are you sure you want to delete this role`}
// itemName={selectedRoleName}
// isLoading={isDeleting}
// />
// </Layout>
// );
// };
// export default Roles;

View File

@ -20,10 +20,9 @@ import {
StatusBadge,
DataTable,
Pagination,
UsersTable,
RolesTable,
type Column,
} from '@/components/shared';
import { UsersTable, RolesTable } from '@/components/superadmin';
import { tenantService } from '@/services/tenant-service';
import { auditLogService } from '@/services/audit-log-service';
import type { Tenant, AssignedModule } from '@/types/tenant';

View File

@ -5,15 +5,13 @@ import {
PrimaryButton,
StatusBadge,
ActionDropdown,
// NewTenantModal, // Commented out - using wizard instead
// ViewTenantModal, // Commented out - using details page instead
// EditTenantModal, // Commented out - using edit page instead
DeleteConfirmationModal,
DataTable,
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
// Note: NewTenantModal, ViewTenantModal, EditTenantModal are now in @/components/superadmin (commented out - using wizard/details/edit pages instead)
import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { tenantService } from '@/services/tenant-service';

View File

@ -0,0 +1,476 @@
// import { useState, useEffect } from 'react';
// import type { ReactElement } from 'react';
// import { Layout } from '@/components/layout/Layout';
// import {
// PrimaryButton,
// StatusBadge,
// ActionDropdown,
// NewUserModal,
// ViewUserModal,
// EditUserModal,
// DeleteConfirmationModal,
// DataTable,
// Pagination,
// FilterDropdown,
// type Column,
// } from '@/components/shared';
// import { Plus, Download, ArrowUpDown } from 'lucide-react';
// import { userService } from '@/services/user-service';
// import type { User } from '@/types/user';
// import { showToast } from '@/utils/toast';
// // Helper function to get user initials
// const getUserInitials = (firstName: string, lastName: string): string => {
// return `${firstName[0]}${lastName[0]}`.toUpperCase();
// };
// // Helper function to format date
// const formatDate = (dateString: string): string => {
// const date = new Date(dateString);
// return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// };
// // Helper function to get status badge variant
// const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
// switch (status.toLowerCase()) {
// case 'active':
// return 'success';
// case 'pending_verification':
// return 'process';
// case 'inactive':
// return 'failure';
// case 'deleted':
// return 'failure';
// case 'suspended':
// return 'process';
// default:
// return 'success';
// }
// };
// const Users = (): ReactElement => {
// const [users, setUsers] = useState<User[]>([]);
// const [isLoading, setIsLoading] = useState<boolean>(true);
// const [error, setError] = useState<string | null>(null);
// const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
// const [isCreating, setIsCreating] = useState<boolean>(false);
// // Pagination state
// const [currentPage, setCurrentPage] = useState<number>(1);
// const [limit, setLimit] = useState<number>(5);
// const [pagination, setPagination] = useState<{
// page: number;
// limit: number;
// total: number;
// totalPages: number;
// hasMore: boolean;
// }>({
// page: 1,
// limit: 5,
// total: 0,
// totalPages: 1,
// hasMore: false,
// });
// // Filter state
// const [statusFilter, setStatusFilter] = useState<string | null>(null);
// const [orderBy, setOrderBy] = useState<string[] | null>(null);
// // View, Edit, Delete modals
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
// const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
// const [selectedUserName, setSelectedUserName] = useState<string>('');
// const [isUpdating, setIsUpdating] = useState<boolean>(false);
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
// const fetchUsers = async (
// page: number,
// itemsPerPage: number,
// status: string | null = null,
// sortBy: string[] | null = null
// ): Promise<void> => {
// try {
// setIsLoading(true);
// setError(null);
// const response = await userService.getAll(page, itemsPerPage, status, sortBy);
// if (response.success) {
// setUsers(response.data);
// setPagination(response.pagination);
// } else {
// setError('Failed to load users');
// }
// } catch (err: any) {
// setError(err?.response?.data?.error?.message || 'Failed to load users');
// } finally {
// setIsLoading(false);
// }
// };
// useEffect(() => {
// fetchUsers(currentPage, limit, statusFilter, orderBy);
// }, [currentPage, limit, statusFilter, orderBy]);
// const handleCreateUser = async (data: {
// email: string;
// password: string;
// first_name: string;
// last_name: string;
// status: 'active' | 'suspended' | 'deleted';
// auth_provider: 'local';
// role_id: string;
// }): Promise<void> => {
// try {
// setIsCreating(true);
// const response = await userService.create(data);
// const message = response.message || `User created successfully`;
// const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
// showToast.success(message, description);
// setIsModalOpen(false);
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
// } catch (err: any) {
// throw err;
// } finally {
// setIsCreating(false);
// }
// };
// // View user handler
// const handleViewUser = (userId: string): void => {
// setSelectedUserId(userId);
// setViewModalOpen(true);
// };
// // Edit user handler
// const handleEditUser = (userId: string, userName: string): void => {
// setSelectedUserId(userId);
// setSelectedUserName(userName);
// setEditModalOpen(true);
// };
// // Update user handler
// const handleUpdateUser = async (
// id: string,
// data: {
// email: string;
// first_name: string;
// last_name: string;
// status: 'active' | 'suspended' | 'deleted';
// tenant_id: string;
// role_id: string;
// }
// ): Promise<void> => {
// try {
// setIsUpdating(true);
// const response = await userService.update(id, data);
// const message = response.message || `User updated successfully`;
// const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been updated`;
// showToast.success(message, description);
// setEditModalOpen(false);
// setSelectedUserId(null);
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
// } catch (err: any) {
// throw err;
// } finally {
// setIsUpdating(false);
// }
// };
// // Delete user handler
// const handleDeleteUser = (userId: string, userName: string): void => {
// setSelectedUserId(userId);
// setSelectedUserName(userName);
// setDeleteModalOpen(true);
// };
// // Confirm delete handler
// const handleConfirmDelete = async (): Promise<void> => {
// if (!selectedUserId) return;
// try {
// setIsDeleting(true);
// await userService.delete(selectedUserId);
// setDeleteModalOpen(false);
// setSelectedUserId(null);
// setSelectedUserName('');
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
// } catch (err: any) {
// throw err;
// } finally {
// setIsDeleting(false);
// }
// };
// // Load user for view/edit
// const loadUser = async (id: string): Promise<User> => {
// const response = await userService.getById(id);
// return response.data;
// };
// // Define table columns
// const columns: Column<User>[] = [
// {
// key: 'name',
// label: 'User Name',
// render: (user) => (
// <div className="flex items-center gap-3">
// <div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
// <span className="text-xs font-normal text-[#9aa6b2]">
// {getUserInitials(user.first_name, user.last_name)}
// </span>
// </div>
// <span className="text-sm font-normal text-[#0f1724]">
// {user.first_name} {user.last_name}
// </span>
// </div>
// ),
// mobileLabel: 'Name',
// },
// {
// key: 'email',
// label: 'Email',
// render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.email}</span>,
// },
// {
// key: 'status',
// label: 'Status',
// render: (user) => (
// <StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
// ),
// },
// {
// key: 'auth_provider',
// label: 'Auth Provider',
// render: (user) => (
// <span className="text-sm font-normal text-[#0f1724]">{user.auth_provider}</span>
// ),
// },
// {
// key: 'created_at',
// label: 'Joined Date',
// render: (user) => (
// <span className="text-sm font-normal text-[#6b7280]">{formatDate(user.created_at)}</span>
// ),
// mobileLabel: 'Joined',
// },
// {
// key: 'actions',
// label: 'Actions',
// align: 'right',
// render: (user) => (
// <div className="flex justify-end">
// <ActionDropdown
// onView={() => handleViewUser(user.id)}
// onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
// onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
// />
// </div>
// ),
// },
// ];
// // Mobile card renderer
// const mobileCardRenderer = (user: User) => (
// <div className="p-4">
// <div className="flex items-start justify-between gap-3 mb-3">
// <div className="flex items-center gap-3 flex-1 min-w-0">
// <div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
// <span className="text-xs font-normal text-[#9aa6b2]">
// {getUserInitials(user.first_name, user.last_name)}
// </span>
// </div>
// <div className="flex-1 min-w-0">
// <h3 className="text-sm font-medium text-[#0f1724] truncate">
// {user.first_name} {user.last_name}
// </h3>
// <p className="text-xs text-[#6b7280] mt-0.5 truncate">{user.email}</p>
// </div>
// </div>
// <ActionDropdown
// onView={() => handleViewUser(user.id)}
// onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
// onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
// />
// </div>
// <div className="grid grid-cols-2 gap-3 text-xs">
// <div>
// <span className="text-[#9aa6b2]">Status:</span>
// <div className="mt-1">
// <StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
// </div>
// </div>
// <div>
// <span className="text-[#9aa6b2]">Auth Provider:</span>
// <p className="text-[#0f1724] font-normal mt-1">{user.auth_provider}</p>
// </div>
// <div>
// <span className="text-[#9aa6b2]">Joined:</span>
// <p className="text-[#6b7280] font-normal mt-1">{formatDate(user.created_at)}</p>
// </div>
// </div>
// </div>
// );
// return (
// <Layout
// currentPage="Users"
// pageHeader={{
// title: 'User List',
// description: 'View and manage all users in your QAssure platform from a single place.',
// }}
// >
// {/* Table Container */}
// <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
// {/* Table Header with Filters */}
// <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
// {/* Filters */}
// <div className="flex flex-wrap items-center gap-3">
// {/* Status Filter */}
// <FilterDropdown
// label="Status"
// options={[
// { value: 'active', label: 'Active' },
// { value: 'pending_verification', label: 'Pending Verification' },
// { value: 'inactive', label: 'Inactive' },
// { value: 'suspended', label: 'Suspended' },
// { value: 'deleted', label: 'Deleted' },
// ]}
// value={statusFilter}
// onChange={(value) => {
// setStatusFilter(value as string | null);
// setCurrentPage(1); // Reset to first page when filter changes
// }}
// placeholder="All"
// />
// {/* Sort Filter */}
// <FilterDropdown
// label="Sort by"
// options={[
// { value: ['first_name', 'asc'], label: 'First Name (A-Z)' },
// { value: ['first_name', 'desc'], label: 'First Name (Z-A)' },
// { value: ['last_name', 'asc'], label: 'Last Name (A-Z)' },
// { value: ['last_name', 'desc'], label: 'Last Name (Z-A)' },
// { value: ['email', 'asc'], label: 'Email (A-Z)' },
// { value: ['email', 'desc'], label: 'Email (Z-A)' },
// { value: ['created_at', 'asc'], label: 'Created (Oldest)' },
// { value: ['created_at', 'desc'], label: 'Created (Newest)' },
// { value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
// { value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
// ]}
// value={orderBy}
// onChange={(value) => {
// setOrderBy(value as string[] | null);
// setCurrentPage(1); // Reset to first page when sort changes
// }}
// placeholder="Default"
// showIcon
// icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
// />
// </div>
// {/* Actions */}
// <div className="flex items-center gap-2">
// {/* Export Button */}
// <button
// type="button"
// className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
// >
// <Download className="w-3.5 h-3.5" />
// <span>Export</span>
// </button>
// {/* New User Button */}
// <PrimaryButton
// size="default"
// className="flex items-center gap-2"
// onClick={() => setIsModalOpen(true)}
// >
// <Plus className="w-3.5 h-3.5" />
// <span className="text-xs">New User</span>
// </PrimaryButton>
// </div>
// </div>
// {/* Data Table */}
// <DataTable
// data={users}
// columns={columns}
// keyExtractor={(user) => user.id}
// mobileCardRenderer={mobileCardRenderer}
// emptyMessage="No users found"
// isLoading={isLoading}
// error={error}
// />
// {/* Table Footer with Pagination */}
// {pagination.total > 0 && (
// <Pagination
// currentPage={currentPage}
// totalPages={pagination.totalPages}
// totalItems={pagination.total}
// limit={limit}
// onPageChange={(page: number) => {
// setCurrentPage(page);
// }}
// onLimitChange={(newLimit: number) => {
// setLimit(newLimit);
// setCurrentPage(1); // Reset to first page when limit changes
// }}
// />
// )}
// </div>
// {/* New User Modal */}
// <NewUserModal
// isOpen={isModalOpen}
// onClose={() => setIsModalOpen(false)}
// onSubmit={handleCreateUser}
// isLoading={isCreating}
// />
// {/* View User Modal */}
// <ViewUserModal
// isOpen={viewModalOpen}
// onClose={() => {
// setViewModalOpen(false);
// setSelectedUserId(null);
// }}
// userId={selectedUserId}
// onLoadUser={loadUser}
// />
// {/* Edit User Modal */}
// <EditUserModal
// isOpen={editModalOpen}
// onClose={() => {
// setEditModalOpen(false);
// setSelectedUserId(null);
// setSelectedUserName('');
// }}
// userId={selectedUserId}
// onLoadUser={loadUser}
// onSubmit={handleUpdateUser}
// isLoading={isUpdating}
// />
// {/* Delete Confirmation Modal */}
// <DeleteConfirmationModal
// isOpen={deleteModalOpen}
// onClose={() => {
// setDeleteModalOpen(false);
// setSelectedUserId(null);
// setSelectedUserName('');
// }}
// onConfirm={handleConfirmDelete}
// title="Delete User"
// message="Are you sure you want to delete this user"
// itemName={selectedUserName}
// isLoading={isDeleting}
// />
// </Layout>
// );
// };
// export default Users;

View File

@ -1,7 +1,140 @@
import { Layout } from '@/components/layout/Layout'
import { type ReactElement } from 'react'
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import {
StatusBadge,
DataTable,
type Column,
} from '@/components/shared';
import { useAppSelector } from '@/hooks/redux-hooks';
import { tenantService } from '@/services/tenant-service';
import type { AssignedModule } from '@/types/tenant';
// Helper function to get status badge variant
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
if (!status) return 'process';
switch (status.toLowerCase()) {
case 'running':
case 'active':
return 'success';
case 'stopped':
case 'failed':
return 'failure';
default:
return 'process';
}
};
const Modules = (): ReactElement => {
const tenantId = useAppSelector((state) => state.auth.tenantId);
const [modules, setModules] = useState<AssignedModule[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetchTenantModules = async (): Promise<void> => {
if (!tenantId) {
setError('Tenant ID not found');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setError(null);
const response = await tenantService.getById(tenantId);
if (response.success && response.data.assignedModules) {
setModules(response.data.assignedModules);
} else {
setError('Failed to load modules');
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load modules');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTenantModules();
}, [tenantId]);
// Define table columns
const columns: Column<AssignedModule>[] = [
{
key: 'module_id',
label: 'Module ID',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724] font-mono">{module.module_id}</span>
),
mobileLabel: 'ID',
},
{
key: 'name',
label: 'Name',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724]">{module.name}</span>
),
},
{
key: 'version',
label: 'Version',
render: (module) => (
<span className="text-sm font-normal text-[#0f1724]">{module.version}</span>
),
},
{
key: 'status',
label: 'Status',
render: (module) => (
<StatusBadge variant={getStatusVariant(module.status)}>
{module.status || 'Unknown'}
</StatusBadge>
),
},
{
key: 'base_url',
label: 'Base URL',
render: (module) => (
<span className="text-sm font-normal text-[#6b7280] font-mono truncate max-w-[200px]">
{module.base_url || 'N/A'}
</span>
),
mobileLabel: 'URL',
},
];
// Mobile card renderer
const mobileCardRenderer = (module: AssignedModule) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{module.name}</h3>
<p className="text-xs text-[#6b7280] mt-0.5 truncate font-mono">{module.module_id}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Version:</span>
<p className="text-[#0f1724] font-normal mt-1">{module.version}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Status:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.status)}>
{module.status || 'Unknown'}
</StatusBadge>
</div>
</div>
<div className="col-span-2">
<span className="text-[#9aa6b2]">Base URL:</span>
<p className="text-[#0f1724] font-normal mt-1 font-mono truncate">
{module.base_url || 'N/A'}
</p>
</div>
</div>
</div>
);
return (
<Layout
currentPage="Modules"
@ -10,10 +143,21 @@ const Modules = (): ReactElement => {
description: 'View and manage all system modules registered in the QAssure platform.',
}}
>
<div>Modules</div>
{/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden w-full max-w-full">
{/* Data Table */}
<DataTable
data={modules}
columns={columns}
keyExtractor={(module) => module.id}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No modules assigned to this tenant"
isLoading={isLoading}
error={error}
/>
</div>
</Layout>
)
}
);
};
export default Modules
export default Modules;

View File

@ -18,6 +18,7 @@ import { roleService } from '@/services/role-service';
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
import { showToast } from '@/utils/toast';
import { NewRoleModal } from '@/components/shared/NewRoleModal';
import { usePermissions } from '@/hooks/usePermissions';
// Helper function to format date
const formatDate = (dateString: string): string => {
@ -40,6 +41,7 @@ const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
};
const Roles = (): ReactElement => {
const { canCreate, canUpdate, canDelete } = usePermissions();
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@ -240,8 +242,8 @@ const Roles = (): ReactElement => {
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)}
onDelete={() => handleDeleteRole(role.id, role.name)}
onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
/>
</div>
),
@ -258,8 +260,8 @@ const Roles = (): ReactElement => {
</div>
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)}
onDelete={() => handleDeleteRole(role.id, role.name)}
onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
@ -349,14 +351,16 @@ const Roles = (): ReactElement => {
</button>
{/* New Role Button */}
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New Role</span>
</PrimaryButton>
{canCreate('roles') && (
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New Role</span>
</PrimaryButton>
)}
</div>
</div>

View File

@ -1,5 +1,5 @@
import { Layout } from '@/components/layout/Layout';
import { ImageIcon, Loader2 } from 'lucide-react';
import { ImageIcon, Loader2, X } from 'lucide-react';
import { useState, useEffect, type ReactElement } from 'react';
import { useAppSelector, useAppDispatch } from '@/hooks/redux-hooks';
import { tenantService } from '@/services/tenant-service';
@ -38,6 +38,8 @@ const Settings = (): ReactElement => {
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [faviconError, setFaviconError] = useState<string | null>(null);
// Fetch tenant data on mount
useEffect(() => {
@ -66,12 +68,14 @@ const Settings = (): ReactElement => {
if (tenantData.logo_file_path) {
setLogoFileUrl(tenantData.logo_file_path);
setLogoFilePath(tenantData.logo_file_path);
setLogoError(null); // Clear error if existing logo is found
}
// Set favicon
if (tenantData.favicon_file_path) {
setFaviconFileUrl(tenantData.favicon_file_path);
setFaviconFilePath(tenantData.favicon_file_path);
setFaviconError(null); // Clear error if existing favicon is found
}
}
} catch (err: any) {
@ -133,6 +137,7 @@ const Settings = (): ReactElement => {
const response = await fileService.uploadSimple(file);
setLogoFilePath(response.data.file_url);
setLogoFileUrl(response.data.file_url);
setLogoError(null); // Clear error on successful upload
showToast.success('Logo uploaded successfully');
} catch (err: any) {
const errorMessage =
@ -182,6 +187,7 @@ const Settings = (): ReactElement => {
const response = await fileService.uploadSimple(file);
setFaviconFilePath(response.data.file_url);
setFaviconFileUrl(response.data.file_url);
setFaviconError(null); // Clear error on successful upload
showToast.success('Favicon uploaded successfully');
} catch (err: any) {
const errorMessage =
@ -200,9 +206,55 @@ const Settings = (): ReactElement => {
}
};
const handleDeleteLogo = (): void => {
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
}
setLogoFile(null);
setLogoPreviewUrl(null);
setLogoFileUrl(null);
setLogoFilePath(null);
setLogoError(null); // Clear error on delete
// Reset the file input
const fileInput = document.getElementById('logo-upload') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
};
const handleDeleteFavicon = (): void => {
if (faviconPreviewUrl) {
URL.revokeObjectURL(faviconPreviewUrl);
}
setFaviconFile(null);
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
setFaviconError(null); // Clear error on delete
// Reset the file input
const fileInput = document.getElementById('favicon-upload') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
};
const handleSave = async (): Promise<void> => {
if (!tenantId || !tenant) return;
// Validate logo and favicon are uploaded
setLogoError(null);
setFaviconError(null);
if (!logoFilePath) {
setLogoError('Logo is required');
return;
}
if (!faviconFilePath) {
setFaviconError('Favicon is required');
return;
}
try {
setIsSaving(true);
setError(null);
@ -333,7 +385,9 @@ const Settings = (): ReactElement => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
<label className="text-sm font-medium text-[#0f1724]">
Company Logo <span className="text-[#e02424]">*</span>
</label>
<label
htmlFor="logo-upload"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
@ -358,13 +412,18 @@ const Settings = (): ReactElement => {
disabled={isUploadingLogo}
/>
</label>
{logoFile && (
{logoError && (
<p className="text-sm text-[#ef4444]">{logoError}</p>
)}
{(logoFile || logoFileUrl) && (
<div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]">
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
{logoFile && (
<div className="text-xs text-[#6b7280]">
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
)}
{(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2">
<div className="mt-2 relative inline-block">
<img
src={logoPreviewUrl || logoFileUrl || ''}
alt="Logo preview"
@ -378,25 +437,25 @@ const Settings = (): ReactElement => {
});
}}
/>
<button
type="button"
onClick={handleDeleteLogo}
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
aria-label="Delete logo"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
)}
{!logoFile && logoFileUrl && (
<div className="mt-2">
<img
src={logoFileUrl}
alt="Current logo"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', maxHeight: '80px' }}
/>
</div>
)}
</div>
{/* Favicon */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
<label className="text-sm font-medium text-[#0f1724]">
Favicon <span className="text-[#e02424]">*</span>
</label>
<label
htmlFor="favicon-upload"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
@ -421,13 +480,18 @@ const Settings = (): ReactElement => {
disabled={isUploadingFavicon}
/>
</label>
{faviconFile && (
{faviconError && (
<p className="text-sm text-[#ef4444]">{faviconError}</p>
)}
{(faviconFile || faviconFileUrl) && (
<div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]">
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
{faviconFile && (
<div className="text-xs text-[#6b7280]">
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
)}
{(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2">
<div className="mt-2 relative inline-block">
<img
src={faviconPreviewUrl || faviconFileUrl || ''}
alt="Favicon preview"
@ -441,20 +505,18 @@ const Settings = (): ReactElement => {
});
}}
/>
<button
type="button"
onClick={handleDeleteFavicon}
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
aria-label="Delete favicon"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
)}
{!faviconFile && faviconFileUrl && (
<div className="mt-2">
<img
src={faviconFileUrl}
alt="Current favicon"
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', width: '64px', height: '64px' }}
/>
</div>
)}
</div>
</div>

View File

@ -18,6 +18,7 @@ import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { userService } from '@/services/user-service';
import type { User } from '@/types/user';
import { showToast } from '@/utils/toast';
import { usePermissions } from '@/hooks/usePermissions';
// Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => {
@ -49,6 +50,7 @@ const getStatusVariant = (status: string): 'success' | 'failure' | 'process' =>
};
const Users = (): ReactElement => {
const { canCreate, canUpdate, canDelete } = usePermissions();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@ -262,8 +264,8 @@ const Users = (): ReactElement => {
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
onEdit={canUpdate('users') ? () => handleEditUser(user.id, `${user.first_name} ${user.last_name}`) : undefined}
onDelete={canDelete('users') ? () => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`) : undefined}
/>
</div>
),
@ -289,8 +291,8 @@ const Users = (): ReactElement => {
</div>
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
onEdit={canUpdate('users') ? () => handleEditUser(user.id, `${user.first_name} ${user.last_name}`) : undefined}
onDelete={canDelete('users') ? () => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`) : undefined}
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
@ -382,14 +384,16 @@ const Users = (): ReactElement => {
</button>
{/* New User Button */}
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New User</span>
</PrimaryButton>
{canCreate('users') && (
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New User</span>
</PrimaryButton>
)}
</div>
</div>

View File

@ -1,12 +1,12 @@
import Dashboard from '@/pages/Dashboard';
import Tenants from '@/pages/Tenants';
import CreateTenantWizard from '@/pages/CreateTenantWizard';
import EditTenant from '@/pages/EditTenant';
import TenantDetails from '@/pages/TenantDetails';
import Users from '@/pages/Users';
import Roles from '@/pages/Roles';
import Modules from '@/pages/Modules';
import AuditLogs from '@/pages/AuditLogs';
import Dashboard from '@/pages/superadmin/Dashboard';
import Tenants from '@/pages/superadmin/Tenants';
import CreateTenantWizard from '@/pages/superadmin/CreateTenantWizard';
import EditTenant from '@/pages/superadmin/EditTenant';
import TenantDetails from '@/pages/superadmin/TenantDetails';
// import Users from '@/pages/superadmin/Users';
// import Roles from '@/pages/superadmin/Roles';
import Modules from '@/pages/superadmin/Modules';
import AuditLogs from '@/pages/superadmin/AuditLogs';
import type { ReactElement } from 'react';
export interface RouteConfig {
@ -36,14 +36,14 @@ export const superAdminRoutes: RouteConfig[] = [
path: '/tenants/:id',
element: <TenantDetails />,
},
{
path: '/users',
element: <Users />,
},
{
path: '/roles',
element: <Roles />,
},
// {
// path: '/users',
// element: <Users />,
// },
// {
// path: '/roles',
// element: <Roles />,
// },
{
path: '/modules',
element: <Modules />,

View File

@ -44,6 +44,20 @@ export const moduleService = {
const response = await apiClient.get<ModulesResponse>(`/modules?${params.toString()}`);
return response.data;
},
getAvailable: async (
page: number = 1,
limit: number = 100,
tenantId?: string | null
): Promise<ModulesResponse> => {
const params = new URLSearchParams();
params.append('page', String(page));
params.append('limit', String(limit));
if (tenantId) {
params.append('tenant_id', tenantId);
}
const response = await apiClient.get<ModulesResponse>(`/modules/available?${params.toString()}`);
return response.data;
},
getById: async (id: string): Promise<GetModuleResponse> => {
const response = await apiClient.get<GetModuleResponse>(`/modules/${id}`);
return response.data;

View File

@ -7,6 +7,7 @@ export interface Role {
is_system?: boolean;
tenant_id?: string | null;
module_ids?: string[] | null;
modules?: string[] | null;
permissions?: Permission[] | null;
created_at: string;
updated_at: string;
@ -37,6 +38,7 @@ export interface CreateRoleRequest {
description: string;
tenant_id?: string | null;
module_ids?: string[] | null;
modules?: string[] | null;
permissions?: Permission[] | null;
}
@ -57,6 +59,7 @@ export interface UpdateRoleRequest {
description: string;
tenant_id?: string | null;
module_ids?: string[] | null;
modules?: string[] | null;
permissions?: Permission[] | null;
}