diff --git a/.env b/.env index 75d31ae..aef7f2b 100644 --- a/.env +++ b/.env @@ -1,2 +1,4 @@ +# VITE_FRONTEND_BASE_URL=http://localhost:5173 # VITE_API_BASE_URL=http://localhost:3000/api/v1 +VITE_FRONTEND_BASE_URL=https://qasure.tech4bizsolutions.com VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1 diff --git a/Jenkinsfile.automated b/Jenkinsfile similarity index 100% rename from Jenkinsfile.automated rename to Jenkinsfile diff --git a/src/App.tsx b/src/App.tsx index 842b794..8bf63cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,101 +1,12 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter } from "react-router-dom"; 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"; -import Roles from "./pages/Roles"; -import Modules from "./pages/Modules"; -import AuditLogs from "./pages/AuditLogs"; -import ForgotPassword from "./pages/ForgotPassword"; -import ResetPassword from "./pages/ResetPassword"; +import { AppRoutes } from "@/routes"; function App() { return ( - - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - {/* Catch-all route for 404 */} - - - - } - /> - + ); } diff --git a/src/pages/CreateTenantWizard.tsx b/src/pages/CreateTenantWizard.tsx index 5428231..606c46d 100644 --- a/src/pages/CreateTenantWizard.tsx +++ b/src/pages/CreateTenantWizard.tsx @@ -102,7 +102,7 @@ const subscriptionTierOptions = [ // Helper function to get base URL without protocol const getBaseUrlWithoutProtocol = (): string => { - const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5173'; + const apiBaseUrl = import.meta.env.VITE_FRONTEND_BASE_URL || 'http://localhost:5173'; // Remove protocol (http:// or https://) return apiBaseUrl.replace(/^https?:\/\//, ''); }; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index fd2a4d9..668a914 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -29,7 +29,7 @@ type LoginFormData = z.infer; const Login = (): ReactElement => { const navigate = useNavigate(); const dispatch = useAppDispatch(); - const { isLoading, error, isAuthenticated } = useAppSelector((state) => state.auth); + const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth); const { register, @@ -47,9 +47,26 @@ const Login = (): ReactElement => { // Redirect if already authenticated useEffect(() => { if (isAuthenticated) { - navigate('/dashboard'); + // Check if user is super_admin, redirect to super admin dashboard + // Handle both array and JSON string formats + let rolesArray: string[] = []; + if (Array.isArray(roles)) { + rolesArray = roles; + } else if (typeof roles === 'string') { + try { + rolesArray = JSON.parse(roles); + } catch { + rolesArray = []; + } + } + if (rolesArray.includes('super_admin')) { + navigate('/dashboard'); + } else { + // Tenant admin - redirect to tenant dashboard + navigate('/tenant/dashboard'); + } } - }, [isAuthenticated, navigate]); + }, [isAuthenticated, roles, navigate]); // Clear errors only on component mount, not on every auth state change useEffect(() => { @@ -72,8 +89,14 @@ const Login = (): ReactElement => { const message = result.message || 'Login successful'; const description = result.message ? undefined : 'Welcome back!'; showToast.success(message, description); - // Only navigate on success - navigate('/dashboard'); + + // Check roles after login to redirect appropriately + const userRoles = result.data.roles || []; + if (userRoles.includes('super_admin')) { + navigate('/dashboard'); + } else { + navigate('/tenant/dashboard'); + } } } catch (error: any) { // Clear Redux error state since we're handling errors locally @@ -140,7 +163,7 @@ const Login = (): ReactElement => {

- Welcome Back 1 + Welcome Back

