Update environment configuration for production, remove automated Jenkins pipeline, and refactor routing in App component to utilize AppRoutes. Enhance Login and ProtectedRoute components to manage user roles and navigation based on authentication status.

This commit is contained in:
Yashwin 2026-01-28 17:41:39 +05:30
parent 60b14f3fe7
commit cc11f403de
12 changed files with 616 additions and 101 deletions

2
.env
View File

@ -1,2 +1,4 @@
# VITE_FRONTEND_BASE_URL=http://localhost:5173
# VITE_API_BASE_URL=http://localhost:3000/api/v1 # 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 VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1

View File

@ -1,101 +1,12 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import Login from "./pages/Login"; import { AppRoutes } from "@/routes";
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";
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Toaster position="top-right" richColors /> <Toaster position="top-right" richColors />
<Routes> <AppRoutes />
<Route path="/" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/tenants"
element={
<ProtectedRoute>
<Tenants />
</ProtectedRoute>
}
/>
<Route
path="/tenants/create-wizard"
element={
<ProtectedRoute>
<CreateTenantWizard />
</ProtectedRoute>
}
/>
<Route
path="/tenants/:id"
element={
<ProtectedRoute>
<TenantDetails />
</ProtectedRoute>
}
/>
<Route
path="/users"
element={
<ProtectedRoute>
<Users />
</ProtectedRoute>
}
/>
<Route
path="/roles"
element={
<ProtectedRoute>
<Roles />
</ProtectedRoute>
}
/>
<Route
path="/modules"
element={
<ProtectedRoute>
<Modules />
</ProtectedRoute>
}
/>
<Route
path="/audit-logs"
element={
<ProtectedRoute>
<AuditLogs />
</ProtectedRoute>
}
/>
{/* Catch-all route for 404 */}
<Route
path="*"
element={
<ProtectedRoute>
<NotFound />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter> </BrowserRouter>
); );
} }

View File

