From f07db4040ece8aa50e92c1eceac11599e5b397c1 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Tue, 27 Jan 2026 18:15:58 +0530 Subject: [PATCH] Enhance tenant management by adding CreateTenantWizard and TenantDetails components for streamlined tenant creation and viewing. Update routing in App component to include new paths for tenant management. Refactor Header and Layout components to support breadcrumb navigation. Improve EditRoleModal, EditUserModal, and NewRoleModal to include defaultTenantId for automatic tenant association in role and user management. Update API services to support tenant-specific data fetching for roles and users. --- src/App.tsx | 18 + src/components/layout/Header.tsx | 43 +- src/components/layout/Layout.tsx | 5 +- src/components/shared/EditRoleModal.tsx | 4 + src/components/shared/EditTenantModal.tsx | 58 +- src/components/shared/EditUserModal.tsx | 152 ++-- src/components/shared/FormField.tsx | 30 +- src/components/shared/NewRoleModal.tsx | 10 +- src/components/shared/NewTenantModal.tsx | 570 ++++++------- src/components/shared/NewUserModal.tsx | 44 +- src/components/shared/RolesTable.tsx | 545 +++++++++++++ src/components/shared/UsersTable.tsx | 582 ++++++++++++++ src/components/shared/index.ts | 6 +- .../dashboard/components/StatsGrid.tsx | 50 +- src/pages/CreateTenantWizard.tsx | 761 ++++++++++++++++++ src/pages/TenantDetails.tsx | 626 ++++++++++++++ src/pages/Tenants.tsx | 90 ++- src/services/audit-log-service.ts | 6 +- src/services/module-service.ts | 1 - src/services/role-service.ts | 21 + src/services/user-service.ts | 20 + src/types/role.ts | 2 + src/types/tenant.ts | 9 + src/utils/format-date.ts | 10 + 24 files changed, 3174 insertions(+), 489 deletions(-) create mode 100644 src/components/shared/RolesTable.tsx create mode 100644 src/components/shared/UsersTable.tsx create mode 100644 src/pages/CreateTenantWizard.tsx create mode 100644 src/pages/TenantDetails.tsx create mode 100644 src/utils/format-date.ts diff --git a/src/App.tsx b/src/App.tsx index f088c1c..842b794 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { Toaster } from "sonner"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Tenants from "./pages/Tenants"; +import CreateTenantWizard from "./pages/CreateTenantWizard"; +import TenantDetails from "./pages/TenantDetails"; import Users from "./pages/Users"; import NotFound from "./pages/NotFound"; import ProtectedRoute from "./pages/ProtectedRoute"; @@ -36,6 +38,22 @@ function App() { } /> + + + + } + /> + + + + } + /> void; } -export const Header = ({ currentPage, onMenuClick }: HeaderProps): ReactElement => { +export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps): ReactElement => { const navigate = useNavigate(); const dispatch = useAppDispatch(); const { user, isLoading } = useAppSelector((state) => state.auth); @@ -105,11 +105,42 @@ export const Header = ({ currentPage, onMenuClick }: HeaderProps): ReactElement {/* Breadcrumbs */} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 37be5c4..5b266ec 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -7,6 +7,7 @@ import type { ReactNode, ReactElement } from 'react'; interface LayoutProps { children: ReactNode; currentPage: string; + breadcrumbs?: Array<{ label: string; path?: string }>; pageHeader?: { title: string; description?: string; @@ -14,7 +15,7 @@ interface LayoutProps { }; } -export const Layout = ({ children, currentPage, pageHeader }: LayoutProps): ReactElement => { +export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: LayoutProps): ReactElement => { const [isSidebarOpen, setIsSidebarOpen] = useState(false); const toggleSidebar = (): void => { @@ -47,7 +48,7 @@ export const Layout = ({ children, currentPage, pageHeader }: LayoutProps): Reac {/* Main Content */}
{/* Top Header */} -
+
{/* Main Content Area */}
diff --git a/src/components/shared/EditRoleModal.tsx b/src/components/shared/EditRoleModal.tsx index 3d1c929..c749e96 100644 --- a/src/components/shared/EditRoleModal.tsx +++ b/src/components/shared/EditRoleModal.tsx @@ -84,6 +84,7 @@ interface EditRoleModalProps { onLoadRole: (id: string) => Promise; onSubmit: (id: string, data: UpdateRoleRequest) => Promise; isLoading?: boolean; + defaultTenantId?: string; // If provided, automatically include tenant_id in request body } export const EditRoleModal = ({ @@ -93,6 +94,7 @@ export const EditRoleModal = ({ onLoadRole, onSubmit, isLoading = false, + defaultTenantId, }: EditRoleModalProps): ReactElement | null => { const [isLoadingRole, setIsLoadingRole] = useState(false); const [loadError, setLoadError] = useState(null); @@ -368,6 +370,8 @@ export const EditRoleModal = ({ try { const submitData = { ...data, + // Include tenant_id if defaultTenantId is provided + tenant_id: defaultTenantId || undefined, // Only include module_ids if user is not super_admin module_ids: isSuperAdmin ? undefined : (selectedModules.length > 0 ? selectedModules : undefined), permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined, diff --git a/src/components/shared/EditTenantModal.tsx b/src/components/shared/EditTenantModal.tsx index 6d6e907..0543812 100644 --- a/src/components/shared/EditTenantModal.tsx +++ b/src/components/shared/EditTenantModal.tsx @@ -103,12 +103,12 @@ export const EditTenantModal = ({ if (isOpen && tenantId) { // Only load if this is a new tenantId or modal was closed and reopened if (loadedTenantIdRef.current !== tenantId) { - const loadTenant = async (): Promise => { - try { - setIsLoadingTenant(true); - setLoadError(null); - clearErrors(); - const tenant = await onLoadTenant(tenantId); + const loadTenant = async (): Promise => { + try { + setIsLoadingTenant(true); + setLoadError(null); + clearErrors(); + const tenant = await onLoadTenant(tenantId); loadedTenantIdRef.current = tenantId; // Validate subscription_tier to match enum type @@ -133,23 +133,23 @@ export const EditTenantModal = ({ setSelectedModules(tenantModules); setInitialModuleOptions(initialOptions); - reset({ - name: tenant.name, - slug: tenant.slug, - status: tenant.status, - settings: tenant.settings, + reset({ + name: tenant.name, + slug: tenant.slug, + status: tenant.status, + settings: tenant.settings, subscription_tier: validSubscriptionTier, - max_users: tenant.max_users, - max_modules: tenant.max_modules, + max_users: tenant.max_users, + max_modules: tenant.max_modules, modules: tenantModules, - }); - } catch (err: any) { - setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details'); - } finally { - setIsLoadingTenant(false); - } - }; - loadTenant(); + }); + } catch (err: any) { + setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details'); + } finally { + setIsLoadingTenant(false); + } + }; + loadTenant(); } } else if (!isOpen) { // Only reset when modal is closed @@ -295,14 +295,14 @@ export const EditTenantModal = ({ {/* Status and Subscription Tier Row */}
- setValue('status', value as 'active' | 'suspended' | 'deleted')} - error={errors.status?.message} + setValue('status', value as 'active' | 'suspended' | 'deleted')} + error={errors.status?.message} />
diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index b844f63..469ae4b 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -37,6 +37,7 @@ interface EditUserModalProps { onLoadUser: (id: string) => Promise; onSubmit: (id: string, data: EditUserFormData) => Promise; isLoading?: boolean; + defaultTenantId?: string; // If provided, automatically set tenant_id and hide tenant field } const statusOptions = [ @@ -52,6 +53,7 @@ export const EditUserModal = ({ onLoadUser, onSubmit, isLoading = false, + defaultTenantId, }: EditUserModalProps): ReactElement | null => { const [isLoadingUser, setIsLoadingUser] = useState(false); const [loadError, setLoadError] = useState(null); @@ -113,21 +115,21 @@ export const EditUserModal = ({ ...options, ]; } else { - try { - const tenantResponse = await tenantService.getById(selectedTenantId); - if (tenantResponse.success) { - // Prepend the selected tenant to the options - options = [ - { - value: tenantResponse.data.id, - label: tenantResponse.data.name, - }, - ...options, - ]; - } - } catch (err) { - // If fetching fails, just continue with existing options - console.warn('Failed to fetch selected tenant:', err); + try { + const tenantResponse = await tenantService.getById(selectedTenantId); + if (tenantResponse.success) { + // Prepend the selected tenant to the options + options = [ + { + value: tenantResponse.data.id, + label: tenantResponse.data.name, + }, + ...options, + ]; + } + } catch (err) { + // If fetching fails, just continue with existing options + console.warn('Failed to fetch selected tenant:', err); } } } @@ -141,7 +143,10 @@ export const EditUserModal = ({ // Load roles for dropdown - ensure selected role is included const loadRoles = async (page: number, limit: number) => { - const response = await roleService.getAll(page, limit); + // If defaultTenantId is provided, filter roles by tenant_id + const response = defaultTenantId + ? await roleService.getByTenant(defaultTenantId, page, limit) + : await roleService.getAll(page, limit); let options = response.data.map((role) => ({ value: role.id, label: role.name, @@ -169,21 +174,21 @@ export const EditUserModal = ({ ...options, ]; } else { - try { - const roleResponse = await roleService.getById(selectedRoleId); - if (roleResponse.success) { - // Prepend the selected role to the options - options = [ - { - value: roleResponse.data.id, - label: roleResponse.data.name, - }, - ...options, - ]; - } - } catch (err) { - // If fetching fails, just continue with existing options - console.warn('Failed to fetch selected role:', err); + try { + const roleResponse = await roleService.getById(selectedRoleId); + if (roleResponse.success) { + // Prepend the selected role to the options + options = [ + { + value: roleResponse.data.id, + label: roleResponse.data.name, + }, + ...options, + ]; + } + } catch (err) { + // If fetching fails, just continue with existing options + console.warn('Failed to fetch selected role:', err); } } } @@ -200,22 +205,22 @@ export const EditUserModal = ({ if (isOpen && userId) { // Only load if this is a new userId or modal was closed and reopened if (loadedUserIdRef.current !== userId) { - const loadUser = async (): Promise => { - try { - setIsLoadingUser(true); - setLoadError(null); - clearErrors(); - const user = await onLoadUser(userId); + const loadUser = async (): Promise => { + try { + setIsLoadingUser(true); + setLoadError(null); + clearErrors(); + const user = await onLoadUser(userId); loadedUserIdRef.current = userId; - + // Extract tenant and role IDs from nested objects or fallback to direct properties const tenantId = user.tenant?.id || user.tenant_id || ''; const roleId = user.role?.id || user.role_id || ''; const tenantName = user.tenant?.name || ''; const roleName = user.role?.name || ''; - setSelectedTenantId(tenantId); - setSelectedRoleId(roleId); + setSelectedTenantId(tenantId); + setSelectedRoleId(roleId); setCurrentTenantName(tenantName); setCurrentRoleName(roleName); @@ -226,22 +231,27 @@ export const EditUserModal = ({ if (roleId && roleName) { setInitialRoleOption({ value: roleId, label: roleName }); } - - reset({ - email: user.email, - first_name: user.first_name, - last_name: user.last_name, - status: user.status, - tenant_id: tenantId, - role_id: roleId, - }); - } catch (err: any) { - setLoadError(err?.response?.data?.error?.message || 'Failed to load user details'); - } finally { - setIsLoadingUser(false); + + reset({ + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + status: user.status, + tenant_id: defaultTenantId || tenantId, + role_id: roleId, + }); + + // If defaultTenantId is provided, override tenant_id + if (defaultTenantId) { + setValue('tenant_id', defaultTenantId, { shouldValidate: true }); } - }; - loadUser(); + } catch (err: any) { + setLoadError(err?.response?.data?.error?.message || 'Failed to load user details'); + } finally { + setIsLoadingUser(false); + } + }; + loadUser(); } } else if (!isOpen) { // Only reset when modal is closed @@ -257,19 +267,23 @@ export const EditUserModal = ({ first_name: '', last_name: '', status: 'active', - tenant_id: '', + tenant_id: defaultTenantId || '', role_id: '', }); setLoadError(null); clearErrors(); } - }, [isOpen, userId, onLoadUser, reset, clearErrors]); + }, [isOpen, userId, onLoadUser, reset, clearErrors, defaultTenantId, setValue]); const handleFormSubmit = async (data: EditUserFormData): Promise => { if (!userId) return; clearErrors(); try { + // Ensure tenant_id is set from defaultTenantId if provided + if (defaultTenantId) { + data.tenant_id = defaultTenantId; + } await onSubmit(userId, data); } catch (error: any) { // Handle validation errors from API @@ -390,17 +404,19 @@ export const EditUserModal = ({
{/* Tenant and Role Row */} -
- setValue('tenant_id', value)} - onLoadOptions={loadTenants} - initialOption={initialTenantOption || undefined} - error={errors.tenant_id?.message} - /> +
+ {!defaultTenantId && ( + setValue('tenant_id', value)} + onLoadOptions={loadTenants} + initialOption={initialTenantOption || undefined} + error={errors.tenant_id?.message} + /> + )} *}
- + className + )} + aria-invalid={hasError} + aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined} + {...props} + /> {isPassword && (
{/* Tenant and Role Row */} -
- setValue('tenant_id', value)} - onLoadOptions={loadTenants} - error={errors.tenant_id?.message} - /> +
+ {!defaultTenantId && ( + setValue('tenant_id', value)} + onLoadOptions={loadTenants} + error={errors.tenant_id?.message} + /> + )} { + switch (scope.toLowerCase()) { + case 'platform': + return 'success'; + case 'tenant': + return 'process'; + case 'module': + return 'failure'; + default: + return 'success'; + } +}; + +interface RolesTableProps { + tenantId?: string | null; // If provided, fetch roles for this tenant only + showHeader?: boolean; // Show header with title and actions (default: true) + compact?: boolean; // Compact mode for tabs (default: false) +} + +export const RolesTable = ({ tenantId, showHeader = true, compact = false }: RolesTableProps): 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(compact ? 10 : 20); + const [pagination, setPagination] = useState<{ + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }>({ + page: 1, + limit: compact ? 10 : 20, + 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 = tenantId + ? await roleService.getByTenant(tenantId, page, itemsPerPage, scope, sortBy) + : 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, tenantId]); + + 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); + setSelectedRoleName(''); + 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; // Let the modal handle the error display + } 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}

+
+ )} +
+
+ ); + + if (compact) { + // Compact mode for tabs + return ( + <> +
+
+

Roles

+
+ { + setScopeFilter(Array.isArray(value) ? null : value || null); + setCurrentPage(1); + }} + placeholder="Filter by scope" + /> + setIsModalOpen(true)} + > + + New Role + +
+
+ role.id} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No roles found" + isLoading={isLoading} + error={error} + /> + {pagination.totalPages > 1 && ( + { + setCurrentPage(page); + }} + onLimitChange={(newLimit: number) => { + setLimit(newLimit); + setCurrentPage(1); + }} + /> + )} +
+ + {/* Modals */} + setIsModalOpen(false)} + onSubmit={handleCreateRole} + isLoading={isCreating} + defaultTenantId={tenantId || undefined} + /> + + { + setViewModalOpen(false); + setSelectedRoleId(null); + }} + roleId={selectedRoleId} + onLoadRole={loadRole} + /> + + { + setEditModalOpen(false); + setSelectedRoleId(null); + setSelectedRoleName(''); + }} + roleId={selectedRoleId} + onLoadRole={loadRole} + onSubmit={handleUpdateRole} + isLoading={isUpdating} + defaultTenantId={tenantId || undefined} + /> + + { + setDeleteModalOpen(false); + setSelectedRoleId(null); + setSelectedRoleName(''); + }} + onConfirm={handleConfirmDelete} + title="Delete Role" + message={`Are you sure you want to delete this role`} + itemName={selectedRoleName} + isLoading={isDeleting} + /> + + ); + } + + // Full mode with header + return ( + <> + {/* Table Container */} +
+ {/* Table Header with Filters */} + {showHeader && ( +
+ {/* 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 + +
+
+ )} + + {/* Data 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); + }} + /> + )} +
+ + {/* Modals */} + setIsModalOpen(false)} + onSubmit={handleCreateRole} + isLoading={isCreating} + defaultTenantId={tenantId || undefined} + /> + + { + setViewModalOpen(false); + setSelectedRoleId(null); + }} + roleId={selectedRoleId} + onLoadRole={loadRole} + /> + + { + setEditModalOpen(false); + setSelectedRoleId(null); + setSelectedRoleName(''); + }} + roleId={selectedRoleId} + onLoadRole={loadRole} + onSubmit={handleUpdateRole} + isLoading={isUpdating} + defaultTenantId={tenantId || undefined} + /> + + { + setDeleteModalOpen(false); + setSelectedRoleId(null); + setSelectedRoleName(''); + }} + onConfirm={handleConfirmDelete} + title="Delete Role" + message={`Are you sure you want to delete this role`} + itemName={selectedRoleName} + isLoading={isDeleting} + /> + + ); +}; diff --git a/src/components/shared/UsersTable.tsx b/src/components/shared/UsersTable.tsx new file mode 100644 index 0000000..c8a2b77 --- /dev/null +++ b/src/components/shared/UsersTable.tsx @@ -0,0 +1,582 @@ +import { useState, useEffect, type ReactElement } from 'react'; +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'; +import { formatDate } from '@/utils/format-date'; + +// Helper function to get user initials +const getUserInitials = (firstName: string, lastName: string): string => { + return `${firstName[0]}${lastName[0]}`.toUpperCase(); +}; + +// 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'; + } +}; + +interface UsersTableProps { + tenantId?: string | null; // If provided, fetch users for this tenant only + showHeader?: boolean; // Show header with title and actions (default: true) + compact?: boolean; // Compact mode for tabs (default: false) +} + +export const UsersTable = ({ tenantId, showHeader = true, compact = false }: UsersTableProps): ReactElement => { + const [users, setUsers] = 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(compact ? 10 : 5); + const [pagination, setPagination] = useState<{ + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }>({ + page: 1, + limit: compact ? 10 : 5, + total: 0, + totalPages: 1, + hasMore: false, + }); + + // Filter state + const [statusFilter, setStatusFilter] = 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 [selectedUserId, setSelectedUserId] = useState(null); + const [selectedUserName, setSelectedUserName] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const fetchUsers = async ( + page: number, + itemsPerPage: number, + status: string | null = null, + sortBy: string[] | null = null + ): Promise => { + try { + setIsLoading(true); + setError(null); + const response = tenantId + ? await userService.getByTenant(tenantId, page, itemsPerPage, status, sortBy) + : 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, tenantId]); + + const handleCreateUser = async (data: { + email: string; + password: string; + first_name: string; + last_name: string; + status: 'active' | 'suspended' | 'deleted'; + auth_provider: 'local'; + tenant_id: string; + role_id: string; + }): Promise => { + 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 ( + userId: string, + data: { + email: string; + first_name: string; + last_name: string; + status: 'active' | 'suspended' | 'deleted'; + auth_provider?: string; + tenant_id: string; + role_id: string; + } + ): Promise => { + try { + setIsUpdating(true); + const response = await userService.update(userId, 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); + setSelectedUserName(''); + 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 => { + 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; // Let the modal handle the error display + } finally { + setIsDeleting(false); + } + }; + + // Load user for view/edit + const loadUser = async (id: string): Promise => { + const response = await userService.getById(id); + return response.data; + }; + + // Define table columns + const columns: Column[] = [ + { + key: 'name', + label: 'User Name', + render: (user) => ( +
+
+ + {getUserInitials(user.first_name, user.last_name)} + +
+ + {user.first_name} {user.last_name} + +
+ ), + mobileLabel: 'Name', + }, + { + key: 'email', + label: 'Email', + render: (user) => {user.email}, + }, + { + key: 'status', + label: 'Status', + render: (user) => ( + {user.status} + ), + }, + { + key: 'auth_provider', + label: 'Auth Provider', + render: (user) => ( + {user.auth_provider} + ), + }, + { + key: 'created_at', + label: 'Joined Date', + render: (user) => ( + {formatDate(user.created_at)} + ), + mobileLabel: 'Joined', + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (user) => ( +
+ handleViewUser(user.id)} + onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)} + onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)} + /> +
+ ), + }, + ]; + + // Mobile card renderer + const mobileCardRenderer = (user: User) => ( +
+
+
+
+ + {getUserInitials(user.first_name, user.last_name)} + +
+
+