Sign in to your account to continue diff --git a/src/pages/ProtectedRoute.tsx b/src/pages/ProtectedRoute.tsx index 3a2f009..d19c6c5 100644 --- a/src/pages/ProtectedRoute.tsx +++ b/src/pages/ProtectedRoute.tsx @@ -7,9 +7,32 @@ interface ProtectedRouteProps { } const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => { - const { isAuthenticated } = useAppSelector((state) => state.auth); + const { isAuthenticated, roles } = useAppSelector((state) => state.auth); - return isAuthenticated ? <>{children} : ; + if (!isAuthenticated) { + return ; + } + + // Check if user has super_admin role + // Handle both array and JSON string formats + let rolesArray: string[] = []; + if (Array.isArray(roles)) { + rolesArray = roles; + } else if (typeof roles === 'string') { + try { + rolesArray = JSON.parse(roles); + } catch { + rolesArray = []; + } + } + const hasSuperAdminRole = rolesArray && rolesArray.length > 0 && rolesArray.includes('super_admin'); + + if (!hasSuperAdminRole) { + // If not super_admin, redirect to tenant login + return ; + } + + return <>{children}; }; export default ProtectedRoute; diff --git a/src/pages/TenantLogin.tsx b/src/pages/TenantLogin.tsx new file mode 100644 index 0000000..e4d67b8 --- /dev/null +++ b/src/pages/TenantLogin.tsx @@ -0,0 +1,361 @@ +import { useState, useEffect } 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 type { ReactElement } from 'react'; +import { Shield } from 'lucide-react'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; +import { loginAsync, clearError } from '@/store/authSlice'; +import { FormField } from '@/components/shared'; +import { PrimaryButton } from '@/components/shared'; +import type { LoginError } from '@/services/auth-service'; +import { showToast } from '@/utils/toast'; + +// Zod validation schema +const loginSchema = 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'), +}); + +type LoginFormData = z.infer; + +const TenantLogin = (): ReactElement => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth); + + const { + register, + handleSubmit, + setError, + formState: { errors }, + clearErrors, + } = useForm({ + resolver: zodResolver(loginSchema), + mode: 'onBlur', + }); + + const [generalError, setGeneralError] = useState(''); + const [rememberMe, setRememberMe] = useState(false); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated) { + // Check if user is super_admin, redirect to super admin dashboard + // Handle both array and JSON string formats + let rolesArray: string[] = []; + if (Array.isArray(roles)) { + rolesArray = roles; + } else if (typeof roles === 'string') { + try { + rolesArray = JSON.parse(roles); + } catch { + rolesArray = []; + } + } + if (rolesArray.includes('super_admin')) { + navigate('/dashboard'); + } else { + // Tenant admin - redirect to tenant dashboard + navigate('/tenant/dashboard'); + } + } + }, [isAuthenticated, roles, navigate]); + + // Clear errors on component mount + useEffect(() => { + dispatch(clearError()); + setGeneralError(''); + clearErrors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSubmit = async (data: LoginFormData): Promise => { + setGeneralError(''); + clearErrors(); + dispatch(clearError()); + + try { + const result = await dispatch(loginAsync(data)).unwrap(); + if (result) { + const message = result.message || 'Login successful'; + showToast.success(message); + + // Check roles after login to redirect appropriately + // Handle both array and JSON string formats + const userRoles = result.data.roles || []; + let rolesArray: string[] = []; + if (Array.isArray(userRoles)) { + rolesArray = userRoles; + } else if (typeof userRoles === 'string') { + try { + rolesArray = JSON.parse(userRoles); + } catch { + rolesArray = []; + } + } + if (rolesArray.includes('super_admin')) { + navigate('/dashboard'); + } else { + navigate('/tenant/dashboard'); + } + } + } catch (error: any) { + dispatch(clearError()); + const loginError = error as LoginError; + + if (loginError && typeof loginError === 'object') { + if ('details' in loginError && Array.isArray(loginError.details)) { + loginError.details.forEach((detail) => { + if (detail.path === 'email' || detail.path === 'password') { + setError(detail.path as keyof LoginFormData, { + type: 'server', + message: detail.message, + }); + } else { + setGeneralError(detail.message); + } + }); + } else if ('error' in loginError) { + if (typeof loginError.error === 'object' && loginError.error !== null && 'message' in loginError.error) { + setGeneralError(loginError.error.message || 'Login failed'); + } else if (typeof loginError.error === 'string') { + setGeneralError(loginError.error); + } else { + setGeneralError('Login failed. Please check your credentials.'); + } + } else { + setGeneralError('An unexpected error occurred. Please try again.'); + } + } else if (error?.message) { + setGeneralError(error.message); + } else { + setGeneralError('Login failed. Please check your credentials and try again.'); + } + } + }; + + return ( +

+ {/* Left Side - Blue Background */} +
+
+ {/* Logo Section */} +
+
+
+ +
+
+ QAssure +
+
-
+
Tenant
+
+ + {/* Heading */} +
+

+ Secure access for
every tenant. +

+
+ + {/* Description */} +
+

+ Log in to manage projects, approvals, documents, and more from a single, compliant control center. +

+
+
+ + {/* Stats Grid */} +
+ {[ + { label: 'Avg. tenant uptime', value: '99.98%' }, + { label: 'Regions supported', value: '12+' }, + { label: 'Active tenants', value: '480+' }, + { label: 'Compliance checks', value: '24/7' }, + ].map((stat, index) => ( +
+
+

{stat.label}

+
+
+

{stat.value}

+
+
+ ))} +
+
+ + {/* Footer Text */} +
+

SSO, MFA, and audit-ready access for every tenant.

+

Need help? Contact your workspace administrator.

+
+
+ + {/* Right Side - Login Form */} +
+
+ {/* Header */} +
+
+
+ Tenant Admin Portal +
+
+

Sign in to your tenant

+

+ Use your work email or configured SSO provider to access the admin portal. +

+
+ + {/* Login Card */} +
+ {/* General Error Message */} + {generalError && ( +
+

{generalError}

+
+ )} + {!generalError && error && !errors.email && !errors.password && ( +
+

{error}

+
+ )} + +
{ + e.preventDefault(); + e.stopPropagation(); + handleSubmit(onSubmit)(e); + }} + className="space-y-4" + noValidate + > + {/* Email Field */} + + + {/* Password Field */} + + + {/* Remember Me & Forgot Password */} +
+
+ setRememberMe(e.target.checked)} + className="w-[18px] h-[18px] rounded border-[#d1d5db] bg-[#112868] text-[#112868] focus:ring-2 focus:ring-[#112868]" + /> + +
+ { + e.preventDefault(); + navigate('/tenant/forgot-password'); + }} + className="text-[13px] font-medium text-[#112868] underline" + > + Forgot Password? + +
+ + {/* Submit Button */} +
+ + {isLoading ? 'Signing in...' : 'Sign In'} + +
+ + {/* New to tenant */} + + + {/* SSO Section */} +
+
+

