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' }); +};