From bb5a086110545f1f5565359cc8abece2a52a7d64 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Thu, 29 Jan 2026 17:27:34 +0530 Subject: [PATCH] Refactor routing and role management in various components to streamline tenant and super admin navigation. Update Sidebar and Header to conditionally render menu items based on user roles. Enhance theme integration for tenant admins and adjust navigation paths in Login and Roles components. Remove TenantLogin and TenantProtectedRoute components for cleaner structure. --- src/components/NavigationInitializer.tsx | 17 + src/components/layout/Header.tsx | 10 +- src/components/layout/Layout.tsx | 4 +- src/components/layout/Sidebar.tsx | 106 +++- src/components/shared/EditTenantModal.tsx | 4 +- src/components/shared/NewUserModal.tsx | 49 +- src/components/shared/PageHeader.tsx | 19 +- src/components/shared/PrimaryButton.tsx | 51 ++ src/components/shared/RolesTable.tsx | 4 +- src/components/shared/SecondaryButton.tsx | 51 ++ src/components/shared/UsersTable.tsx | 7 +- src/hooks/useTenantTheme.ts | 35 ++ src/main.tsx | 16 +- src/pages/CreateTenantWizard.tsx | 32 +- src/pages/EditTenant.tsx | 90 ++- src/pages/Login.tsx | 4 +- src/pages/Roles.tsx | 4 +- src/pages/TenantDetails.tsx | 9 +- src/pages/tenant/AuditLogs.tsx | 390 ++++++++++++ src/pages/tenant/Dashboard.tsx | 129 ++++ src/pages/tenant/Roles.tsx | 443 ++++++++++++++ src/pages/tenant/Settings.tsx | 567 ++++++++++++++++++ src/pages/{ => tenant}/TenantLogin.tsx | 56 +- .../{ => tenant}/TenantProtectedRoute.tsx | 4 + src/pages/tenant/Users.tsx | 476 +++++++++++++++ src/routes/index.tsx | 8 +- src/routes/public-routes.tsx | 2 +- src/routes/tenant-admin-routes.tsx | 28 +- src/services/api-client.ts | 9 +- src/services/theme-service.ts | 21 + src/store/store.ts | 2 + src/store/themeSlice.ts | 139 +++++ src/types/user.ts | 2 +- src/utils/navigation.ts | 34 ++ src/utils/theme.ts | 76 +++ 35 files changed, 2764 insertions(+), 134 deletions(-) create mode 100644 src/components/NavigationInitializer.tsx create mode 100644 src/hooks/useTenantTheme.ts create mode 100644 src/pages/tenant/AuditLogs.tsx create mode 100644 src/pages/tenant/Dashboard.tsx create mode 100644 src/pages/tenant/Roles.tsx create mode 100644 src/pages/tenant/Settings.tsx rename src/pages/{ => tenant}/TenantLogin.tsx (86%) rename src/pages/{ => tenant}/TenantProtectedRoute.tsx (90%) create mode 100644 src/pages/tenant/Users.tsx create mode 100644 src/services/theme-service.ts create mode 100644 src/store/themeSlice.ts create mode 100644 src/utils/navigation.ts create mode 100644 src/utils/theme.ts diff --git a/src/components/NavigationInitializer.tsx b/src/components/NavigationInitializer.tsx new file mode 100644 index 0000000..d1f7fd0 --- /dev/null +++ b/src/components/NavigationInitializer.tsx @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { setNavigate } from '@/utils/navigation'; + +/** + * Component to initialize navigation utility for use in services/interceptors + * This should be rendered once at the app level + */ +export const NavigationInitializer = (): null => { + const navigate = useNavigate(); + + useEffect(() => { + setNavigate(navigate); + }, [navigate]); + + return null; +}; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 44c2900..104fbda 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -69,14 +69,18 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps): // Close dropdown immediately setIsDropdownOpen(false); + // Check if user is on a tenant route to determine redirect path + const isTenantRoute = window.location.pathname.startsWith('/tenant'); + const redirectPath = isTenantRoute ? '/tenant/login' : '/'; + try { // Call logout API with Bearer token const result = await dispatch(logoutAsync()).unwrap(); const message = result.message || 'Logged out successfully'; const description = result.message ? undefined : 'You have been logged out'; showToast.success(message, description); - // Clear state and redirect - navigate('/', { replace: true }); + // Clear state and redirect to appropriate login page + navigate(redirectPath, { replace: true }); } catch (error: any) { // Even if API call fails, clear local state and redirect to login console.error('Logout error:', error); @@ -86,7 +90,7 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps): // Dispatch logout action to clear local state dispatch({ type: 'auth/logout' }); showToast.success(message, description); - navigate('/', { replace: true }); + navigate(redirectPath, { replace: true }); } }; diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 5b266ec..e5620ba 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -29,10 +29,10 @@ export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: Layou return (
{/* Background */} -
+
{/* Content Wrapper */} -
+
{/* Mobile Overlay */} {isSidebarOpen && (
; @@ -23,7 +25,8 @@ interface SidebarProps { onClose: () => void; } -const platformMenu: MenuItem[] = [ +// Super Admin menu items +const superAdminPlatformMenu: MenuItem[] = [ { icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' }, { icon: Building2, label: 'Tenants', path: '/tenants' }, { icon: Users, label: 'User Management', path: '/users' }, @@ -31,13 +34,54 @@ const platformMenu: MenuItem[] = [ { icon: Package, label: 'Modules', path: '/modules' }, ]; -const systemMenu: MenuItem[] = [ +const superAdminSystemMenu: MenuItem[] = [ { icon: FileText, label: 'Audit Logs', path: '/audit-logs' }, { icon: Settings, label: 'Settings', path: '/settings' }, ]; +// Tenant Admin menu items +const tenantAdminPlatformMenu: MenuItem[] = [ + { icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' }, + { icon: Shield, label: 'Roles', path: '/tenant/roles' }, + { icon: Users, label: 'Users', path: '/tenant/users' }, + { icon: Package, label: 'Modules', path: '/tenant/modules' }, +]; + +const tenantAdminSystemMenu: MenuItem[] = [ + { icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs' }, + { icon: Settings, label: 'Settings', path: '/tenant/settings' }, +]; + export const Sidebar = ({ isOpen, onClose }: SidebarProps) => { const location = useLocation(); + const { roles } = useAppSelector((state) => state.auth); + const { theme, logoUrl } = useAppSelector((state) => state.theme); + + // Fetch theme for tenant admin + const isSuperAdminCheck = () => { + let rolesArray: string[] = []; + if (Array.isArray(roles)) { + rolesArray = roles; + } else if (typeof roles === 'string') { + try { + rolesArray = JSON.parse(roles); + } catch { + rolesArray = []; + } + } + return rolesArray.includes('super_admin'); + }; + + const isSuperAdmin = isSuperAdminCheck(); + + // Fetch theme if tenant admin + if (!isSuperAdmin) { + useTenantTheme(); + } + + // Select menu items based on role + const platformMenu = isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu; + const systemMenu = isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu; const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
@@ -64,9 +108,17 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => { className={cn( 'flex items-center gap-2.5 px-3 py-2 rounded-md transition-colors min-h-[44px]', isActive - ? 'bg-[#112868] text-[#23dce1] shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]' + ? 'shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]' : 'text-[#0f1724] hover:bg-gray-50' )} + style={ + isActive + ? { + backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868', + color: !isSuperAdmin && theme?.secondary_color ? theme.secondary_color : '#23dce1', + } + : undefined + } > {item.label} @@ -91,11 +143,29 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => { {/* Mobile Header with Close Button */}
-
+ {!isSuperAdmin && logoUrl ? ( + Logo { + e.currentTarget.style.display = 'none'; + const fallback = e.currentTarget.nextElementSibling as HTMLElement; + if (fallback) fallback.style.display = 'flex'; + }} + /> + ) : null} +
- QAssure + {(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
{tenant.domain && ( - + )}
diff --git a/src/pages/tenant/AuditLogs.tsx b/src/pages/tenant/AuditLogs.tsx new file mode 100644 index 0000000..62d6426 --- /dev/null +++ b/src/pages/tenant/AuditLogs.tsx @@ -0,0 +1,390 @@ +import { useState, useEffect } from 'react'; +import type { ReactElement } from 'react'; +import { Layout } from '@/components/layout/Layout'; +import { + ViewAuditLogModal, + DataTable, + Pagination, + FilterDropdown, + StatusBadge, + type Column, +} from '@/components/shared'; +import { Download, ArrowUpDown } from 'lucide-react'; +import { auditLogService } from '@/services/audit-log-service'; +import type { AuditLog } from '@/types/audit-log'; +import { useAppSelector } from '@/hooks/redux-hooks'; + +// 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', + hour: '2-digit', + minute: '2-digit', + }); +}; + +// Helper function to get action badge variant +const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'process' => { + const lowerAction = action.toLowerCase(); + if (lowerAction.includes('create') || lowerAction.includes('register')) return 'success'; + if (lowerAction.includes('update') || lowerAction.includes('version_update') || lowerAction.includes('login')) return 'info'; + if (lowerAction.includes('delete') || lowerAction.includes('deregister')) return 'failure'; + if (lowerAction.includes('read') || lowerAction.includes('get') || lowerAction.includes('status_change')) return 'process'; + return 'info'; +}; + +// Helper function to get method badge variant +const getMethodVariant = (method: string | null): 'success' | 'failure' | 'info' | 'process' => { + if (!method) return 'info'; + const upperMethod = method.toUpperCase(); + if (upperMethod === 'GET') return 'success'; + if (upperMethod === 'POST') return 'info'; + if (upperMethod === 'PUT' || upperMethod === 'PATCH') return 'process'; + if (upperMethod === 'DELETE') return 'failure'; + return 'info'; +}; + +// Helper function to get status badge color based on response status +const getStatusColor = (status: number | null): string => { + if (!status) return 'text-[#6b7280]'; + if (status >= 200 && status < 300) return 'text-[#10b981]'; + if (status >= 300 && status < 400) return 'text-[#f59e0b]'; + if (status >= 400) return 'text-[#ef4444]'; + return 'text-[#6b7280]'; +}; + +const AuditLogs = (): ReactElement => { + const tenantId = useAppSelector((state) => state.auth.tenantId); + const [auditLogs, setAuditLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(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 [methodFilter, setMethodFilter] = useState(null); + const [orderBy, setOrderBy] = useState(null); + + // View modal + const [viewModalOpen, setViewModalOpen] = useState(false); + const [selectedAuditLogId, setSelectedAuditLogId] = useState(null); + + const fetchAuditLogs = async ( + page: number, + itemsPerPage: number, + method: string | null = null, + sortBy: string[] | null = null + ): Promise => { + try { + setIsLoading(true); + setError(null); + const response = await auditLogService.getAll(page, itemsPerPage, method, sortBy, tenantId); + if (response.success) { + setAuditLogs(response.data); + setPagination(response.pagination); + } else { + setError('Failed to load audit logs'); + } + } catch (err: any) { + setError(err?.response?.data?.error?.message || 'Failed to load audit logs'); + } finally { + setIsLoading(false); + } + }; + + // Fetch audit logs on mount and when pagination/filters change + useEffect(() => { + if (tenantId) { + fetchAuditLogs(currentPage, limit, methodFilter, orderBy); + } + }, [currentPage, limit, methodFilter, orderBy, tenantId]); + + // View audit log handler + const handleViewAuditLog = (auditLogId: string): void => { + setSelectedAuditLogId(auditLogId); + setViewModalOpen(true); + }; + + // Load audit log for view + const loadAuditLog = async (id: string): Promise => { + const response = await auditLogService.getById(id); + return response.data; + }; + + // Define table columns + const columns: Column[] = [ + { + key: 'created_at', + label: 'Timestamp', + render: (log) => ( + {formatDate(log.created_at)} + ), + mobileLabel: 'Time', + }, + { + key: 'resource_type', + label: 'Resource Type', + render: (log) => ( + {log.resource_type} + ), + }, + { + key: 'action', + label: 'Action', + render: (log) => ( + + {log.action} + + ), + }, + { + key: 'resource_id', + label: 'Resource ID', + render: (log) => ( + + {log.resource_id || 'N/A'} + + ), + }, + { + key: 'user', + label: 'User', + render: (log) => ( + + {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'} + + ), + }, + { + key: 'request_method', + label: 'Method', + render: (log) => ( + + {log.request_method ? log.request_method.toUpperCase() : 'N/A'} + + ), + }, + { + key: 'response_status', + label: 'Status', + render: (log) => ( + + {log.response_status || 'N/A'} + + ), + }, + { + key: 'ip_address', + label: 'IP Address', + render: (log) => ( + + {log.ip_address || 'N/A'} + + ), + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (log) => ( +
+ +
+ ), + }, + ]; + + // Mobile card renderer + const mobileCardRenderer = (log: AuditLog) => ( +
+
+
+

{log.resource_type}

+
+ + {log.action} + +
+
+ +
+
+
+ Timestamp: +

{formatDate(log.created_at)}

+
+
+ User: +

+ {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'} +

+
+
+ Method: +
+ + {log.request_method ? log.request_method.toUpperCase() : 'N/A'} + +
+
+
+ Status: +

+ {log.response_status || 'N/A'} +

+
+
+ Resource ID: +

+ {log.resource_id || 'N/A'} +

+
+
+ IP Address: +

{log.ip_address || 'N/A'}

+
+
+
+ ); + + return ( + + {/* Table Container */} +
+ {/* Table Header with Filters */} +
+ {/* Filters */} +
+ {/* Method Filter */} + { + setMethodFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All" + /> + + {/* Sort Filter */} + { + setOrderBy(value as string[] | null); + setCurrentPage(1); + }} + placeholder="Default" + showIcon + icon={} + /> +
+ + {/* Actions */} +
+ {/* Export Button */} + +
+
+ + {/* Table */} + log.id} + isLoading={isLoading} + error={error} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No audit logs found" + /> + + {/* Pagination */} + {pagination.total > 0 && ( +
+ setCurrentPage(page)} + onLimitChange={(newLimit: number) => { + setLimit(newLimit); + setCurrentPage(1); + }} + /> +
+ )} +
+ + {/* View Audit Log Modal */} + { + setViewModalOpen(false); + setSelectedAuditLogId(null); + }} + auditLogId={selectedAuditLogId} + onLoadAuditLog={loadAuditLog} + /> +
+ ); +}; + +export default AuditLogs; diff --git a/src/pages/tenant/Dashboard.tsx b/src/pages/tenant/Dashboard.tsx new file mode 100644 index 0000000..180d91b --- /dev/null +++ b/src/pages/tenant/Dashboard.tsx @@ -0,0 +1,129 @@ +import { Layout } from '@/components/layout/Layout'; +import type { ReactElement } from 'react'; +import { Info, FileCheck, Briefcase, FileText, GraduationCap } from 'lucide-react'; + +interface StatCardProps { + icon: React.ComponentType<{ className?: string }>; + value: string | number; + label: string; + status: 'success' | 'process' | 'warning' | 'disabled'; + statusLabel: string; +} + +const StatCard = ({ icon: Icon, value, label, status, statusLabel }: StatCardProps): ReactElement => { + const statusConfig = { + success: { + bg: 'bg-[#f1fffb]', + dot: 'bg-[#16c784]', + text: 'text-[#16c784]', + }, + process: { + bg: 'bg-[#fff5e5]', + dot: 'bg-[#fca004]', + text: 'text-[#fca004]', + }, + warning: { + bg: 'bg-[#fdf5f4]', + dot: 'bg-[#e0352a]', + text: 'text-[#e0352a]', + }, + disabled: { + bg: 'bg-[#e5e7eb]', + dot: 'bg-[#9ca3af]', + text: 'text-[#9ca3af]', + }, + }; + + const config = statusConfig[status]; + const valueColor = status === 'warning' && label === 'Overdue Tasks' ? 'text-[#e0352a]' : 'text-[#0f1724]'; + + return ( +
+
+ {/* Header with icon and status */} +
+ +
+
+ {statusLabel} +
+
+ + {/* Value and Label */} +
+
+ {value} +
+
+ {label} +
+
+
+
+ ); +}; + +const Dashboard = (): ReactElement => { + const statCards: StatCardProps[] = [ + { + icon: Info, + value: '18', + label: 'Open CAPAs', + status: 'success', + statusLabel: 'Success', + }, + { + icon: FileCheck, + value: '7', + label: 'Pending Approvals', + status: 'process', + statusLabel: 'Process', + }, + { + icon: Briefcase, + value: '9', + label: 'Active Projects', + status: 'warning', + statusLabel: 'Warning', + }, + { + icon: Info, + value: '3', + label: 'Overdue Tasks', + status: 'warning', + statusLabel: 'Warning', + }, + { + icon: FileText, + value: '14', + label: 'Docs Pending Review', + status: 'disabled', + statusLabel: 'Disabled', + }, + { + icon: GraduationCap, + value: '94%', + label: 'Training Compliance', + status: 'success', + statusLabel: 'Success', + }, + ]; + + return ( + +
+ {statCards.map((card, index) => ( + + ))} +
+
+ ); +}; + +export default Dashboard; diff --git a/src/pages/tenant/Roles.tsx b/src/pages/tenant/Roles.tsx new file mode 100644 index 0000000..82da1b8 --- /dev/null +++ b/src/pages/tenant/Roles.tsx @@ -0,0 +1,443 @@ +import { useState, useEffect } from 'react'; +import type { ReactElement } from 'react'; +import { Layout } from '@/components/layout/Layout'; +import { + PrimaryButton, + StatusBadge, + ActionDropdown, + 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'; +import { NewRoleModal } from '@/components/shared/NewRoleModal'; + +// 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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(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(null); + const [orderBy, setOrderBy] = useState(null); + + // View, Edit, Delete modals + const [viewModalOpen, setViewModalOpen] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [selectedRoleId, setSelectedRoleId] = useState(null); + const [selectedRoleName, setSelectedRoleName] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const fetchRoles = async ( + page: number, + itemsPerPage: number, + scope: string | null = null, + sortBy: string[] | null = null + ): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + const response = await roleService.getById(id); + return response.data; + }; + + // Table columns + const columns: Column[] = [ + { + key: 'name', + label: 'Name', + render: (role) => ( + {role.name} + ), + }, + { + key: 'code', + label: 'Code', + render: (role) => ( + {role.code} + ), + }, + { + key: 'scope', + label: 'Scope', + render: (role) => ( + {role.scope} + ), + }, + { + key: 'description', + label: 'Description', + render: (role) => ( + + {role.description || 'N/A'} + + ), + }, + { + key: 'is_system', + label: 'System Role', + render: (role) => ( + + {role.is_system ? 'Yes' : 'No'} + + ), + }, + { + key: 'created_at', + label: 'Created Date', + render: (role) => ( + {formatDate(role.created_at)} + ), + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (role) => ( +
+ handleViewRole(role.id)} + onEdit={() => handleEditRole(role.id, role.name)} + onDelete={() => handleDeleteRole(role.id, role.name)} + /> +
+ ), + }, + ]; + + // Mobile card renderer + const mobileCardRenderer = (role: Role) => ( +
+
+
+