+ {user.first_name} {user.last_name} +

+

{user.email}

+
+
+ handleViewUser(user.id)} + onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)} + onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)} + /> +
+
+
+ Status: +
+ {user.status} +
+
+
+ Auth Provider: +

{user.auth_provider}

+
+
+ Joined: +

{formatDate(user.created_at)}

+
+
+
+ ); + + if (compact) { + // Compact mode for tabs + return ( + <> +
+
+

Users

+
+ { + setStatusFilter(Array.isArray(value) ? null : value || null); + setCurrentPage(1); + }} + placeholder="Filter by status" + /> + setIsModalOpen(true)} + > + + New User + +
+
+ user.id} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No users found" + isLoading={isLoading} + error={error} + /> + {pagination.totalPages > 1 && ( + { + setCurrentPage(page); + }} + onLimitChange={(newLimit: number) => { + setLimit(newLimit); + setCurrentPage(1); + }} + /> + )} +
+ + {/* Modals */} + setIsModalOpen(false)} + onSubmit={handleCreateUser} + isLoading={isCreating} + defaultTenantId={tenantId || undefined} + /> + + { + setViewModalOpen(false); + setSelectedUserId(null); + }} + userId={selectedUserId} + onLoadUser={loadUser} + /> + + { + setEditModalOpen(false); + setSelectedUserId(null); + setSelectedUserName(''); + }} + userId={selectedUserId} + onLoadUser={loadUser} + onSubmit={handleUpdateUser} + isLoading={isUpdating} + defaultTenantId={tenantId || undefined} + /> + + { + setDeleteModalOpen(false); + setSelectedUserId(null); + setSelectedUserName(''); + }} + onConfirm={handleConfirmDelete} + title="Delete User" + message="Are you sure you want to delete this user" + itemName={selectedUserName} + isLoading={isDeleting} + /> + + ); + } + + // Full mode with header + return ( + <> + {/* Table Container */} +
+ {/* Table Header with Filters */} + {showHeader && ( +
+ {/* Filters */} +
+ {/* Status Filter */} + { + setStatusFilter(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 User Button */} + setIsModalOpen(true)} + > + + New User + +
+
+ )} + + {/* Data Table */} + user.id} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No users found" + isLoading={isLoading} + error={error} + /> + + {/* Table Footer with Pagination */} + {pagination.total > 0 && ( + { + setCurrentPage(page); + }} + onLimitChange={(newLimit: number) => { + setLimit(newLimit); + setCurrentPage(1); + }} + /> + )} +
+ + {/* Modals */} + setIsModalOpen(false)} + onSubmit={handleCreateUser} + isLoading={isCreating} + defaultTenantId={tenantId || undefined} + /> + + { + setViewModalOpen(false); + setSelectedUserId(null); + }} + userId={selectedUserId} + onLoadUser={loadUser} + /> + + { + setEditModalOpen(false); + setSelectedUserId(null); + setSelectedUserName(''); + }} + userId={selectedUserId} + onLoadUser={loadUser} + onSubmit={handleUpdateUser} + isLoading={isUpdating} + /> + + { + setDeleteModalOpen(false); + setSelectedUserId(null); + setSelectedUserName(''); + }} + onConfirm={handleConfirmDelete} + title="Delete User" + message="Are you sure you want to delete this user" + itemName={selectedUserName} + isLoading={isDeleting} + /> + + ); +}; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index a6737b2..1402c98 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -12,7 +12,7 @@ export { DataTable } from './DataTable'; export type { Column } from './DataTable'; export { Pagination } from './Pagination'; export { FilterDropdown } from './FilterDropdown'; -export { NewTenantModal } from './NewTenantModal'; +// export { NewTenantModal } from './NewTenantModal'; export { ViewTenantModal } from './ViewTenantModal'; export { EditTenantModal } from './EditTenantModal'; export { DeleteConfirmationModal } from './DeleteConfirmationModal'; @@ -26,4 +26,6 @@ export { ViewModuleModal } from './ViewModuleModal'; export { NewModuleModal } from './NewModuleModal'; export { ViewAuditLogModal } from './ViewAuditLogModal'; export { PageHeader } from './PageHeader'; -export type { TabItem } from './PageHeader'; \ No newline at end of file +export type { TabItem } from './PageHeader'; +export { UsersTable } from './UsersTable'; +export { RolesTable } from './RolesTable'; \ No newline at end of file diff --git a/src/features/dashboard/components/StatsGrid.tsx b/src/features/dashboard/components/StatsGrid.tsx index b345a5b..dfc5f17 100644 --- a/src/features/dashboard/components/StatsGrid.tsx +++ b/src/features/dashboard/components/StatsGrid.tsx @@ -18,45 +18,45 @@ export const StatsGrid = () => { const { data } = response; const mappedStats: StatCardData[] = [ - { - icon: Building2, + { + icon: Building2, value: data.totalTenants, - label: 'Total Tenants', + label: 'Total Tenants', badge: { text: `${data.activeTenants} active`, variant: 'green' }, - }, - { - icon: CheckCircle2, + }, + { + icon: CheckCircle2, value: data.activeTenants, - label: 'Active Tenants', + label: 'Active Tenants', badge: { text: data.totalTenants > 0 ? `${Math.round((data.activeTenants / data.totalTenants) * 100)}% Rate` : '0% Rate', variant: 'green', }, - }, - { - icon: Users, + }, + { + icon: Users, value: data.totalUsers, - label: 'Total Users', + label: 'Total Users', badge: { text: 'All users', variant: 'gray' }, - }, - { - icon: TrendingUp, + }, + { + icon: TrendingUp, value: data.activeSessions, - label: 'Active Sessions', + label: 'Active Sessions', badge: { text: 'Live now', variant: 'gray' }, - }, - { - icon: Package, + }, + { + icon: Package, value: data.registeredModules, - label: 'Registered Modules', + label: 'Registered Modules', badge: { text: 'Total', variant: 'gray' }, - }, - { - icon: Heart, + }, + { + icon: Heart, value: data.healthyModules, - label: 'Healthy Modules', + label: 'Healthy Modules', badge: { text: data.registeredModules > 0 ? `${Math.round((data.healthyModules / data.registeredModules) * 100)}% Uptime` @@ -65,8 +65,8 @@ export const StatsGrid = () => { ? 'green' : 'gray', }, - }, - ]; + }, +]; setStatsData(mappedStats); } catch (err) { diff --git a/src/pages/CreateTenantWizard.tsx b/src/pages/CreateTenantWizard.tsx new file mode 100644 index 0000000..026fafd --- /dev/null +++ b/src/pages/CreateTenantWizard.tsx @@ -0,0 +1,761 @@ +import { useState, useEffect, useRef } from 'react'; +import type { ReactElement } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Layout } from '@/components/layout/Layout'; +import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; +import { tenantService } from '@/services/tenant-service'; +import { moduleService } from '@/services/module-service'; +import { showToast } from '@/utils/toast'; +import { ChevronRight, ChevronLeft } from 'lucide-react'; + +// Step 1: Tenant Details Schema - matches NewTenantModal +const tenantDetailsSchema = z.object({ + name: z + .string() + .min(1, 'name is required') + .min(3, 'name must be at least 3 characters') + .max(100, 'name must be at most 100 characters'), + slug: z + .string() + .min(1, 'slug is required') + .min(3, 'slug must be at least 3 characters') + .max(100, 'slug must be at most 100 characters') + .regex(/^[a-z0-9-]+$/, 'slug format is invalid'), + domain: z.string().optional().nullable(), + status: z.enum(['active', 'suspended', 'deleted'], { + message: 'Status is required', + }), + subscription_tier: z.enum(['basic', 'professional', 'enterprise'], { + message: 'Invalid subscription tier', + }).optional().nullable(), + max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(), + max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(), + modules: z.array(z.string().uuid()).optional().nullable(), +}); + +// Step 2: Contact Details Schema - user creation + organization address +const contactDetailsSchema = z + .object({ + email: z.string().min(1, 'Email is required').email('Please enter a valid email address'), + password: z.string().min(1, 'Password is required').min(6, 'Password must be at least 6 characters'), + confirmPassword: z.string().min(1, 'Confirm password is required'), + first_name: z.string().min(1, 'First name is required'), + last_name: z.string().min(1, 'Last name is required'), + contact_phone: z.string().optional().nullable(), + address_line1: z.string().min(1, 'Address is required'), + address_line2: z.string().optional().nullable(), + city: z.string().min(1, 'City is required'), + state: z.string().min(1, 'State is required'), + postal_code: z.string().min(1, 'Postal code is required'), + country: z.string().min(1, 'Country is required'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); + +// Step 3: Settings Schema +const settingsSchema = z.object({ + enable_sso: z.boolean(), + enable_2fa: z.boolean(), +}); + +type TenantDetailsForm = z.infer; +type ContactDetailsForm = z.infer; +type SettingsForm = z.infer; + +const statusOptions = [ + { value: 'active', label: 'Active' }, + { value: 'suspended', label: 'Suspended' }, + { value: 'deleted', label: 'Deleted' }, +]; + +const subscriptionTierOptions = [ + { value: 'basic', label: 'Basic' }, + { value: 'professional', label: 'Professional' }, + { value: 'enterprise', label: 'Enterprise' }, +]; + +// Helper function to get base URL without protocol +const getBaseUrlWithoutProtocol = (): string => { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; + // Remove protocol (http:// or https://) + return apiBaseUrl.replace(/^https?:\/\//, ''); +}; + +const CreateTenantWizard = (): ReactElement => { + const navigate = useNavigate(); + const [currentStep, setCurrentStep] = useState(1); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [selectedModules, setSelectedModules] = useState([]); + const [initialModuleOptions, setInitialModuleOptions] = useState>([]); + + // Form instances for each step + const tenantDetailsForm = useForm({ + resolver: zodResolver(tenantDetailsSchema), + defaultValues: { + name: '', + slug: '', + domain: '', + status: 'active', + subscription_tier: null, + max_users: null, + max_modules: null, + modules: [], + }, + }); + + // Load modules for multiselect + const loadModules = async (page: number, limit: number) => { + const response = await moduleService.getRunningModules(page, limit); + return { + options: response.data.map((module) => ({ + value: module.id, + label: module.name, + })), + pagination: response.pagination, + }; + }; + + const contactDetailsForm = useForm({ + resolver: zodResolver(contactDetailsSchema), + defaultValues: { + email: '', + password: '', + confirmPassword: '', + first_name: '', + last_name: '', + contact_phone: '', + address_line1: '', + address_line2: '', + city: '', + state: '', + postal_code: '', + country: '', + }, + }); + + const settingsForm = useForm({ + resolver: zodResolver(settingsSchema), + defaultValues: { + enable_sso: false, + enable_2fa: false, + }, + }); + + // Auto-generate slug and domain from name + const nameValue = tenantDetailsForm.watch('name'); + const baseUrlWithoutProtocol = getBaseUrlWithoutProtocol(); + const previousNameRef = useRef(''); + + useEffect(() => { + if (nameValue) { + const slug = nameValue + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + tenantDetailsForm.setValue('slug', slug, { shouldValidate: true }); + + // Auto-generate domain when tenant name changes (like slug) + // Always update domain when name changes, similar to slug behavior + if (nameValue !== previousNameRef.current) { + const autoGeneratedDomain = `${slug}.${baseUrlWithoutProtocol}`; + tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false }); + previousNameRef.current = nameValue; + } + } else if (!nameValue && previousNameRef.current) { + // Clear domain when name is cleared + tenantDetailsForm.setValue('domain', '', { shouldValidate: false }); + previousNameRef.current = ''; + } + }, [nameValue, tenantDetailsForm, baseUrlWithoutProtocol]); + + const handleNext = async (): Promise => { + if (currentStep === 1) { + const isValid = await tenantDetailsForm.trigger(); + if (isValid) { + // Store selected modules and their options for restoration when going back + const modules = tenantDetailsForm.getValues('modules') || []; + if (modules.length > 0 && initialModuleOptions.length === 0) { + // Load module names for selected modules + try { + const moduleOptionsPromises = modules.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 }>; + setInitialModuleOptions(moduleOptions); + } catch (err) { + console.warn('Failed to load module names:', err); + } + } + setCurrentStep(2); + } + } else if (currentStep === 2) { + const isValid = await contactDetailsForm.trigger(); + if (isValid) { + setCurrentStep(3); + } + } + }; + + const handlePrevious = (): void => { + if (currentStep > 1) { + // When going back to step 1, restore selected modules and their options + if (currentStep === 2) { + const modules = tenantDetailsForm.getValues('modules') || []; + setSelectedModules(modules); + // Restore initial module options if we have selected modules + if (modules.length > 0 && initialModuleOptions.length === 0) { + // Load module names for selected modules + const loadModuleOptions = async () => { + try { + const moduleOptionsPromises = modules.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 }>; + setInitialModuleOptions(moduleOptions); + } catch (err) { + console.warn('Failed to load module names:', err); + } + }; + loadModuleOptions(); + } + } + setCurrentStep(currentStep - 1); + } + }; + + const handleSubmit = async (): Promise => { + const isValid = await settingsForm.trigger(); + if (!isValid) return; + + try { + setIsSubmitting(true); + const tenantDetails = tenantDetailsForm.getValues(); + const contactDetails = contactDetailsForm.getValues(); + const settings = settingsForm.getValues(); + + // Combine all data for tenant creation - matches NewTenantModal structure + const { modules, ...restTenantDetails } = tenantDetails; + // Extract confirmPassword from contactDetails (not needed in API call) + const { confirmPassword, ...contactData } = contactDetails; + + const tenantData = { + ...restTenantDetails, + module_ids: selectedModules.length > 0 ? selectedModules : undefined, + settings: { + ...settings, + contact: contactData, // Include first_name, last_name, email, password + }, + }; + + const response = await tenantService.create(tenantData); + const message = response.message || 'Tenant created successfully'; + showToast.success(message); + navigate('/tenants'); + } catch (err: any) { + // Handle validation errors from API - same as NewTenantModal + if (err?.response?.data?.details && Array.isArray(err.response.data.details)) { + const validationErrors = err.response.data.details; + validationErrors.forEach((detail: { path: string; message: string }) => { + // Handle tenant details errors + if ( + detail.path === 'name' || + detail.path === 'slug' || + detail.path === 'status' || + detail.path === 'subscription_tier' || + detail.path === 'max_users' || + detail.path === 'max_modules' || + detail.path === 'module_ids' + ) { + const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path; + tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, { + type: 'server', + message: detail.message, + }); + } + // Handle contact details errors + else if ( + detail.path === 'email' || + detail.path === 'password' || + detail.path === 'first_name' || + detail.path === 'last_name' + ) { + contactDetailsForm.setError(detail.path as keyof ContactDetailsForm, { + type: 'server', + message: detail.message, + }); + } + }); + } else { + const errorObj = err?.response?.data?.error; + const errorMessage = + (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || + (typeof errorObj === 'string' ? errorObj : null) || + err?.response?.data?.message || + err?.message || + 'Failed to create tenant. Please try again.'; + tenantDetailsForm.setError('root', { + type: 'server', + message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.', + }); + showToast.error(typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.'); + } + } finally { + setIsSubmitting(false); + } + }; + + const steps = [ + { + number: 1, + title: 'Tenant Details', + description: 'Basic organization information', + isActive: currentStep === 1, + isCompleted: currentStep > 1, + }, + { + number: 2, + title: 'Contact Details', + description: 'Primary contact & address', + isActive: currentStep === 2, + isCompleted: currentStep > 2, + }, + { + number: 3, + title: 'Settings', + description: 'Usage limits & security', + isActive: currentStep === 3, + isCompleted: false, + }, + ]; + + return ( + +
+ {/* Steps Sidebar */} +
+
+

