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" /> <div className="absolute top-0 left-[-80px] w-full md:left-[-80px] md:w-[1440px] h-full bg-[#f6f9ff] z-0" />
{/* Content Wrapper */} {/* 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 */} {/* Mobile Overlay */}
{isSidebarOpen && ( {isSidebarOpen && (
<div <div
@ -46,12 +46,12 @@ export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: Layou
<Sidebar isOpen={isSidebarOpen} onClose={closeSidebar} /> <Sidebar isOpen={isSidebarOpen} onClose={closeSidebar} />
{/* Main Content */} {/* 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 */} {/* Top Header */}
<Header currentPage={currentPage} breadcrumbs={breadcrumbs} onMenuClick={toggleSidebar} /> <Header currentPage={currentPage} breadcrumbs={breadcrumbs} onMenuClick={toggleSidebar} />
{/* Main Content Area */} {/* 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 */} {/* Page Header */}
{pageHeader && ( {pageHeader && (
<PageHeader <PageHeader

View File

@ -18,6 +18,10 @@ interface MenuItem {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
label: string; label: string;
path: string; path: string;
requiredPermission?: {
resource: string;
action?: string; // If not provided, checks for '*' or 'read'
};
} }
interface SidebarProps { interface SidebarProps {
@ -29,32 +33,32 @@ interface SidebarProps {
const superAdminPlatformMenu: MenuItem[] = [ const superAdminPlatformMenu: MenuItem[] = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' }, { icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
{ icon: Building2, label: 'Tenants', path: '/tenants' }, { icon: Building2, label: 'Tenants', path: '/tenants' },
{ icon: Users, label: 'User Management', path: '/users' }, // { icon: Users, label: 'User Management', path: '/users' },
{ icon: Shield, label: 'Roles', path: '/roles' }, // { icon: Shield, label: 'Roles', path: '/roles' },
{ icon: Package, label: 'Modules', path: '/modules' }, { icon: Package, label: 'Modules', path: '/modules' },
]; ];
const superAdminSystemMenu: MenuItem[] = [ const superAdminSystemMenu: MenuItem[] = [
{ icon: FileText, label: 'Audit Logs', path: '/audit-logs' }, { icon: FileText, label: 'Audit Logs', path: '/audit-logs' },
{ icon: Settings, label: 'Settings', path: '/settings' }, // { icon: Settings, label: 'Settings', path: '/settings' },
]; ];
// Tenant Admin menu items // Tenant Admin menu items
const tenantAdminPlatformMenu: MenuItem[] = [ const tenantAdminPlatformMenu: MenuItem[] = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' }, { icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' },
{ icon: Shield, label: 'Roles', path: '/tenant/roles' }, { icon: Shield, label: 'Roles', path: '/tenant/roles', requiredPermission: { resource: 'roles' } },
{ icon: Users, label: 'Users', path: '/tenant/users' }, { icon: Users, label: 'Users', path: '/tenant/users', requiredPermission: { resource: 'users' } },
{ icon: Package, label: 'Modules', path: '/tenant/modules' }, { icon: Package, label: 'TenantModules', path: '/tenant/modules', requiredPermission: { resource: 'tenants' } },
]; ];
const tenantAdminSystemMenu: MenuItem[] = [ const tenantAdminSystemMenu: MenuItem[] = [
{ icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs' }, { icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs', requiredPermission: { resource: 'audit_logs' } },
{ icon: Settings, label: 'Settings', path: '/tenant/settings' }, { icon: Settings, label: 'Settings', path: '/tenant/settings', requiredPermission: { resource: 'tenants' } },
]; ];
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => { export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const location = useLocation(); const location = useLocation();
const { roles } = useAppSelector((state) => state.auth); const { roles, permissions } = useAppSelector((state) => state.auth);
const { theme, logoUrl } = useAppSelector((state) => state.theme); const { theme, logoUrl } = useAppSelector((state) => state.theme);
// Fetch theme for tenant admin // Fetch theme for tenant admin
@ -79,12 +83,56 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
useTenantTheme(); useTenantTheme();
} }
// Select menu items based on role // Helper function to check if user has permission for a resource
const platformMenu = isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu; const hasPermission = (resource: string, requiredAction?: string): boolean => {
const systemMenu = isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu; 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[] }) => ( 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="flex flex-col gap-1">
<div className="pb-1 px-3"> <div className="pb-1 px-3">
<div className="text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]"> <div className="text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]">
@ -193,9 +241,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</aside> </aside>
{/* Desktop Sidebar */} {/* 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 */} {/* 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"> <div className="flex gap-3 items-center px-2">
{!isSuperAdmin && logoUrl ? ( {!isSuperAdmin && logoUrl ? (
<img <img
@ -225,13 +273,17 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</div> </div>
{/* Platform Menu */} {/* Platform Menu */}
<MenuSection title="Platform" items={platformMenu} /> {platformMenu.length > 0 && (
<MenuSection title="Platform" items={platformMenu} />
)}
{/* System Menu */} {/* System Menu */}
<MenuSection title="System" items={systemMenu} /> {systemMenu.length > 0 && (
<MenuSection title="System" items={systemMenu} />
)}
{/* Support Center */} {/* 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"> <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]" /> <HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" />
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span> <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 { Modal, FormField, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
import type { Role, UpdateRoleRequest } from '@/types/role'; import type { Role, UpdateRoleRequest } from '@/types/role';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
import { moduleService } from '@/services/module-service';
// Utility function to generate code from name // Utility function to generate code from name
const generateCodeFromName = (name: string): string => { 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)" "Code must be lowercase and use '_' for separation (e.g. abc_def)"
), ),
description: z.string().min(1, 'Description is required'), 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({ permissions: z.array(z.object({
resource: z.string(), resource: z.string(),
action: z.string(), action: z.string(),
@ -101,11 +102,11 @@ export const EditRoleModal = ({
const loadedRoleIdRef = useRef<string | null>(null); const loadedRoleIdRef = useRef<string | null>(null);
const permissions = useAppSelector((state) => state.auth.permissions); const permissions = useAppSelector((state) => state.auth.permissions);
const roles = useAppSelector((state) => state.auth.roles); 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 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 [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 [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
const { const {
@ -120,7 +121,7 @@ export const EditRoleModal = ({
} = useForm<EditRoleFormData>({ } = useForm<EditRoleFormData>({
resolver: zodResolver(editRoleSchema), resolver: zodResolver(editRoleSchema),
defaultValues: { defaultValues: {
module_ids: [], modules: [],
permissions: [], permissions: [],
}, },
}); });
@ -135,25 +136,18 @@ export const EditRoleModal = ({
} }
}, [nameValue, setValue]); }, [nameValue, setValue]);
// Load modules from tenant assignedModules // Load available modules from /modules/available endpoint
const loadModules = async (page: number, limit: number) => { // For super_admin, send tenant_id if defaultTenantId is provided
const assignedModules = tenant?.assignedModules || []; // For tenant users, send tenant_id from auth state
const startIndex = (page - 1) * limit; const loadAvailableModules = async (page: number, limit: number) => {
const endIndex = startIndex + limit; const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth;
const paginatedModules = assignedModules.slice(startIndex, endIndex); const response = await moduleService.getAvailable(page, limit, tenantId);
return { return {
options: paginatedModules.map((module) => ({ options: response.data.map((module) => ({
value: module.id, value: module.id,
label: module.name, label: module.name,
})), })),
pagination: { pagination: response.pagination,
page,
limit,
total: assignedModules.length,
totalPages: Math.ceil(assignedModules.length / limit),
hasMore: endIndex < assignedModules.length,
},
}; };
}; };
@ -248,6 +242,11 @@ export const EditRoleModal = ({
setValue('permissions', selectedPermissions.length > 0 ? selectedPermissions : []); setValue('permissions', selectedPermissions.length > 0 ? selectedPermissions : []);
}, [selectedPermissions, setValue]); }, [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 // Expand resources that have selected permissions when role is loaded
useEffect(() => { useEffect(() => {
if (selectedPermissions.length > 0 && availableResourcesAndActions.size > 0) { if (selectedPermissions.length > 0 && availableResourcesAndActions.size > 0) {
@ -292,34 +291,66 @@ export const EditRoleModal = ({
const role = await onLoadRole(roleId); const role = await onLoadRole(roleId);
loadedRoleIdRef.current = roleId; loadedRoleIdRef.current = roleId;
// Extract module_ids and permissions from role // Extract modules and permissions from role
const roleModuleIds = role.module_ids || []; const roleModules = role.modules || [];
const rolePermissions = role.permissions || []; const rolePermissions = role.permissions || [];
// Set modules if exists and user is not super_admin // Set available modules if exists
if (roleModuleIds.length > 0 && !isSuperAdmin) { if (roleModules.length > 0) {
setSelectedModules(roleModuleIds); setSelectedAvailableModules(roleModules);
setValue('module_ids', roleModuleIds); setValue('modules', roleModules);
// Load module names from tenant assignedModules // Load module names from available modules API
const assignedModules = tenant?.assignedModules || []; // Use tenant_id from auth for tenant users, or defaultTenantId for super_admin
const moduleOptions = roleModuleIds const loadModuleNames = async () => {
.map((moduleId: string) => { try {
const module = assignedModules.find((m) => m.id === moduleId); const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth;
if (module) { // Load first page of available modules to get module names
return { const availableModulesResponse = await moduleService.getAvailable(1, 100, tenantId);
value: moduleId,
label: module.name, // 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 }>; loadModuleNames();
setInitialModuleOptions(moduleOptions);
} else { } else {
// Clear modules if super_admin or no modules setSelectedAvailableModules([]);
setSelectedModules([]); setInitialAvailableModuleOptions([]);
setInitialModuleOptions([]);
} }
// Set permissions (always set, even if empty array) // Set permissions (always set, even if empty array)
@ -333,8 +364,7 @@ export const EditRoleModal = ({
name: role.name, name: role.name,
code: role.code, code: role.code,
description: role.description || '', description: role.description || '',
// Only set module_ids if user is not super_admin modules: roleModules,
module_ids: isSuperAdmin ? [] : roleModuleIds,
permissions: rolePermissions, permissions: rolePermissions,
}); });
} catch (err: any) { } catch (err: any) {
@ -348,20 +378,20 @@ export const EditRoleModal = ({
} else if (!isOpen) { } else if (!isOpen) {
// Only reset when modal is closed // Only reset when modal is closed
loadedRoleIdRef.current = null; loadedRoleIdRef.current = null;
setSelectedModules([]); setSelectedAvailableModules([]);
setSelectedPermissions([]); setSelectedPermissions([]);
setInitialModuleOptions([]); setInitialAvailableModuleOptions([]);
reset({ reset({
name: '', name: '',
code: '', code: '',
description: '', description: '',
module_ids: [], modules: [],
permissions: [], permissions: [],
}); });
setLoadError(null); setLoadError(null);
clearErrors(); 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> => { const handleFormSubmit = async (data: EditRoleFormData): Promise<void> => {
if (!roleId) return; if (!roleId) return;
@ -370,10 +400,10 @@ export const EditRoleModal = ({
try { try {
const submitData = { const submitData = {
...data, ...data,
// Include tenant_id if defaultTenantId is provided // For super_admin, always include tenant_id if defaultTenantId is provided
tenant_id: defaultTenantId || undefined, tenant_id: isSuperAdmin ? (defaultTenantId || undefined) : (defaultTenantId || undefined),
// Only include module_ids if user is not super_admin // Include modules from available modules endpoint
module_ids: isSuperAdmin ? undefined : (selectedModules.length > 0 ? selectedModules : undefined), modules: selectedAvailableModules.length > 0 ? selectedAvailableModules : undefined,
permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined, permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined,
}; };
await onSubmit(roleId, submitData as UpdateRoleRequest); await onSubmit(roleId, submitData as UpdateRoleRequest);
@ -388,7 +418,7 @@ export const EditRoleModal = ({
detail.path === 'name' || detail.path === 'name' ||
detail.path === 'code' || detail.path === 'code' ||
detail.path === 'description' || detail.path === 'description' ||
detail.path === 'module_ids' || detail.path === 'modules' ||
detail.path === 'permissions' detail.path === 'permissions'
) { ) {
setError(detail.path as keyof EditRoleFormData, { setError(detail.path as keyof EditRoleFormData, {
@ -465,7 +495,7 @@ export const EditRoleModal = ({
)} )}
{!isLoadingRole && ( {!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 */} {/* Role Name and Role Code Row */}
<div className="grid grid-cols-2 gap-5 pb-4"> <div className="grid grid-cols-2 gap-5 pb-4">
<FormField <FormField
@ -496,21 +526,19 @@ export const EditRoleModal = ({
{...register('description')} {...register('description')}
/> />
{/* Module Selection - Only show if user is not super_admin */} {/* Available Modules Selection */}
{!isSuperAdmin && ( <MultiselectPaginatedSelect
<MultiselectPaginatedSelect label="Available Modules"
label="Modules" placeholder="Select available modules"
placeholder="Select modules" value={selectedAvailableModules}
value={selectedModules} onValueChange={(values) => {
onValueChange={(values) => { setSelectedAvailableModules(values);
setSelectedModules(values); setValue('modules', values.length > 0 ? values : []);
setValue('module_ids', values.length > 0 ? values : []); }}
}} onLoadOptions={loadAvailableModules}
onLoadOptions={loadModules} initialOptions={initialAvailableModuleOptions}
initialOptions={initialModuleOptions} error={errors.modules?.message}
error={errors.module_ids?.message} />
/>
)}
{/* Permissions Section */} {/* Permissions Section */}
<div className="pb-4"> <div className="pb-4">
@ -520,7 +548,7 @@ export const EditRoleModal = ({
{errors.permissions && ( {errors.permissions && (
<p className="text-sm text-[#ef4444] mb-2">{errors.permissions.message}</p> <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 ? ( {Array.from(availableResourcesAndActions.entries()).length === 0 ? (
<p className="text-sm text-[#6b7280]">No permissions available</p> <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"> <div className="flex flex-col gap-2 pb-4">
<label <label
htmlFor={fieldId} 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> <span>{label}</span>
{required && <span className="text-[#e02424] text-[8px]">*</span>} {required && <span className="text-[#e02424]">*</span>}
</label> </label>
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button

View File

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

View File

@ -12,9 +12,6 @@ export { DataTable } from './DataTable';
export type { Column } from './DataTable'; export type { Column } from './DataTable';
export { Pagination } from './Pagination'; export { Pagination } from './Pagination';
export { FilterDropdown } from './FilterDropdown'; export { FilterDropdown } from './FilterDropdown';
// export { NewTenantModal } from './NewTenantModal';
export { ViewTenantModal } from './ViewTenantModal';
export { EditTenantModal } from './EditTenantModal';
export { DeleteConfirmationModal } from './DeleteConfirmationModal'; export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { NewUserModal } from './NewUserModal'; export { NewUserModal } from './NewUserModal';
export { ViewUserModal } from './ViewUserModal'; export { ViewUserModal } from './ViewUserModal';
@ -22,10 +19,6 @@ export { EditUserModal } from './EditUserModal';
export { NewRoleModal } from './NewRoleModal'; export { NewRoleModal } from './NewRoleModal';
export { ViewRoleModal } from './ViewRoleModal'; export { ViewRoleModal } from './ViewRoleModal';
export { EditRoleModal } from './EditRoleModal'; export { EditRoleModal } from './EditRoleModal';
export { ViewModuleModal } from './ViewModuleModal';
export { NewModuleModal } from './NewModuleModal';
export { ViewAuditLogModal } from './ViewAuditLogModal'; export { ViewAuditLogModal } from './ViewAuditLogModal';
export { PageHeader } from './PageHeader'; export { PageHeader } from './PageHeader';
export type { TabItem } 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 { moduleService } from '@/services/module-service';
import { fileService } from '@/services/file-service'; import { fileService } from '@/services/file-service';
import { showToast } from '@/utils/toast'; 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 // Step 1: Tenant Details Schema - matches NewTenantModal
const tenantDetailsSchema = z.object({ const tenantDetailsSchema = z.object({
@ -52,12 +52,10 @@ const contactDetailsSchema = z
.refine( .refine(
(val) => { (val) => {
if (!val || val.trim() === '') return true; // Optional field, empty is valid 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. return /^\d{10}$/.test(val);
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, ''));
}, },
{ {
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'), address_line1: z.string().min(1, 'Address is required'),
@ -66,8 +64,7 @@ const contactDetailsSchema = z
state: z.string().min(1, 'State is required'), state: z.string().min(1, 'State is required'),
postal_code: z postal_code: z
.string() .string()
.min(1, 'Postal code is required') .regex(/^[1-9]\d{5}$/, 'Postal code must be a valid 6-digit PIN code'),
.regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'),
country: z.string().min(1, 'Country is required'), country: z.string().min(1, 'Country is required'),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
@ -172,14 +169,16 @@ const CreateTenantWizard = (): ReactElement => {
// File upload state for branding // File upload state for branding
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = 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 [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = 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 [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null); const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false); const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
const [isUploadingFavicon, setIsUploadingFavicon] = 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 // Auto-generate slug and domain from name
const nameValue = tenantDetailsForm.watch('name'); const nameValue = tenantDetailsForm.watch('name');
@ -204,7 +203,7 @@ const CreateTenantWizard = (): ReactElement => {
const host = baseUrlObj.host; // e.g., "localhost:5173" const host = baseUrlObj.host; // e.g., "localhost:5173"
const protocol = baseUrlObj.protocol; // e.g., "http:" or "https:" const protocol = baseUrlObj.protocol; // e.g., "http:" or "https:"
const autoGeneratedDomain = `${protocol}//${slug}.${host}/tenant`; const autoGeneratedDomain = `${protocol}//${slug}.${host}/tenant`;
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false }); tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
} catch { } catch {
// Fallback if URL parsing fails // Fallback if URL parsing fails
const autoGeneratedDomain = `${baseUrlWithProtocol.replace(/\/$/, '')}/${slug}/tenant`; 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 handleSubmit = async (): Promise<void> => {
const isValid = await settingsForm.trigger(); const isValid = await settingsForm.trigger();
if (!isValid) return; 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 { try {
setIsSubmitting(true); setIsSubmitting(true);
const tenantDetails = tenantDetailsForm.getValues(); const tenantDetails = tenantDetailsForm.getValues();
@ -323,8 +368,8 @@ const CreateTenantWizard = (): ReactElement => {
primary_color: primary_color || undefined, primary_color: primary_color || undefined,
secondary_color: secondary_color || undefined, secondary_color: secondary_color || undefined,
accent_color: accent_color || undefined, accent_color: accent_color || undefined,
logo_file_path: logoFilePath || undefined, logo_file_path: logoFileUrl || undefined,
favicon_file_path: faviconFilePath || undefined, favicon_file_path: faviconFileUrl || undefined,
}, },
}, },
}; };
@ -433,26 +478,23 @@ const CreateTenantWizard = (): ReactElement => {
{steps.map((step) => ( {steps.map((step) => (
<div <div
key={step.number} key={step.number}
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${ className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${step.isActive ? 'bg-[#f5f7fa]' : ''
step.isActive ? 'bg-[#f5f7fa]' : '' }`}
}`}
> >
<div <div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${ className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${step.isActive
step.isActive ? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]' : step.isCompleted
: step.isCompleted
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]' ? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
: 'bg-white border border-[rgba(0,0,0,0.08)] text-[#6b7280]' : 'bg-white border border-[rgba(0,0,0,0.08)] text-[#6b7280]'
}`} }`}
> >
{step.number} {step.number}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div <div
className={`text-sm font-medium ${ className={`text-sm font-medium ${step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]' }`}
}`}
> >
{step.title} {step.title}
</div> </div>
@ -655,9 +697,16 @@ const CreateTenantWizard = (): ReactElement => {
<FormField <FormField
label="Contact Phone" label="Contact Phone"
type="tel" type="tel"
placeholder="Enter contact phone" placeholder="Enter 10-digit phone number"
maxLength={10}
error={contactDetailsForm.formState.errors.contact_phone?.message} 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>
</div> </div>
@ -700,7 +749,11 @@ const CreateTenantWizard = (): ReactElement => {
required required
placeholder="Enter postal code" placeholder="Enter postal code"
error={contactDetailsForm.formState.errors.postal_code?.message} 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 <FormField
label="Country" label="Country"
@ -740,7 +793,9 @@ const CreateTenantWizard = (): ReactElement => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */} {/* Company Logo */}
<div className="flex flex-col gap-2"> <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 <label
htmlFor="logo-upload-wizard" 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" 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); setIsUploadingLogo(true);
try { try {
const response = await fileService.uploadSimple(file); const response = await fileService.uploadSimple(file);
setLogoFilePath(response.data.file_path); // setLogoFilePath(response.data.file_path);
setLogoFileUrl(response.data.file_url); 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 // Keep preview URL as fallback, will be cleaned up on component unmount or file change
showToast.success('Logo uploaded successfully'); showToast.success('Logo uploaded successfully');
} catch (err: any) { } catch (err: any) {
@ -798,7 +854,7 @@ const CreateTenantWizard = (): ReactElement => {
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
setLogoPreviewUrl(null); setLogoPreviewUrl(null);
setLogoFileUrl(null); setLogoFileUrl(null);
setLogoFilePath(null); // setLogoFilePath(null);
} finally { } finally {
setIsUploadingLogo(false); setIsUploadingLogo(false);
} }
@ -807,13 +863,18 @@ const CreateTenantWizard = (): ReactElement => {
className="hidden" className="hidden"
/> />
</label> </label>
{logoFile && ( {logoError && (
<p className="text-sm text-[#ef4444]">{logoError}</p>
)}
{(logoFile || logoFileUrl) && (
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]"> {logoFile && (
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`} <div className="text-xs text-[#6b7280]">
</div> {isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
)}
{(logoPreviewUrl || logoFileUrl) && ( {(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2"> <div className="mt-2 relative inline-block">
<img <img
key={logoPreviewUrl || logoFileUrl} key={logoPreviewUrl || logoFileUrl}
src={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>
)} )}
</div> </div>
@ -843,7 +912,9 @@ const CreateTenantWizard = (): ReactElement => {
{/* Favicon */} {/* Favicon */}
<div className="flex flex-col gap-2"> <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 <label
htmlFor="favicon-upload-wizard" 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" 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); setIsUploadingFavicon(true);
try { try {
const response = await fileService.uploadSimple(file); const response = await fileService.uploadSimple(file);
setFaviconFilePath(response.data.file_path); // setFaviconFilePath(response.data.file_path);
setFaviconFileUrl(response.data.file_url); 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 // Keep preview URL as fallback, will be cleaned up on component unmount or file change
showToast.success('Favicon uploaded successfully'); showToast.success('Favicon uploaded successfully');
} catch (err: any) { } catch (err: any) {
@ -901,7 +973,7 @@ const CreateTenantWizard = (): ReactElement => {
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
setFaviconPreviewUrl(null); setFaviconPreviewUrl(null);
setFaviconFileUrl(null); setFaviconFileUrl(null);
setFaviconFilePath(null); // setFaviconFilePath(null);
} finally { } finally {
setIsUploadingFavicon(false); setIsUploadingFavicon(false);
} }
@ -910,13 +982,18 @@ const CreateTenantWizard = (): ReactElement => {
className="hidden" className="hidden"
/> />
</label> </label>
{faviconFile && ( {faviconError && (
<p className="text-sm text-[#ef4444]">{faviconError}</p>
)}
{(faviconFile || faviconFileUrl) && (
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]"> {faviconFile && (
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} <div className="text-xs text-[#6b7280]">
</div> {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
)}
{(faviconPreviewUrl || faviconFileUrl) && ( {(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2"> <div className="mt-2 relative inline-block">
<img <img
key={faviconFileUrl || faviconPreviewUrl || ''} key={faviconFileUrl || faviconPreviewUrl || ''}
src={faviconPreviewUrl || faviconFileUrl || ''} 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>
)} )}
</div> </div>
@ -1055,14 +1140,12 @@ const CreateTenantWizard = (): ReactElement => {
checked={settingsForm.watch('enable_sso')} checked={settingsForm.watch('enable_sso')}
/> />
<div <div
className={`w-10 h-5 rounded-full transition-colors ${ className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]' }`}
}`}
> >
<div <div
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${ 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'
settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0' }`}
}`}
></div> ></div>
</div> </div>
</label> </label>
@ -1084,14 +1167,12 @@ const CreateTenantWizard = (): ReactElement => {
checked={settingsForm.watch('enable_2fa')} checked={settingsForm.watch('enable_2fa')}
/> />
<div <div
className={`w-10 h-5 rounded-full transition-colors ${ className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]' }`}
}`}
> >
<div <div
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${ 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'
settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0' }`}
}`}
></div> ></div>
</div> </div>
</label> </label>

View File

@ -10,7 +10,7 @@ import { tenantService } from '@/services/tenant-service';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import { fileService } from '@/services/file-service'; import { fileService } from '@/services/file-service';
import { showToast } from '@/utils/toast'; 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 // Step 1: Tenant Details Schema
const tenantDetailsSchema = z.object({ const tenantDetailsSchema = z.object({
@ -48,12 +48,11 @@ const contactDetailsSchema = z.object({
.nullable() .nullable()
.refine( .refine(
(val) => { (val) => {
if (!val || val.trim() === '') return true; if (!val || val.trim() === '') return true; // Optional field, empty is valid
const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}[-\s\.]?[0-9]{1,9}$/; return /^\d{10}$/.test(val);
return phoneRegex.test(val.replace(/\s/g, ''));
}, },
{ {
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'), 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'), state: z.string().min(1, 'State is required'),
postal_code: z postal_code: z
.string() .string()
.min(1, 'Postal code is required') .regex(/^[1-9]\d{5}$/, 'Postal code must be a valid 6-digit PIN code'),
.regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'),
country: z.string().min(1, 'Country is required'), country: z.string().min(1, 'Country is required'),
}); });
@ -118,6 +116,8 @@ const EditTenant = (): ReactElement => {
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null); const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false); const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
const [isUploadingFavicon, setIsUploadingFavicon] = 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 // Form instances for each step
const tenantDetailsForm = useForm<TenantDetailsForm>({ const tenantDetailsForm = useForm<TenantDetailsForm>({
@ -243,19 +243,21 @@ const EditTenant = (): ReactElement => {
setLogoFilePath(logoPath); setLogoFilePath(logoPath);
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; // const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
setLogoPreviewUrl(logoPath); setLogoPreviewUrl(logoPath);
setLogoError(null); // Clear error if existing logo is found
} }
if (faviconPath) { if (faviconPath) {
setFaviconFilePath(faviconPath); setFaviconFilePath(faviconPath);
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; // const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
setFaviconFileUrl(faviconPath); setFaviconFileUrl(faviconPath);
setFaviconPreviewUrl(faviconPath); setFaviconPreviewUrl(faviconPath);
setFaviconError(null); // Clear error if existing favicon is found
} }
// Validate subscription_tier // Validate subscription_tier
const validSubscriptionTier = const validSubscriptionTier =
tenant.subscription_tier === 'basic' || tenant.subscription_tier === 'basic' ||
tenant.subscription_tier === 'professional' || tenant.subscription_tier === 'professional' ||
tenant.subscription_tier === 'enterprise' tenant.subscription_tier === 'enterprise'
? tenant.subscription_tier ? tenant.subscription_tier
: null; : null;
@ -267,9 +269,9 @@ const EditTenant = (): ReactElement => {
// Create initial options from assignedModules // Create initial options from assignedModules
const initialOptions = tenant.assignedModules const initialOptions = tenant.assignedModules
? tenant.assignedModules.map((module) => ({ ? tenant.assignedModules.map((module) => ({
value: module.id, value: module.id,
label: module.name, label: module.name,
})) }))
: []; : [];
setSelectedModules(tenantModules); 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> => { const handleSubmit = async (): Promise<void> => {
if (!id) return; if (!id) return;
const isValid = await settingsForm.trigger(); const isValid = await settingsForm.trigger();
if (!isValid) return; 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 { try {
setIsSubmitting(true); setIsSubmitting(true);
const tenantDetails = tenantDetailsForm.getValues(); const tenantDetails = tenantDetailsForm.getValues();
@ -563,26 +612,23 @@ const EditTenant = (): ReactElement => {
{steps.map((step) => ( {steps.map((step) => (
<div <div
key={step.number} key={step.number}
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${ className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${step.isActive ? 'bg-[#f5f7fa]' : ''
step.isActive ? 'bg-[#f5f7fa]' : '' }`}
}`}
> >
<div <div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${ className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${step.isActive
step.isActive ? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]' : step.isCompleted
: step.isCompleted
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]' ? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
: 'bg-white border border-[rgba(0,0,0,0.08)] text-[#6b7280]' : 'bg-white border border-[rgba(0,0,0,0.08)] text-[#6b7280]'
}`} }`}
> >
{step.number} {step.number}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div <div
className={`text-sm font-medium ${ className={`text-sm font-medium ${step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]' }`}
}`}
> >
{step.title} {step.title}
</div> </div>
@ -753,9 +799,16 @@ const EditTenant = (): ReactElement => {
<FormField <FormField
label="Contact Phone" label="Contact Phone"
type="tel" type="tel"
placeholder="Enter contact phone" placeholder="Enter 10-digit phone number"
maxLength={10}
error={contactDetailsForm.formState.errors.contact_phone?.message} 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>
</div> </div>
@ -797,8 +850,13 @@ const EditTenant = (): ReactElement => {
required required
placeholder="Enter postal code" placeholder="Enter postal code"
error={contactDetailsForm.formState.errors.postal_code?.message} 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 <FormField
label="Country" label="Country"
required required
@ -834,7 +892,9 @@ const EditTenant = (): ReactElement => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */} {/* Company Logo */}
<div className="flex flex-col gap-2"> <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 <label
htmlFor="logo-upload-edit-page" 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" 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 { try {
const response = await fileService.uploadSimple(file); const response = await fileService.uploadSimple(file);
setLogoFilePath(response.data.file_url); setLogoFilePath(response.data.file_url);
setLogoError(null); // Clear error on successful upload
showToast.success('Logo uploaded successfully'); showToast.success('Logo uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -892,13 +953,18 @@ const EditTenant = (): ReactElement => {
className="hidden" className="hidden"
/> />
</label> </label>
{logoFile && ( {logoError && (
<p className="text-sm text-[#ef4444]">{logoError}</p>
)}
{(logoFile || logoPreviewUrl) && (
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]"> {logoFile && (
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`} <div className="text-xs text-[#6b7280]">
</div> {isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
{(logoPreviewUrl ) && ( </div>
<div className="mt-2"> )}
{logoPreviewUrl && (
<div className="mt-2 relative inline-block">
<img <img
key={logoPreviewUrl} key={logoPreviewUrl}
src={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>
)} )}
</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> </div>
{/* Favicon */} {/* Favicon */}
<div className="flex flex-col gap-2"> <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 <label
htmlFor="favicon-upload-edit-page" 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" 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); const response = await fileService.uploadSimple(file);
setFaviconFilePath(response.data.file_url); setFaviconFilePath(response.data.file_url);
setFaviconFileUrl(response.data.file_url); setFaviconFileUrl(response.data.file_url);
setFaviconError(null); // Clear error on successful upload
showToast.success('Favicon uploaded successfully'); showToast.success('Favicon uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -990,34 +1057,44 @@ const EditTenant = (): ReactElement => {
className="hidden" className="hidden"
/> />
</label> </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="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]"> {faviconFile && (
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} <div className="text-xs text-[#6b7280]">
</div> {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
)}
{(faviconPreviewUrl || faviconFileUrl) && ( {(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2"> <div className="mt-2 relative inline-block">
<img <img
key={faviconPreviewUrl || faviconFileUrl} key={faviconPreviewUrl || faviconFileUrl}
src={faviconPreviewUrl || faviconFileUrl || ''} src={faviconPreviewUrl || faviconFileUrl || ''}
alt="Favicon preview" alt="Favicon preview"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', maxHeight: '80px' }} 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>
)} )}
</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>
</div> </div>
@ -1121,14 +1198,12 @@ const EditTenant = (): ReactElement => {
checked={settingsForm.watch('enable_sso')} checked={settingsForm.watch('enable_sso')}
/> />
<div <div
className={`w-10 h-5 rounded-full transition-colors ${ className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]' }`}
}`}
> >
<div <div
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${ 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'
settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0' }`}
}`}
></div> ></div>
</div> </div>
</label> </label>
@ -1146,14 +1221,12 @@ const EditTenant = (): ReactElement => {
checked={settingsForm.watch('enable_2fa')} checked={settingsForm.watch('enable_2fa')}
/> />
<div <div
className={`w-10 h-5 rounded-full transition-colors ${ className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]' }`}
}`}
> >
<div <div
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${ 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'
settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0' }`}
}`}
></div> ></div>
</div> </div>
</label> </label>

View File

@ -3,14 +3,13 @@ import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout'; import { Layout } from '@/components/layout/Layout';
import { import {
StatusBadge, StatusBadge,
ViewModuleModal,
NewModuleModal,
PrimaryButton, PrimaryButton,
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
type Column, type Column,
} from '@/components/shared'; } from '@/components/shared';
import { ViewModuleModal, NewModuleModal } from '@/components/superadmin';
import { Plus, Download, ArrowUpDown } from 'lucide-react'; import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import type { Module } from '@/types/module'; 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, StatusBadge,
DataTable, DataTable,
Pagination, Pagination,
UsersTable,
RolesTable,
type Column, type Column,
} from '@/components/shared'; } from '@/components/shared';
import { UsersTable, RolesTable } from '@/components/superadmin';
import { tenantService } from '@/services/tenant-service'; import { tenantService } from '@/services/tenant-service';
import { auditLogService } from '@/services/audit-log-service'; import { auditLogService } from '@/services/audit-log-service';
import type { Tenant, AssignedModule } from '@/types/tenant'; import type { Tenant, AssignedModule } from '@/types/tenant';

View File

@ -5,15 +5,13 @@ import {
PrimaryButton, PrimaryButton,
StatusBadge, StatusBadge,
ActionDropdown, ActionDropdown,
// NewTenantModal, // Commented out - using wizard instead
// ViewTenantModal, // Commented out - using details page instead
// EditTenantModal, // Commented out - using edit page instead
DeleteConfirmationModal, DeleteConfirmationModal,
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
type Column, type Column,
} from '@/components/shared'; } 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 { Plus, Download, ArrowUpDown } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { tenantService } from '@/services/tenant-service'; 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 { useState, useEffect } from 'react';
import { type ReactElement } 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 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 ( return (
<Layout <Layout
currentPage="Modules" currentPage="Modules"
@ -10,10 +143,21 @@ const Modules = (): ReactElement => {
description: 'View and manage all system modules registered in the QAssure platform.', description: 'View and manage all system modules registered in the QAssure platform.',
}} }}
> >
{/* Table Container */}
<div>Modules</div> <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> </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 type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
import { showToast } from '@/utils/toast'; import { showToast } from '@/utils/toast';
import { NewRoleModal } from '@/components/shared/NewRoleModal'; import { NewRoleModal } from '@/components/shared/NewRoleModal';
import { usePermissions } from '@/hooks/usePermissions';
// Helper function to format date // Helper function to format date
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
@ -40,6 +41,7 @@ const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
}; };
const Roles = (): ReactElement => { const Roles = (): ReactElement => {
const { canCreate, canUpdate, canDelete } = usePermissions();
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -240,8 +242,8 @@ const Roles = (): ReactElement => {
<div className="flex justify-end"> <div className="flex justify-end">
<ActionDropdown <ActionDropdown
onView={() => handleViewRole(role.id)} onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)} onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
onDelete={() => handleDeleteRole(role.id, role.name)} onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
/> />
</div> </div>
), ),
@ -258,8 +260,8 @@ const Roles = (): ReactElement => {
</div> </div>
<ActionDropdown <ActionDropdown
onView={() => handleViewRole(role.id)} onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)} onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
onDelete={() => handleDeleteRole(role.id, role.name)} onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-2 gap-3 text-xs">
@ -349,14 +351,16 @@ const Roles = (): ReactElement => {
</button> </button>
{/* New Role Button */} {/* New Role Button */}
<PrimaryButton {canCreate('roles') && (
size="default" <PrimaryButton
className="flex items-center gap-2" size="default"
onClick={() => setIsModalOpen(true)} className="flex items-center gap-2"
> onClick={() => setIsModalOpen(true)}
<Plus className="w-3.5 h-3.5" /> >
<span className="text-xs">New Role</span> <Plus className="w-3.5 h-3.5" />
</PrimaryButton> <span className="text-xs">New Role</span>
</PrimaryButton>
)}
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { Layout } from '@/components/layout/Layout'; 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 { useState, useEffect, type ReactElement } from 'react';
import { useAppSelector, useAppDispatch } from '@/hooks/redux-hooks'; import { useAppSelector, useAppDispatch } from '@/hooks/redux-hooks';
import { tenantService } from '@/services/tenant-service'; import { tenantService } from '@/services/tenant-service';
@ -38,6 +38,8 @@ const Settings = (): ReactElement => {
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null); const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null); const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false); 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 // Fetch tenant data on mount
useEffect(() => { useEffect(() => {
@ -66,12 +68,14 @@ const Settings = (): ReactElement => {
if (tenantData.logo_file_path) { if (tenantData.logo_file_path) {
setLogoFileUrl(tenantData.logo_file_path); setLogoFileUrl(tenantData.logo_file_path);
setLogoFilePath(tenantData.logo_file_path); setLogoFilePath(tenantData.logo_file_path);
setLogoError(null); // Clear error if existing logo is found
} }
// Set favicon // Set favicon
if (tenantData.favicon_file_path) { if (tenantData.favicon_file_path) {
setFaviconFileUrl(tenantData.favicon_file_path); setFaviconFileUrl(tenantData.favicon_file_path);
setFaviconFilePath(tenantData.favicon_file_path); setFaviconFilePath(tenantData.favicon_file_path);
setFaviconError(null); // Clear error if existing favicon is found
} }
} }
} catch (err: any) { } catch (err: any) {
@ -133,6 +137,7 @@ const Settings = (): ReactElement => {
const response = await fileService.uploadSimple(file); const response = await fileService.uploadSimple(file);
setLogoFilePath(response.data.file_url); setLogoFilePath(response.data.file_url);
setLogoFileUrl(response.data.file_url); setLogoFileUrl(response.data.file_url);
setLogoError(null); // Clear error on successful upload
showToast.success('Logo uploaded successfully'); showToast.success('Logo uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -182,6 +187,7 @@ const Settings = (): ReactElement => {
const response = await fileService.uploadSimple(file); const response = await fileService.uploadSimple(file);
setFaviconFilePath(response.data.file_url); setFaviconFilePath(response.data.file_url);
setFaviconFileUrl(response.data.file_url); setFaviconFileUrl(response.data.file_url);
setFaviconError(null); // Clear error on successful upload
showToast.success('Favicon uploaded successfully'); showToast.success('Favicon uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = 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> => { const handleSave = async (): Promise<void> => {
if (!tenantId || !tenant) return; 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 { try {
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
@ -333,7 +385,9 @@ const Settings = (): ReactElement => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */} {/* Company Logo */}
<div className="flex flex-col gap-2"> <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 <label
htmlFor="logo-upload" 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" 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} disabled={isUploadingLogo}
/> />
</label> </label>
{logoFile && ( {logoError && (
<p className="text-sm text-[#ef4444]">{logoError}</p>
)}
{(logoFile || logoFileUrl) && (
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]"> {logoFile && (
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`} <div className="text-xs text-[#6b7280]">
</div> {isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
)}
{(logoPreviewUrl || logoFileUrl) && ( {(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2"> <div className="mt-2 relative inline-block">
<img <img
src={logoPreviewUrl || logoFileUrl || ''} src={logoPreviewUrl || logoFileUrl || ''}
alt="Logo preview" 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>
)} )}
</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> </div>
{/* Favicon */} {/* Favicon */}
<div className="flex flex-col gap-2"> <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 <label
htmlFor="favicon-upload" 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" 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} disabled={isUploadingFavicon}
/> />
</label> </label>
{faviconFile && ( {faviconError && (
<p className="text-sm text-[#ef4444]">{faviconError}</p>
)}
{(faviconFile || faviconFileUrl) && (
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<div className="text-xs text-[#6b7280]"> {faviconFile && (
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} <div className="text-xs text-[#6b7280]">
</div> {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
)}
{(faviconPreviewUrl || faviconFileUrl) && ( {(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2"> <div className="mt-2 relative inline-block">
<img <img
src={faviconPreviewUrl || faviconFileUrl || ''} src={faviconPreviewUrl || faviconFileUrl || ''}
alt="Favicon preview" 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>
)} )}
</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>
</div> </div>

View File

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

View File

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

View File

@ -44,6 +44,20 @@ export const moduleService = {
const response = await apiClient.get<ModulesResponse>(`/modules?${params.toString()}`); const response = await apiClient.get<ModulesResponse>(`/modules?${params.toString()}`);
return response.data; 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> => { getById: async (id: string): Promise<GetModuleResponse> => {
const response = await apiClient.get<GetModuleResponse>(`/modules/${id}`); const response = await apiClient.get<GetModuleResponse>(`/modules/${id}`);
return response.data; return response.data;

View File

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