{role.name}

+

{role.code}

+
+ handleViewRole(role.id)} + onEdit={() => handleEditRole(role.id, role.name)} + onDelete={() => handleDeleteRole(role.id, role.name)} + /> +
+
+
+ Scope: +
+ {role.scope} +
+
+
+ Created: +

{formatDate(role.created_at)}

+
+ {role.description && ( +
+ Description: +

{role.description}

+
+ )} +
+
+ ); + + return ( + + {/* Table Container */} +
+ {/* Table Header with Filters */} +
+ {/* Filters */} +
+ {/* Scope Filter */} + { + setScopeFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All" + /> + + {/* Sort Filter */} + { + setOrderBy(value as string[] | null); + setCurrentPage(1); + }} + placeholder="Default" + showIcon + icon={} + /> +
+ + {/* Actions */} +
+ {/* Export Button */} + + + {/* New Role Button */} + setIsModalOpen(true)} + > + + New Role + +
+
+ + {/* Table */} + role.id} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No roles found" + isLoading={isLoading} + error={error} + /> + + {/* Table Footer with Pagination */} + {pagination.total > 0 && ( + { + setCurrentPage(page); + }} + onLimitChange={(newLimit: number) => { + setLimit(newLimit); + setCurrentPage(1); + }} + /> + )} +
+ + {/* New Role Modal */} + setIsModalOpen(false)} + onSubmit={handleCreateRole} + isLoading={isCreating} + /> + + {/* View Role Modal */} + { + setViewModalOpen(false); + setSelectedRoleId(null); + }} + roleId={selectedRoleId} + onLoadRole={loadRole} + /> + + {/* Edit Role Modal */} + { + setEditModalOpen(false); + setSelectedRoleId(null); + setSelectedRoleName(''); + }} + roleId={selectedRoleId} + onLoadRole={loadRole} + onSubmit={handleUpdateRole} + isLoading={isUpdating} + /> + + {/* Delete Confirmation Modal */} + { + setDeleteModalOpen(false); + setSelectedRoleId(null); + setSelectedRoleName(''); + }} + onConfirm={handleConfirmDelete} + title="Delete Role" + message={`Are you sure you want to delete this role`} + itemName={selectedRoleName} + isLoading={isDeleting} + /> +
+ ); +}; + +export default Roles; diff --git a/src/pages/tenant/Settings.tsx b/src/pages/tenant/Settings.tsx new file mode 100644 index 0000000..0dde87b --- /dev/null +++ b/src/pages/tenant/Settings.tsx @@ -0,0 +1,567 @@ +import { Layout } from '@/components/layout/Layout'; +import { ImageIcon, Loader2 } from 'lucide-react'; +import { useState, useEffect, type ReactElement } from 'react'; +import { useAppSelector, useAppDispatch } from '@/hooks/redux-hooks'; +import { tenantService } from '@/services/tenant-service'; +import { fileService } from '@/services/file-service'; +import { showToast } from '@/utils/toast'; +import { updateTheme } from '@/store/themeSlice'; +import { PrimaryButton } from '@/components/shared'; +import type { Tenant } from '@/types/tenant'; + +const Settings = (): ReactElement => { + const tenantId = useAppSelector((state) => state.auth.tenantId); + const dispatch = useAppDispatch(); + + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + // Tenant data + const [tenant, setTenant] = useState(null); + + // Color states + const [primaryColor, setPrimaryColor] = useState('#112868'); + const [secondaryColor, setSecondaryColor] = useState('#23DCE1'); + const [accentColor, setAccentColor] = useState('#084CC8'); + + // Logo states + const [logoFile, setLogoFile] = useState(null); + const [logoFilePath, setLogoFilePath] = useState(null); + const [logoFileUrl, setLogoFileUrl] = useState(null); + const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); + const [isUploadingLogo, setIsUploadingLogo] = useState(false); + + // Favicon states + const [faviconFile, setFaviconFile] = useState(null); + const [faviconFilePath, setFaviconFilePath] = useState(null); + const [faviconFileUrl, setFaviconFileUrl] = useState(null); + const [faviconPreviewUrl, setFaviconPreviewUrl] = useState(null); + const [isUploadingFavicon, setIsUploadingFavicon] = useState(false); + + // Fetch tenant data on mount + useEffect(() => { + const fetchTenant = async (): Promise => { + 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) { + const tenantData = response.data; + setTenant(tenantData); + + // Set colors + setPrimaryColor(tenantData.primary_color || '#112868'); + setSecondaryColor(tenantData.secondary_color || '#23DCE1'); + setAccentColor(tenantData.accent_color || '#084CC8'); + + // Set logo + if (tenantData.logo_file_path) { + setLogoFileUrl(tenantData.logo_file_path); + setLogoFilePath(tenantData.logo_file_path); + } + + // Set favicon + if (tenantData.favicon_file_path) { + setFaviconFileUrl(tenantData.favicon_file_path); + setFaviconFilePath(tenantData.favicon_file_path); + } + } + } catch (err: any) { + const errorMessage = + err?.response?.data?.error?.message || + err?.response?.data?.message || + err?.message || + 'Failed to load tenant settings'; + setError(errorMessage); + showToast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + fetchTenant(); + }, [tenantId]); + + // Cleanup preview URLs + useEffect(() => { + return () => { + if (logoPreviewUrl) { + URL.revokeObjectURL(logoPreviewUrl); + } + if (faviconPreviewUrl) { + URL.revokeObjectURL(faviconPreviewUrl); + } + }; + }, [logoPreviewUrl, faviconPreviewUrl]); + + const handleLogoChange = async (e: React.ChangeEvent): Promise => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validate file size (2MB max) + if (file.size > 2 * 1024 * 1024) { + showToast.error('Logo file size must be less than 2MB'); + return; + } + + // Validate file type + const validTypes = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/jpg']; + if (!validTypes.includes(file.type)) { + showToast.error('Logo must be PNG, SVG, or JPG format'); + return; + } + + // Revoke previous preview URL + if (logoPreviewUrl) { + URL.revokeObjectURL(logoPreviewUrl); + } + + const previewUrl = URL.createObjectURL(file); + setLogoFile(file); + setLogoPreviewUrl(previewUrl); + setIsUploadingLogo(true); + + try { + const response = await fileService.uploadSimple(file); + setLogoFilePath(response.data.file_url); + setLogoFileUrl(response.data.file_url); + showToast.success('Logo uploaded successfully'); + } catch (err: any) { + const errorMessage = + err?.response?.data?.error?.message || + err?.response?.data?.message || + err?.message || + 'Failed to upload logo. Please try again.'; + showToast.error(errorMessage); + setLogoFile(null); + URL.revokeObjectURL(previewUrl); + setLogoPreviewUrl(null); + setLogoFileUrl(null); + setLogoFilePath(null); + } finally { + setIsUploadingLogo(false); + } + }; + + const handleFaviconChange = async (e: React.ChangeEvent): Promise => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validate file size (500KB max) + if (file.size > 500 * 1024) { + showToast.error('Favicon file size must be less than 500KB'); + return; + } + + // Validate file type + const validTypes = ['image/x-icon', 'image/png', 'image/vnd.microsoft.icon']; + if (!validTypes.includes(file.type)) { + showToast.error('Favicon must be ICO or PNG format'); + return; + } + + // Revoke previous preview URL + if (faviconPreviewUrl) { + URL.revokeObjectURL(faviconPreviewUrl); + } + + const previewUrl = URL.createObjectURL(file); + setFaviconFile(file); + setFaviconPreviewUrl(previewUrl); + setIsUploadingFavicon(true); + + try { + const response = await fileService.uploadSimple(file); + setFaviconFilePath(response.data.file_url); + setFaviconFileUrl(response.data.file_url); + showToast.success('Favicon uploaded successfully'); + } catch (err: any) { + const errorMessage = + err?.response?.data?.error?.message || + err?.response?.data?.message || + err?.message || + 'Failed to upload favicon. Please try again.'; + showToast.error(errorMessage); + setFaviconFile(null); + URL.revokeObjectURL(previewUrl); + setFaviconPreviewUrl(null); + setFaviconFileUrl(null); + setFaviconFilePath(null); + } finally { + setIsUploadingFavicon(false); + } + }; + + const handleSave = async (): Promise => { + if (!tenantId || !tenant) return; + + try { + setIsSaving(true); + setError(null); + + // Build update data matching EditTenantModal format + const existingSettings = (tenant.settings as Record) || {}; + const existingContact = (existingSettings.contact as Record) || {}; + + const updateData = { + name: tenant.name, + slug: tenant.slug, + status: tenant.status, + domain: tenant.domain || null, + subscription_tier: tenant.subscription_tier || null, + max_users: tenant.max_users || null, + max_modules: tenant.max_modules || null, + settings: { + enable_sso: tenant.enable_sso || false, + enable_2fa: tenant.enable_2fa || false, + contact: existingContact, + branding: { + primary_color: primaryColor || undefined, + secondary_color: secondaryColor || undefined, + accent_color: accentColor || undefined, + logo_file_path: logoFilePath || undefined, + favicon_file_path: faviconFilePath || undefined, + }, + }, + }; + + const response = await tenantService.update(tenantId, updateData); + + if (response.success) { + showToast.success('Settings updated successfully'); + + // Update theme in Redux + dispatch( + updateTheme({ + logo_file_path: logoFilePath, + favicon_file_path: faviconFilePath, + primary_color: primaryColor, + secondary_color: secondaryColor, + accent_color: accentColor, + }) + ); + + // Update local tenant state + setTenant({ + ...tenant, + primary_color: primaryColor, + secondary_color: secondaryColor, + accent_color: accentColor, + logo_file_path: logoFilePath, + favicon_file_path: faviconFilePath, + }); + } + } catch (err: any) { + const errorMessage = + err?.response?.data?.error?.message || + err?.response?.data?.message || + err?.message || + 'Failed to update settings. Please try again.'; + setError(errorMessage); + showToast.error(errorMessage); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + if (error && !tenant) { + return ( + +
+

{error}

+
+
+ ); + } + + return ( + +
+ {error && ( +
+

{error}

+
+ )} + + {/* Branding Section */} +
+ {/* Section Header */} +
+

Branding

+

+ Customize logo, favicon, and colors for this tenant experience. +

+
+ + {/* Logo and Favicon Upload */} +
+ {/* Company Logo */} +
+ + + {logoFile && ( +
+
+ {isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`} +
+ {(logoPreviewUrl || logoFileUrl) && ( +
+ Logo preview { + console.error('Failed to load logo preview image', { + logoFileUrl, + logoPreviewUrl, + src: e.currentTarget.src, + }); + }} + /> +
+ )} +
+ )} + {!logoFile && logoFileUrl && ( +
+ Current logo +
+ )} +
+ + {/* Favicon */} +
+ + + {faviconFile && ( +
+
+ {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} +
+ {(faviconPreviewUrl || faviconFileUrl) && ( +
+ Favicon preview { + console.error('Failed to load favicon preview image', { + faviconFileUrl, + faviconPreviewUrl, + src: e.currentTarget.src, + }); + }} + /> +
+ )} +
+ )} + {!faviconFile && faviconFileUrl && ( +
+ Current favicon +
+ )} +
+
+ + {/* Primary Color */} +
+ +
+
+
+ setPrimaryColor(e.target.value)} + className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent" + placeholder="#112868" + /> +
+ setPrimaryColor(e.target.value)} + className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" + /> +
+

+ Used for navigation, headers, and key actions. +

+
+ + {/* Secondary and Accent Colors */} +
+ {/* Secondary Color */} +
+ +
+
+
+ setSecondaryColor(e.target.value)} + className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent" + placeholder="#23DCE1" + /> +
+ setSecondaryColor(e.target.value)} + className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" + /> +
+

+ Used for highlights and supporting elements. +

+
+ + {/* Accent Color */} +
+ +
+
+
+ setAccentColor(e.target.value)} + className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent" + placeholder="#084CC8" + /> +
+ setAccentColor(e.target.value)} + className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" + /> +
+

+ Used for alerts and special notices. +

+
+
+ + {/* Save Button */} +
+ + {isSaving ? 'Saving...' : 'Save Changes'} + +
+
+
+ + ); +}; + +export default Settings; diff --git a/src/pages/TenantLogin.tsx b/src/pages/tenant/TenantLogin.tsx similarity index 86% rename from src/pages/TenantLogin.tsx rename to src/pages/tenant/TenantLogin.tsx index e4d67b8..0f7f9da 100644 --- a/src/pages/TenantLogin.tsx +++ b/src/pages/tenant/TenantLogin.tsx @@ -11,6 +11,7 @@ import { FormField } from '@/components/shared'; import { PrimaryButton } from '@/components/shared'; import type { LoginError } from '@/services/auth-service'; import { showToast } from '@/utils/toast'; +import { useTenantTheme } from '@/hooks/useTenantTheme'; // Zod validation schema const loginSchema = z.object({ @@ -30,6 +31,10 @@ const TenantLogin = (): ReactElement => { const navigate = useNavigate(); const dispatch = useAppDispatch(); const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth); + const { theme, logoUrl } = useAppSelector((state) => state.theme); + + // Fetch and apply tenant theme + useTenantTheme(); const { register, @@ -64,7 +69,7 @@ const TenantLogin = (): ReactElement => { navigate('/dashboard'); } else { // Tenant admin - redirect to tenant dashboard - navigate('/tenant/dashboard'); + navigate('/tenant'); } } }, [isAuthenticated, roles, navigate]); @@ -104,7 +109,7 @@ const TenantLogin = (): ReactElement => { if (rolesArray.includes('super_admin')) { navigate('/dashboard'); } else { - navigate('/tenant/dashboard'); + navigate('/tenant'); } } } catch (error: any) { @@ -145,16 +150,43 @@ const TenantLogin = (): ReactElement => { return (
{/* Left Side - Blue Background */} -
+
{/* Logo Section */}
-
+ {logoUrl ? ( + Logo { + // Fallback to icon if image fails + e.currentTarget.style.display = 'none'; + const fallback = e.currentTarget.nextElementSibling as HTMLElement; + if (fallback) fallback.style.display = 'flex'; + }} + /> + ) : null} +
-
- QAssure +
+ {logoUrl ? '' : 'QAssure'}
-
Tenant
@@ -275,7 +307,11 @@ const TenantLogin = (): ReactElement => { id="remember-me" checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} - className="w-[18px] h-[18px] rounded border-[#d1d5db] bg-[#112868] text-[#112868] focus:ring-2 focus:ring-[#112868]" + className="w-[18px] h-[18px] rounded border-[#d1d5db] focus:ring-2" + style={{ + backgroundColor: theme?.primary_color || '#112868', + accentColor: theme?.primary_color || '#112868', + } as React.CSSProperties & { accentColor?: string }} />