Steps

+
+
+ {steps.map((step) => ( +
+
+ {step.number} +
+
+
+ {step.title} +
+
{step.description}
+
+
+ ))} +
+
+ + {/* Main Content */} +
+ {/* Step 1: Tenant Details */} + {currentStep === 1 && ( +
+
+

Tenant Details

+

+ Basic information for the new organization. +

+
+ {/* General Error Display */} + {tenantDetailsForm.formState.errors.root && ( +
+

{tenantDetailsForm.formState.errors.root.message}

+
+ )} + +
+ + + + {/* Status and Subscription Tier Row */} +
+
+ + tenantDetailsForm.setValue('status', value as 'active' | 'suspended' | 'deleted') + } + error={tenantDetailsForm.formState.errors.status?.message} + /> +
+
+ + tenantDetailsForm.setValue( + 'subscription_tier', + value === '' ? null : (value as 'basic' | 'professional' | 'enterprise') + ) + } + error={tenantDetailsForm.formState.errors.subscription_tier?.message} + /> +
+
+ {/* Max Users and Max Modules Row */} +
+
+ { + if (value === '' || value === null || value === undefined) return null; + const num = Number(value); + return isNaN(num) ? null : num; + }, + })} + /> +
+
+ { + if (value === '' || value === null || value === undefined) return null; + const num = Number(value); + return isNaN(num) ? null : num; + }, + })} + /> +
+
+ {/* Modules Multiselect */} + { + setSelectedModules(values); + tenantDetailsForm.setValue('modules', values.length > 0 ? values : []); + }} + onLoadOptions={loadModules} + initialOptions={initialModuleOptions} + error={tenantDetailsForm.formState.errors.modules?.message} + /> +
+
+ )} + + {/* Step 2: Contact Details */} + {currentStep === 2 && ( +
+
+

Contact Details

+

+ Contact information for the main account administrator. +

+
+ {/* General Error Display */} + {contactDetailsForm.formState.errors.root && ( +
+

{contactDetailsForm.formState.errors.root.message}

+
+ )} +
+ {/* User Account Information Section */} +
+ {/* Email */} + + {/* First Name and Last Name Row */} +
+ + +
+ {/* Password and Confirm Password Row */} +
+ + +
+ {/* Contact Phone */} +
+ +
+
+ {/* Organization Address Section */} +
+

Organization Address

+
+ + +
+ + +
+
+ + +
+
+
+
+
+ )} + + {/* Step 3: Settings */} + {currentStep === 3 && ( +
+
+

Configuration & Limits

+

+ Set resource limits and security preferences for this tenant. +

+
+
+
+
+

+ Enable Single Sign-On (SSO) +

+

+ Allow users to log in using their organization's identity provider. +

+
+ +
+
+
+

+ Enable Two-Factor Authentication (2FA) +

+

+ Enforce 2FA for all users in this tenant organization. +

+
+ +
+
+
+ )} + + {/* Footer Navigation */} +
+ {currentStep > 1 && ( + + + Previous + + )} + {currentStep < 3 ? ( + + Next + + + ) : ( + + {isSubmitting ? 'Saving...' : 'Save Tenant'} + + )} +
+
+
+
+ ); +}; + +export default CreateTenantWizard; diff --git a/src/pages/TenantDetails.tsx b/src/pages/TenantDetails.tsx new file mode 100644 index 0000000..ffccbfc --- /dev/null +++ b/src/pages/TenantDetails.tsx @@ -0,0 +1,626 @@ +import { useState, useEffect, useMemo, type ReactElement } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Calendar, + Globe, + Hash, + Users, + Package, + FileText, + History, + CreditCard, + Edit, + CheckCircle2, + XCircle, +} from 'lucide-react'; +import { Layout } from '@/components/layout/Layout'; +import { + StatusBadge, + DataTable, + Pagination, + UsersTable, + RolesTable, + type Column, +} from '@/components/shared'; +import { tenantService } from '@/services/tenant-service'; +import { auditLogService } from '@/services/audit-log-service'; +import type { Tenant, AssignedModule } from '@/types/tenant'; +import type { AuditLog } from '@/types/audit-log'; +import { formatDate } from '@/utils/format-date'; + +type TabType = 'overview' | 'users' | 'roles' | 'modules' | 'license' | 'audit-logs' | 'billing'; + +const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [ + { id: 'overview', label: 'Overview', icon: }, + { id: 'users', label: 'Users', icon: }, + { id: 'roles', label: 'Roles', icon: }, + { id: 'modules', label: 'Modules', icon: }, + { id: 'license', label: 'License', icon: }, + { id: 'audit-logs', label: 'Audit Logs', icon: }, + { id: 'billing', label: 'Billing', icon: }, +]; + +const getStatusVariant = (status: string): 'success' | 'failure' | 'info' | 'process' => { + switch (status.toLowerCase()) { + case 'active': + return 'success'; + case 'suspended': + return 'process'; + case 'deleted': + return 'failure'; + default: + return 'success'; + } +}; + +const getTenantInitials = (name: string): string => { + const words = name.trim().split(/\s+/); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); +}; + +const TenantDetails = (): ReactElement => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState('overview'); + const [tenant, setTenant] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + + // Modules tab state - using assignedModules from tenant response + + // Audit logs tab state + const [auditLogs, setAuditLogs] = useState([]); + const [auditLogsLoading, setAuditLogsLoading] = useState(false); + const [auditLogsPage, setAuditLogsPage] = useState(1); + const [auditLogsLimit] = useState(10); + const [auditLogsPagination, setAuditLogsPagination] = useState<{ + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }>({ + page: 1, + limit: 10, + total: 0, + totalPages: 1, + hasMore: false, + }); + + // Fetch tenant details + useEffect(() => { + const fetchTenant = async (): Promise => { + if (!id) return; + try { + setIsLoading(true); + setError(null); + const response = await tenantService.getById(id); + if (response.success) { + setTenant(response.data); + } else { + setError('Failed to load tenant details'); + } + } catch (err: any) { + setError(err?.response?.data?.error?.message || 'Failed to load tenant details'); + } finally { + setIsLoading(false); + } + }; + + fetchTenant(); + }, [id]); + + // Fetch audit logs for this tenant + const fetchAuditLogs = async (): Promise => { + if (!id) return; + try { + setAuditLogsLoading(true); + const response = await auditLogService.getAll(auditLogsPage, auditLogsLimit, null, null, id); + if (response.success) { + setAuditLogs(response.data); + setAuditLogsPagination(response.pagination); + } + } catch (err: any) { + console.error('Failed to load audit logs:', err); + } finally { + setAuditLogsLoading(false); + } + }; + + // Fetch data when tab changes + useEffect(() => { + if (activeTab === 'audit-logs' && id) { + fetchAuditLogs(); + } + }, [activeTab, id, auditLogsPage]); + + // Calculate stats for overview + const stats = useMemo(() => { + if (!tenant) return null; + return { + totalUsers: tenant.users?.length || 0, + totalModules: tenant.assignedModules?.length || 0, + activeModules: tenant.assignedModules?.filter((m) => m.status === 'running')?.length || 0, + subscriptionTier: tenant.subscription_tier || 'N/A', + }; + }, [tenant]); + + if (isLoading) { + return ( + +
+
Loading tenant details...
+
+
+ ); + } + + if (error || !tenant) { + return ( + +
+
{error || 'Tenant not found'}
+
+
+ ); + } + + return ( + +
+ {/* Tenant Header Card */} +
+
+
+
+ + {getTenantInitials(tenant.name)} + +
+
+
+

+ {tenant.name} +

+ + {tenant.status} + +
+
+
+ + {tenant.slug} +
+ {tenant.domain && ( +
+ + {tenant.domain} +
+ )} +
+ + Created {formatDate(tenant.created_at)} +
+
+
+
+ +
+
+ + {/* Tabs */} +
+
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Tab Content */} +
+ {activeTab === 'overview' && ( + + )} + {activeTab === 'users' && id && ( + + )} + {activeTab === 'roles' && id && ( + + )} + {activeTab === 'modules' && tenant && ( + + )} + {activeTab === 'license' && } + {activeTab === 'audit-logs' && ( + + )} + {activeTab === 'billing' && } +
+
+
+
+ ); +}; + +// Overview Tab Component +interface OverviewTabProps { + tenant: Tenant; + stats: { + totalUsers: number; + totalModules: number; + activeModules: number; + subscriptionTier: string; + } | null; +} + +const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => { + return ( +
+ {/* Stats Cards */} +
+
+
Total Users
+
{stats?.totalUsers || 0}
+
+
+
Total Modules
+
{stats?.totalModules || 0}
+
+
+
Active Modules
+
{stats?.activeModules || 0}
+
+
+
Subscription Tier
+
{stats?.subscriptionTier || 'N/A'}
+
+
+ + {/* General Information */} +
+