Single sign-on

+
+ {['Google', 'Microsoft', 'Okta'].map((provider) => ( + + ))} +
+
+
+ +
+ + {/* Footer Links */} + +
+
+
+ ); +}; + +export default TenantLogin; diff --git a/src/pages/TenantProtectedRoute.tsx b/src/pages/TenantProtectedRoute.tsx new file mode 100644 index 0000000..b6adb07 --- /dev/null +++ b/src/pages/TenantProtectedRoute.tsx @@ -0,0 +1,38 @@ +import { Navigate } from 'react-router-dom'; +import { useAppSelector } from '@/hooks/redux-hooks'; +import type { ReactElement } from 'react'; + +interface TenantProtectedRouteProps { + children: React.ReactNode; +} + +const TenantProtectedRoute = ({ children }: TenantProtectedRouteProps): ReactElement => { + const { isAuthenticated, roles } = useAppSelector((state) => state.auth); + + if (!isAuthenticated) { + return ; + } + + // Check if user has super_admin role - if yes, redirect to super admin dashboard + // Handle both array and JSON string formats + let rolesArray: string[] = []; + if (Array.isArray(roles)) { + rolesArray = roles; + } else if (typeof roles === 'string') { + try { + rolesArray = JSON.parse(roles); + } catch { + rolesArray = []; + } + } + const hasSuperAdminRole = rolesArray.includes('super_admin'); + + if (hasSuperAdminRole) { + // If super_admin, redirect to super admin dashboard + return ; + } + + return <>{children}; +}; + +export default TenantProtectedRoute; diff --git a/src/routes/index.tsx b/src/routes/index.tsx new file mode 100644 index 0000000..46ca584 --- /dev/null +++ b/src/routes/index.tsx @@ -0,0 +1,48 @@ +import { Routes, Route } from 'react-router-dom'; +import type { ReactElement } from 'react'; +import NotFound from '@/pages/NotFound'; +import ProtectedRoute from '@/pages/ProtectedRoute'; +import TenantProtectedRoute from '@/pages/TenantProtectedRoute'; +import { publicRoutes } from './public-routes'; +import { superAdminRoutes } from './super-admin-routes'; +import { tenantAdminRoutes } from './tenant-admin-routes'; + +// App Routes Component +export const AppRoutes = (): ReactElement => { + return ( + + {/* Public Routes */} + {publicRoutes.map((route) => ( + + ))} + + {/* Super Admin Routes */} + {superAdminRoutes.map((route) => ( + {route.element}} + /> + ))} + + {/* Tenant Admin Routes */} + {tenantAdminRoutes.map((route) => ( + {route.element}} + /> + ))} + + {/* 404 - Catch all route */} + + + + } + /> + + ); +}; diff --git a/src/routes/public-routes.tsx b/src/routes/public-routes.tsx new file mode 100644 index 0000000..0ce3539 --- /dev/null +++ b/src/routes/public-routes.tsx @@ -0,0 +1,38 @@ +import Login from '@/pages/Login'; +import TenantLogin from '@/pages/TenantLogin'; +import ForgotPassword from '@/pages/ForgotPassword'; +import ResetPassword from '@/pages/ResetPassword'; +import type { ReactElement } from 'react'; + +export interface RouteConfig { + path: string; + element: ReactElement; +} + +// Public routes (no authentication required) +export const publicRoutes: RouteConfig[] = [ + { + path: '/', + element: , + }, + { + path: '/forgot-password', + element: , + }, + { + path: '/reset-password', + element: , + }, + { + path: '/tenant/login', + element: , + }, + { + path: '/tenant/forgot-password', + element: , + }, + { + path: '/tenant/reset-password', + element: , + }, +]; diff --git a/src/routes/super-admin-routes.tsx b/src/routes/super-admin-routes.tsx new file mode 100644 index 0000000..6e2bbc1 --- /dev/null +++ b/src/routes/super-admin-routes.tsx @@ -0,0 +1,50 @@ +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 Roles from '@/pages/Roles'; +import Modules from '@/pages/Modules'; +import AuditLogs from '@/pages/AuditLogs'; +import type { ReactElement } from 'react'; + +export interface RouteConfig { + path: string; + element: ReactElement; +} + +// Super Admin routes (requires super_admin role) +export const superAdminRoutes: RouteConfig[] = [ + { + path: '/dashboard', + element: , + }, + { + path: '/tenants', + element: , + }, + { + path: '/tenants/create-wizard', + element: , + }, + { + path: '/tenants/:id', + element: , + }, + { + path: '/users', + element: , + }, + { + path: '/roles', + element: , + }, + { + path: '/modules', + element: , + }, + { + path: '/audit-logs', + element: , + }, +]; diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx new file mode 100644 index 0000000..3d0a4e5 --- /dev/null +++ b/src/routes/tenant-admin-routes.tsx @@ -0,0 +1,21 @@ +import Dashboard from '@/pages/Dashboard'; +import type { ReactElement } from 'react'; + +export interface RouteConfig { + path: string; + element: ReactElement; +} + +// Tenant Admin routes (requires authentication but NOT super_admin role) +export const tenantAdminRoutes: RouteConfig[] = [ + { + path: '/tenant/dashboard', + element: , // TODO: Replace with TenantDashboard when created + }, + // Add more tenant admin routes here as needed + // Example: + // { + // path: '/tenant/users', + // element: , + // }, +];