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 }} />