General Information

+
+
+
Tenant Name
+
{tenant.name}
+
+
+
Slug
+
{tenant.slug}
+
+
+
Status
+ {tenant.status} +
+
+
Subscription Tier
+
+ {tenant.subscription_tier || 'N/A'} +
+
+
+
Max Users
+
{tenant.max_users || 'Unlimited'}
+
+
+
Max Modules
+
{tenant.max_modules || 'Unlimited'}
+
+
+
Created At
+
{formatDate(tenant.created_at)}
+
+
+
Updated At
+
{formatDate(tenant.updated_at)}
+
+
+
+
+ ); +}; + + +// Modules Tab Component +interface ModulesTabProps { + modules: AssignedModule[]; +} + +const ModulesTab = ({ modules }: ModulesTabProps): ReactElement => { + const [enabledModules, setEnabledModules] = useState>(new Set()); + + useEffect(() => { + // Initialize enabled modules (assuming all are enabled by default) + setEnabledModules(new Set(modules.map((m) => m.id))); + }, [modules]); + + const toggleModule = (moduleId: string): void => { + setEnabledModules((prev) => { + const next = new Set(prev); + if (next.has(moduleId)) { + next.delete(moduleId); + } else { + next.add(moduleId); + } + return next; + }); + // TODO: Call API to enable/disable module + }; + + const columns: Column[] = [ + { + key: 'name', + label: 'Module Name', + render: (module) => ( +
+
+ +
+ {module.name} +
+ ), + }, + { + key: 'version', + label: 'Version', + render: (module) => ( + {module.version} + ), + }, + { + key: 'status', + label: 'Status', + render: (module) => ( + + {module.status} + + ), + }, + { + key: 'enabled', + label: 'Enabled', + render: (module) => ( + + ), + }, + { + key: 'created_at', + label: 'Registered', + render: (module) => ( + {formatDate(module.created_at)} + ), + }, + ]; + + return ( +
+
+

Modules

+
+ module.id} + isLoading={false} + emptyMessage="No modules assigned to this tenant" + /> +
+ ); +}; + +// License Tab Component +interface LicenseTabProps { + tenant: Tenant; +} + +const LicenseTab = ({ tenant: _tenant }: LicenseTabProps): ReactElement => { + // Placeholder for license data + return ( +
+