@ -102,7 +102,7 @@ const subscriptionTierOptions = [
// Helper function to get base URL without protocol // Helper function to get base URL without protocol
const getBaseUrlWithoutProtocol = (): string => { 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://) // Remove protocol (http:// or https://)
return apiBaseUrl.replace(/^https?:\/\//, ''); return apiBaseUrl.replace(/^https?:\/\//, '');
}; };

View File

@ -29,7 +29,7 @@ type LoginFormData = z.infer<typeof loginSchema>;
const Login = (): ReactElement => { const Login = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { isLoading, error, isAuthenticated } = useAppSelector((state) => state.auth); const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth);
const { const {
register, register,
@ -47,9 +47,26 @@ const Login = (): ReactElement => {
// Redirect if already authenticated // Redirect if already authenticated
useEffect(() => { useEffect(() => {
if (isAuthenticated) { 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 // Clear errors only on component mount, not on every auth state change
useEffect(() => { useEffect(() => {
@ -72,8 +89,14 @@ const Login = (): ReactElement => {
const message = result.message || 'Login successful'; const message = result.message || 'Login successful';
const description = result.message ? undefined : 'Welcome back!'; const description = result.message ? undefined : 'Welcome back!';
showToast.success(message, description); 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) { } catch (error: any) {
// Clear Redux error state since we're handling errors locally // Clear Redux error state since we're handling errors locally
@ -140,7 +163,7 @@ const Login = (): ReactElement => {
</div> </div>
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2"> <h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
Welcome Back 1 Welcome Back
</h1> </h1>
<p className="text-sm md:text-base text-[#6b7280]"> <p className="text-sm md:text-base text-[#6b7280]">
Sign in to your account to continue Sign in to your account to continue

View File

@ -7,9 +7,32 @@ interface ProtectedRouteProps {
} }
const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => { const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => {
const { isAuthenticated } = useAppSelector((state) => state.auth); const { isAuthenticated, roles } = useAppSelector((state) => state.auth);
return isAuthenticated ? <>{children}</> : <Navigate to="/" replace />; if (!isAuthenticated) {
return <Navigate to="/" replace />;
}
// 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 <Navigate to="/tenant/login" replace />;
}
return <>{children}</>;
}; };
export default ProtectedRoute; export default ProtectedRoute;

361
src/pages/TenantLogin.tsx Normal file
View File

@ -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<typeof loginSchema>;
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<LoginFormData>({
resolver: zodResolver(loginSchema),
mode: 'onBlur',
});
const [generalError, setGeneralError] = useState<string>('');
const [rememberMe, setRememberMe] = useState<boolean>(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<void> => {
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 (
<div className="min-h-screen bg-[#f6f9ff] relative flex">
{/* Left Side - Blue Background */}
<div className="hidden lg:flex lg:w-[48%] bg-[#112868] flex-col justify-between px-8 py-8 min-w-[320px]">
<div className="flex flex-col gap-2.5 py-22">
{/* Logo Section */}
<div className="flex flex-col gap-3 max-w-[280px]">
<div className="flex items-center justify-between px-2 w-[206px]">
<div className="bg-[#23dce1] rounded-[10px] shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] w-9 h-9 flex items-center justify-center shrink-0">
<Shield className="w-5 h-5 text-white" strokeWidth={1.67} />
</div>
<div className="text-[18px] font-bold text-[#23dce1] tracking-[-0.36px]">
QAssure
</div>
<div className="text-[13px] font-medium text-white uppercase">-</div>
<div className="text-[13px] font-medium text-white uppercase">Tenant</div>
</div>
{/* Heading */}
<div className="flex flex-col">
<h2 className="text-2xl font-semibold text-white leading-normal">
Secure access for<br />every tenant.
</h2>
</div>
{/* Description */}
<div className="opacity-90">
<p className="text-sm font-medium text-white leading-[21px]">
Log in to manage projects, approvals, documents, and more from a single, compliant control center.
</p>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-2.5 mt-16 max-w-[377px]">
{[
{ 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) => (
<div
key={index}
className="backdrop-blur-[20px] border border-white rounded-md p-3"
style={{
backgroundImage: 'linear-gradient(108.34deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.3) 100.2%)',
}}
>
<div className="opacity-90 mb-1">
<p className="text-xs font-normal text-white leading-normal">{stat.label}</p>
</div>
<div>
<p className="text-base font-semibold text-white leading-normal">{stat.value}</p>
</div>
</div>
))}
</div>
</div>
{/* Footer Text */}
<div className="flex flex-col gap-1 opacity-90">
<p className="text-sm font-normal text-white">SSO, MFA, and audit-ready access for every tenant.</p>
<p className="text-sm font-normal text-white">Need help? Contact your workspace administrator.</p>
</div>
</div>
{/* Right Side - Login Form */}
<div className="flex-1 flex items-center justify-center px-9 py-8">
<div className="w-full max-w-[507px] flex flex-col gap-5">
{/* Header */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="bg-[#edf3fe] px-2.5 py-1 rounded-full">
<span className="text-[11px] font-medium text-[#0f1724]">Tenant Admin Portal</span>
</div>
</div>
<h1 className="text-[22px] font-semibold text-[#0f1724]">Sign in to your tenant</h1>
<p className="text-sm font-normal text-[#6b7280]">
Use your work email or configured SSO provider to access the admin portal.
</p>
</div>
{/* Login Card */}
<div className="bg-white border border-[#d1d5db] rounded-lg p-5 flex flex-col gap-2.5">
{/* General Error Message */}
{generalError && (
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{generalError}</p>
</div>
)}
{!generalError && error && !errors.email && !errors.password && (
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{error}</p>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
handleSubmit(onSubmit)(e);
}}
className="space-y-4"
noValidate
>
{/* Email Field */}
<FormField
label="Email"
type="email"
placeholder="Enter your email"
required
error={errors.email?.message}
{...register('email')}
/>
{/* Password Field */}
<FormField
label="Password"
type="password"
placeholder="Enter your password"
required
error={errors.password?.message}
{...register('password')}
/>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="remember-me"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-[18px] h-[18px] rounded border-[#d1d5db] bg-[#112868] text-[#112868] focus:ring-2 focus:ring-[#112868]"
/>
<label htmlFor="remember-me" className="text-sm font-normal text-[#0f1724] cursor-pointer">
Remember Me
</label>
</div>
<a
href="/tenant/forgot-password"
onClick={(e) => {
e.preventDefault();
navigate('/tenant/forgot-password');
}}
className="text-[13px] font-medium text-[#112868] underline"
>
Forgot Password?
</a>
</div>
{/* Submit Button */}
<div className="pt-1">
<PrimaryButton
type="submit"
size="large"
disabled={isLoading}
className="w-full h-12 text-base font-medium"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</PrimaryButton>
</div>
{/* New to tenant */}
<div className="flex items-center justify-between h-8">
<p className="text-xs font-normal text-[#6b7280]">New to this tenant?</p>
<a
href="/tenant/request-access"
onClick={(e) => {
e.preventDefault();
navigate('/tenant/request-access');
}}
className="text-[13px] font-medium text-[#23dce1] underline"
>
Request Access
</a>
</div>
{/* SSO Section */}
<div className="pt-1">
<div className="flex flex-col gap-2.5 h-[60px]">
<p className="text-xs font-normal text-[#6b7280] uppercase tracking-[0.48px]">Single sign-on</p>
<div className="grid grid-cols-3 gap-2">
{['Google', 'Microsoft', 'Okta'].map((provider) => (
<button
key={provider}
type="button"
className="bg-white border border-[#9ca3af] rounded px-4 py-1 flex items-center justify-center gap-2 h-10 text-sm font-medium text-[#0f1724] hover:bg-[#f5f7fa] transition-colors"
>
{provider}
</button>
))}
</div>
</div>
</div>
</form>
</div>
{/* Footer Links */}
<div className="flex gap-4 items-start">
<a href="/tenant/status" className="text-xs font-normal text-[#6b7280]">
Status page
</a>
<a href="/tenant/security" className="text-xs font-normal text-[#6b7280]">
Security
</a>
<a href="/tenant/help" className="text-xs font-normal text-[#6b7280]">
Help
</a>
</div>
</div>
</div>
</div>
);
};
export default TenantLogin;

View File

@ -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 <Navigate to="/tenant/login" replace />;
}
// 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 <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
export default TenantProtectedRoute;

48
src/routes/index.tsx Normal file
View File

@ -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 (
<Routes>
{/* Public Routes */}
{publicRoutes.map((route) => (
<Route key={route.path} path={route.path} element={route.element} />
))}
{/* Super Admin Routes */}
{superAdminRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={<ProtectedRoute>{route.element}</ProtectedRoute>}
/>
))}
{/* Tenant Admin Routes */}
{tenantAdminRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={<TenantProtectedRoute>{route.element}</TenantProtectedRoute>}
/>
))}
{/* 404 - Catch all route */}
<Route
path="*"
element={
<ProtectedRoute>
<NotFound />
</ProtectedRoute>
}
/>
</Routes>
);
};

View File

@ -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: <Login />,
},
{
path: '/forgot-password',
element: <ForgotPassword />,
},
{
path: '/reset-password',
element: <ResetPassword />,
},
{
path: '/tenant/login',
element: <TenantLogin />,
},
{
path: '/tenant/forgot-password',
element: <ForgotPassword />,
},
{
path: '/tenant/reset-password',
element: <ResetPassword />,
},
];

View File

@ -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: <Dashboard />,
},
{
path: '/tenants',
element: <Tenants />,
},
{
path: '/tenants/create-wizard',
element: <CreateTenantWizard />,
},
{
path: '/tenants/:id',
element: <TenantDetails />,
},
{
path: '/users',
element: <Users />,
},
{
path: '/roles',
element: <Roles />,
},
{
path: '/modules',
element: <Modules />,
},
{
path: '/audit-logs',
element: <AuditLogs />,
},
];

View File

@ -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: <Dashboard />, // TODO: Replace with TenantDashboard when created
},
// Add more tenant admin routes here as needed
// Example:
// {
// path: '/tenant/users',
// element: <TenantUsers />,
// },
];