License Information

+
+
License information will be displayed here.
+
+
+ ); +}; + +// Audit Logs Tab Component +interface AuditLogsTabProps { + auditLogs: AuditLog[]; + isLoading: boolean; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }; + currentPage: number; + limit: number; + onPageChange: (page: number) => void; +} + +const AuditLogsTab = ({ + auditLogs, + isLoading, + pagination, + currentPage, + limit, + onPageChange, +}: AuditLogsTabProps): ReactElement => { + const columns: Column[] = [ + { + key: 'action', + label: 'Action', + render: (log) => ( + {log.action} + ), + }, + { + key: 'resource_type', + label: 'Resource', + render: (log) => ( + {log.resource_type} + ), + }, + { + key: 'user', + label: 'User', + render: (log) => ( + + {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'System'} + + ), + }, + { + key: 'request_method', + label: 'Method', + render: (log) => ( + {log.request_method || 'N/A'} + ), + }, + { + key: 'response_status', + label: 'Status', + render: (log) => ( + = 200 && log.response_status < 300 + ? 'text-green-600' + : log.response_status && log.response_status >= 400 + ? 'text-red-600' + : 'text-gray-600' + }`} + > + {log.response_status || 'N/A'} + + ), + }, + { + key: 'created_at', + label: 'Date', + render: (log) => ( + {formatDate(log.created_at)} + ), + }, + ]; + + return ( +
+
+

Audit Logs

+
+ log.id} + isLoading={isLoading} + /> + {pagination.totalPages > 1 && ( + {}} + /> + )} +
+ ); +}; + +// Billing Tab Component +interface BillingTabProps { + tenant: Tenant; +} + +const BillingTab = ({ tenant: _tenant }: BillingTabProps): ReactElement => { + // Placeholder for billing data + return ( +
+

Billing Information

+
+
Billing information will be displayed here.
+
+
+ ); +}; + +export default TenantDetails; diff --git a/src/pages/Tenants.tsx b/src/pages/Tenants.tsx index 4a6298f..d0ce130 100644 --- a/src/pages/Tenants.tsx +++ b/src/pages/Tenants.tsx @@ -5,8 +5,8 @@ import { PrimaryButton, StatusBadge, ActionDropdown, - NewTenantModal, - ViewTenantModal, + // NewTenantModal, // Commented out - using wizard instead + // ViewTenantModal, // Commented out - using details page instead EditTenantModal, DeleteConfirmationModal, DataTable, @@ -15,6 +15,7 @@ import { type Column, } from '@/components/shared'; import { Plus, Download, ArrowUpDown } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { tenantService } from '@/services/tenant-service'; import type { Tenant } from '@/types/tenant'; import { showToast } from '@/utils/toast'; @@ -55,11 +56,12 @@ const formatSubscriptionTier = (tier: string | null): string => { }; const Tenants = (): ReactElement => { + const navigate = useNavigate(); const [tenants, setTenants] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); - const [isCreating, setIsCreating] = useState(false); + // const [isModalOpen, setIsModalOpen] = useState(false); // Commented out - using wizard instead + // const [isCreating, setIsCreating] = useState(false); // Commented out - using wizard instead // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -83,7 +85,7 @@ const Tenants = (): ReactElement => { const [orderBy, setOrderBy] = useState(null); // View, Edit, Delete modals - const [viewModalOpen, setViewModalOpen] = useState(false); + // const [viewModalOpen, setViewModalOpen] = useState(false); // Commented out - using details page instead const [editModalOpen, setEditModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [selectedTenantId, setSelectedTenantId] = useState(null); @@ -118,35 +120,35 @@ const Tenants = (): ReactElement => { fetchTenants(currentPage, limit, statusFilter, orderBy); }, [currentPage, limit, statusFilter, orderBy]); - const handleCreateTenant = async (data: { - name: string; - slug: string; - status: 'active' | 'suspended' | 'deleted'; - settings?: Record | null; - subscription_tier?: string | null; - max_users?: number | null; - max_modules?: number | null; - }): Promise => { - try { - setIsCreating(true); - const response = await tenantService.create(data); - const message = response.message || `Tenant created successfully`; - const description = response.message ? undefined : `${data.name} has been added`; - showToast.success(message, description); - // Close modal and refresh tenant list - setIsModalOpen(false); - await fetchTenants(currentPage, limit, statusFilter, orderBy); - } catch (err: any) { - throw err; // Let the modal handle the error display - } finally { - setIsCreating(false); - } - }; + // Commented out - using wizard instead + // const handleCreateTenant = async (data: { + // name: string; + // slug: string; + // status: 'active' | 'suspended' | 'deleted'; + // settings?: Record | null; + // subscription_tier?: string | null; + // max_users?: number | null; + // max_modules?: number | null; + // }): Promise => { + // try { + // setIsCreating(true); + // const response = await tenantService.create(data); + // const message = response.message || `Tenant created successfully`; + // const description = response.message ? undefined : `${data.name} has been added`; + // showToast.success(message, description); + // // Close modal and refresh tenant list + // setIsModalOpen(false); + // await fetchTenants(currentPage, limit, statusFilter, orderBy); + // } catch (err: any) { + // throw err; // Let the modal handle the error display + // } finally { + // setIsCreating(false); + // } + // }; // View tenant handler const handleViewTenant = (tenantId: string): void => { - setSelectedTenantId(tenantId); - setViewModalOpen(true); + navigate(`/tenants/${tenantId}`); }; // Edit tenant handler @@ -417,14 +419,24 @@ const Tenants = (): ReactElement => { Export - {/* New Tenant Button */} - setIsModalOpen(true)} > New Tenant + */} + + {/* Add Tenant Button (New Wizard) */} + navigate('/tenants/create-wizard')} + > + + Add Tenant
@@ -458,16 +470,16 @@ const Tenants = (): ReactElement => { )}
- {/* New Tenant Modal */} - setIsModalOpen(false)} onSubmit={handleCreateTenant} isLoading={isCreating} - /> + /> */} - {/* View Tenant Modal */} - { setViewModalOpen(false); @@ -475,7 +487,7 @@ const Tenants = (): ReactElement => { }} tenantId={selectedTenantId} onLoadTenant={loadTenant} - /> + /> */} {/* Edit Tenant Modal */} => { const params = new URLSearchParams(); params.append('page', String(page)); @@ -14,6 +15,9 @@ export const auditLogService = { if (method) { params.append('method', method); } + if (tenantId) { + params.append('tenant_id', tenantId); + } if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) { params.append('orderBy[]', orderBy[0]); params.append('orderBy[]', orderBy[1]); diff --git a/src/services/module-service.ts b/src/services/module-service.ts index f8243f1..8aaf619 100644 --- a/src/services/module-service.ts +++ b/src/services/module-service.ts @@ -41,7 +41,6 @@ export const moduleService = { params.append('page', String(page)); params.append('limit', String(limit)); params.append('tenant_id', tenantId); - params.append('status', 'running'); const response = await apiClient.get(`/modules?${params.toString()}`); return response.data; }, diff --git a/src/services/role-service.ts b/src/services/role-service.ts index 4e44720..05031cb 100644 --- a/src/services/role-service.ts +++ b/src/services/role-service.ts @@ -29,6 +29,27 @@ export const roleService = { const response = await apiClient.get(`/roles?${params.toString()}`); return response.data; }, + getByTenant: async ( + tenantId: string, + page: number = 1, + limit: number = 20, + scope?: string | null, + orderBy?: string[] | null + ): Promise => { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + params.append('tenant_id', tenantId); + if (scope) { + params.append('scope', scope); + } + if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) { + params.append('orderBy[]', orderBy[0]); + params.append('orderBy[]', orderBy[1]); + } + const response = await apiClient.get(`/roles?${params.toString()}`); + return response.data; + }, getById: async (id: string): Promise => { const response = await apiClient.get(`/roles/${id}`); return response.data; diff --git a/src/services/user-service.ts b/src/services/user-service.ts index 213fe29..cae8295 100644 --- a/src/services/user-service.ts +++ b/src/services/user-service.ts @@ -38,6 +38,26 @@ export const userService = { const response = await apiClient.get(`/users/${id}`); return response.data; }, + getByTenant: async ( + tenantId: string, + page: number = 1, + limit: number = 20, + status?: string | null, + orderBy?: string[] | null + ): Promise => { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + if (status) { + params.append('status', status); + } + if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) { + params.append('orderBy[]', orderBy[0]); + params.append('orderBy[]', orderBy[1]); + } + const response = await apiClient.get(`/users/tenant/${tenantId}?${params.toString()}`); + return response.data; + }, update: async (id: string, data: UpdateUserRequest): Promise => { const response = await apiClient.put(`/users/${id}`, data); return response.data; diff --git a/src/types/role.ts b/src/types/role.ts index 765fe17..da92c71 100644 --- a/src/types/role.ts +++ b/src/types/role.ts @@ -35,6 +35,7 @@ export interface CreateRoleRequest { name: string; code: string; description: string; + tenant_id?: string | null; module_ids?: string[] | null; permissions?: Permission[] | null; } @@ -54,6 +55,7 @@ export interface UpdateRoleRequest { name: string; code: string; description: string; + tenant_id?: string | null; module_ids?: string[] | null; permissions?: Permission[] | null; } diff --git a/src/types/tenant.ts b/src/types/tenant.ts index 5ddb66c..3511239 100644 --- a/src/types/tenant.ts +++ b/src/types/tenant.ts @@ -14,6 +14,11 @@ export interface AssignedModule extends Module { TenantModule: TenantModule; } +export interface TenantUser { + email: string; + status: string; +} + export interface Tenant { id: string; name: string; @@ -23,8 +28,12 @@ export interface Tenant { subscription_tier: string | null; max_users: number | null; max_modules: number | null; + domain?: string | null; + enable_sso?: boolean; + enable_2fa?: boolean; modules?: string[]; // Array of module IDs (legacy, for backward compatibility) assignedModules?: AssignedModule[]; // Array of assigned modules with full details + users?: TenantUser[]; // Array of tenant users created_at: string; updated_at: string; } diff --git a/src/utils/format-date.ts b/src/utils/format-date.ts new file mode 100644 index 0000000..85119ef --- /dev/null +++ b/src/utils/format-date.ts @@ -0,0 +1,10 @@ +/** + * Formats a date string to a readable format + * @param dateString - ISO date string + * @returns Formatted date string (e.g., "Jan 23, 2024") + */ +export const formatDate = (dateString: string | null): string => { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +};