From e6fb7687ba8f08a9df3f87bf34f048d727bd5ac3 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Wed, 10 Jun 2026 11:08:59 +0530 Subject: [PATCH] feat: introduce AuthLayout and update password reset validation to require terms acceptance --- src/components/layout/AuthLayout.tsx | 198 +++++++++++ src/pages/ForgotPassword.tsx | 212 ++++++------ src/pages/Login.tsx | 300 ++++++++--------- src/pages/ResetPassword.tsx | 375 +++++++++++---------- src/pages/tenant/TenantLogin.tsx | 483 +++++++++++---------------- 5 files changed, 858 insertions(+), 710 deletions(-) create mode 100644 src/components/layout/AuthLayout.tsx diff --git a/src/components/layout/AuthLayout.tsx b/src/components/layout/AuthLayout.tsx new file mode 100644 index 0000000..a347ccb --- /dev/null +++ b/src/components/layout/AuthLayout.tsx @@ -0,0 +1,198 @@ +import type { ReactElement, ReactNode } from "react"; +import { Shield } from "lucide-react"; + +interface AuthLayoutProps { + children: ReactNode; + portalType?: "admin" | "tenant"; + primaryColor?: string; + logoUrl?: string; + secondaryColor?: string; +} + +const adminStats = [ + { + title: "Tenants Managed", + value: "50+", + }, + { + title: "Regions supported", + value: "12+", + }, + { + title: "System Uptime", + value: "99.9%", + }, + { + title: "Active Users", + value: "1200+", + }, +]; + +const tenantStats = [ + { + title: "Avg. Response time", + value: "< 2min", + }, + { + title: "Regions supported", + value: "12+", + }, + { + title: "Monthly Logins", + value: "1240+", + }, + { + title: "Compliance checks", + value: "24/7", + }, +]; + +export const AuthLayout = ({ + children, + portalType = "admin", + primaryColor, + logoUrl, + secondaryColor, +}: AuthLayoutProps): ReactElement => { + const stats = portalType === "admin" ? adminStats : tenantStats; + const isTenant = portalType === "tenant"; + + return ( +
+ {/* Left Section */} +
+
+
+ {/* Content Block */} +
+
+ {logoUrl ? ( + Logo { + e.currentTarget.style.display = "none"; + const fallback = e.currentTarget + .nextElementSibling as HTMLElement; + if (fallback) fallback.style.display = "flex"; + }} + /> + ) : null} + +
+ +
+ +
+ + QAssure + + + - {portalType} + +
+
+ +

+ {isTenant + ? "Secure access for every tenant." + : "Secure control across all products"} +

+ +

+ {isTenant + ? "Log in to manage projects, approvals, documents, and more from a single, compliant control center." + : "QAssure unifies multiple products into a single platform. The admin console enables centralized management of users, tenants, configurations, and operations."} +

+
+ + {/* Stats Grid */} +
+ {stats.map((item) => ( +
+

+ {item.title} +

+ +

+ {item.value} +

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

+ {isTenant + ? "SSO, MFA, and audit-ready access for every tenant." + : "Centralized control with secure access for all admin operations."} +

+ +

+ Need help? Contact your workspace administrator. +

+
+
+ + {/* Right Section */} +
+
+
+ + {isTenant ? "Tenant Admin Portal" : "Admin Portal"} + +
+ + {children} + + {/* Footer Links */} +
+ + + + + +
+
+
+
+ ); +}; diff --git a/src/pages/ForgotPassword.tsx b/src/pages/ForgotPassword.tsx index b42a28d..7dffef8 100644 --- a/src/pages/ForgotPassword.tsx +++ b/src/pages/ForgotPassword.tsx @@ -1,13 +1,16 @@ import { useState } from "react"; -import { useNavigate, Link } from "react-router-dom"; +import { useNavigate, Link, useLocation } 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, ArrowLeft } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import { authService } from "@/services/auth-service"; import { FormField } from "@/components/shared"; import { PrimaryButton } from "@/components/shared"; +import { useAppSelector } from "@/hooks/redux-hooks"; +import { useTenantTheme } from "@/hooks/useTenantTheme"; +import { AuthLayout } from "@/components/layout/AuthLayout"; // Zod validation schema const forgotPasswordSchema = z.object({ @@ -21,10 +24,20 @@ type ForgotPasswordFormData = z.infer; const ForgotPassword = (): ReactElement => { const navigate = useNavigate(); + const location = useLocation(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const { theme, logoUrl } = useAppSelector((state) => state.theme); + + // Fetch and apply tenant theme if on tenant route + useTenantTheme(); + + const isTenantRoute = location.pathname.startsWith("/tenant"); + const portalType = isTenantRoute ? "tenant" : "admin"; + const loginPath = isTenantRoute ? "/tenant/login" : "/"; + const { register, handleSubmit, @@ -70,114 +83,103 @@ const ForgotPassword = (): ReactElement => { }; return ( -
-
- {/* Forgot Password Card */} -
- {/* Logo Section */} -
-
-
- -
-
- QAssure -
-
-
-
-

- Forgot Password -

-

- Enter your email address and we'll send you a link to reset your - password -

-
- - {/* Success Message */} - {success && ( -
-

{success}

-
- )} - - {/* Error Message */} - {error && ( -
-

{error}

-
- )} - - {!success ? ( -
{ - e.preventDefault(); - e.stopPropagation(); - handleSubmit(onSubmit)(e); - }} - className="space-y-4" - noValidate - > - {/* Email Field */} - - - {/* Submit Button */} -
- - {isLoading ? "Sending..." : "Send Reset Link"} - -
- - ) : ( -
-
- navigate("/")} - className="w-full h-12 text-sm font-medium" - > - Back to Login - -
-
- )} - - {/* Back to Login Link */} - {!success && ( -
- - - Back to Login - -
- )} + + {/* Header */} +
+ {/* Heading */} +
+

+ Forgot Password +

- {/* Footer */} -
-

- © 2026 QAssure. All rights reserved. + {/* Description */} +

+

+ Enter your email address and we'll send you a link to reset your + password

-
+ + {/* Success Message */} + {success && ( +
+

{success}

+
+ )} + + {!success ? ( +
{ + e.preventDefault(); + e.stopPropagation(); + handleSubmit(onSubmit)(e); + }} + className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white space-y-4" + noValidate + > + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Email Field */} + + + {/* Submit Button */} +
+ + {isLoading ? "Sending..." : "Send Reset Link"} + +
+ + ) : ( +
+
+ navigate(loginPath)} + className="w-full h-11 text-base font-semibold rounded-md shadow-sm" + > + Back to Login + +
+
+ )} + + {/* Back to Login Link */} + {!success && ( +
+ + + Back to Login + +
+ )} +
); }; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index ba546ff..b4fcb52 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,27 +1,27 @@ -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'; +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 { 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"; +import { AuthLayout } from "@/components/layout/AuthLayout"; // Zod validation schema const loginSchema = z.object({ email: z .string() - .min(1, 'Email is required') - .email('Please enter a valid email address'), + .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'), + .min(1, "Password is required") + .min(6, "Password must be at least 6 characters"), }); type LoginFormData = z.infer; @@ -29,7 +29,9 @@ type LoginFormData = z.infer; const Login = (): ReactElement => { const navigate = useNavigate(); const dispatch = useAppDispatch(); - const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth); + const { isLoading, error, isAuthenticated, roles } = useAppSelector( + (state) => state.auth, + ); const { register, @@ -39,10 +41,10 @@ const Login = (): ReactElement => { clearErrors, } = useForm({ resolver: zodResolver(loginSchema), - mode: 'onBlur', // Validate on blur for better UX + mode: "onBlur", // Validate on blur for better UX }); - const [generalError, setGeneralError] = useState(''); + const [generalError, setGeneralError] = useState(""); // Redirect if already authenticated useEffect(() => { @@ -52,34 +54,33 @@ const Login = (): ReactElement => { let rolesArray: string[] = []; if (Array.isArray(roles)) { rolesArray = roles; - } else if (typeof roles === 'string') { + } else if (typeof roles === "string") { try { rolesArray = JSON.parse(roles); } catch { rolesArray = []; } } - if (rolesArray.includes('super_admin')) { - navigate('/dashboard'); + if (rolesArray.includes("super_admin")) { + navigate("/dashboard"); } else { // Tenant admin - redirect to tenant landing page (workspace selector) - navigate('/tenant/landing'); + navigate("/tenant/landing"); } } }, [isAuthenticated, roles, navigate]); // Clear errors only on component mount, not on every auth state change useEffect(() => { - // Only clear errors on initial mount dispatch(clearError()); - setGeneralError(''); + setGeneralError(""); clearErrors(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Empty dependency array - only run on mount const onSubmit = async (data: LoginFormData): Promise => { // Clear previous errors - setGeneralError(''); + setGeneralError(""); clearErrors(); dispatch(clearError()); @@ -89,173 +90,172 @@ const Login = (): ReactElement => { // Check if password reset is required if (result.data.requirePasswordReset) { const { tempToken, resetReason } = result.data; - showToast.info('Password reset required', 'For security reasons, please update your password.'); + showToast.info( + "Password reset required", + "For security reasons, please update your password.", + ); navigate(`/reset-password?token=${tempToken}&reason=${resetReason}`); return; } - const message = result.message || 'Login successful'; - const description = result.message ? undefined : 'Welcome back!'; + const message = result.message || "Login successful"; + const description = result.message ? undefined : "Welcome back!"; showToast.success(message, description); - + // Check roles after login to redirect appropriately const userRoles = result.data.roles || []; - if (userRoles.includes('super_admin')) { - navigate('/dashboard'); + if (userRoles.includes("super_admin")) { + navigate("/dashboard"); } else { - navigate('/tenant/landing'); + navigate("/tenant/landing"); } } } catch (error: any) { // Clear Redux error state since we're handling errors locally dispatch(clearError()); - // Handle error from unwrap() - error is the rejected value from rejectWithValue const loginError = error as LoginError; - if (loginError && typeof loginError === 'object') { + if (loginError && typeof loginError === "object") { // Check for validation errors with details array - if ('details' in loginError && Array.isArray(loginError.details)) { + if ("details" in loginError && Array.isArray(loginError.details)) { // Validation errors from server - set field-specific errors loginError.details.forEach((detail) => { - if (detail.path === 'email' || detail.path === 'password') { + if (detail.path === "email" || detail.path === "password") { setError(detail.path as keyof LoginFormData, { - type: 'server', + type: "server", message: detail.message, }); } else { - // If error is for a field we don't handle, show as general error setGeneralError(detail.message); } }); - } else if ('error' in loginError) { - // Check if error is an object with message property - if (typeof loginError.error === 'object' && loginError.error !== null && 'message' in loginError.error) { - // General error from server with object structure - setGeneralError(loginError.error.message || 'Login failed'); - } else if (typeof loginError.error === 'string') { - // Error is a string + } 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.'); + setGeneralError("Login failed. Please check your credentials."); } } else { - // Fallback for unknown error structure - setGeneralError('An unexpected error occurred. Please try again.'); + setGeneralError("An unexpected error occurred. Please try again."); } } else if (error?.message) { - // Network error or other error setGeneralError(error.message); } else { - // Complete fallback - setGeneralError('Login failed. Please check your credentials and try again.'); + setGeneralError( + "Login failed. Please check your credentials and try again.", + ); } } }; return ( -
-
- {/* Login Card */} -
- {/* Logo Section */} -
-
-
- -
-
- QAssure -
-
-
-
-

- Welcome Back -

-

- Sign in to your account to continue -

-
- - {/* General Error Message - Prioritize local error over Redux error */} - {generalError && ( -
-

{generalError}

-
- )} - {/* Show Redux error only if no local error and no field errors */} - {!generalError && error && !errors.email && !errors.password && ( -
-

{error}

-
- )} - -
{ - e.preventDefault(); - e.stopPropagation(); - handleSubmit(onSubmit)(e); - }} - className="space-y-4" - noValidate - > - {/* Email Field */} - - - {/* Password Field */} - - - {/* Forgot Password Link */} - - - {/* Submit Button */} -
- - {isLoading ? 'Signing in...' : 'Sign In'} - -
- + + {/* Header */} +
+ {/* Heading */} +
+

+ Sign in +

- {/* Footer */} -
-

- © 2026 QAssure. All rights reserved. + {/* Description */} +

+

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

-
+ + {/* Form Card */} +
{ + e.preventDefault(); + e.stopPropagation(); + handleSubmit(onSubmit)(e); + }} + className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white" + noValidate + > + {/* General Error Message - Prioritize local error over Redux error */} + {generalError && ( +
+

+ {generalError} +

+
+ )} + + {/* Show Redux error only if no local error and no field errors */} + {!generalError && error && !errors.email && !errors.password && ( +
+

{error}

+
+ )} + + {/* Email */} + + + {/* Password */} + + + {/* Remember Me */} +
+ + + +
+ + {/* Login Button */} +
+ + {isLoading ? "Signing in..." : "Login"} + +
+ +
); }; diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx index 57c016b..539ffec 100644 --- a/src/pages/ResetPassword.tsx +++ b/src/pages/ResetPassword.tsx @@ -1,14 +1,14 @@ -import { useState, useEffect } from 'react'; -import { useNavigate, useSearchParams, Link } 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, ArrowLeft } from 'lucide-react'; -import { authService } from '@/services/auth-service'; -import { FormField } from '@/components/shared'; -import { PrimaryButton } from '@/components/shared'; -import { showToast } from '@/utils/toast'; +import { useState, useEffect } from "react"; +import { useNavigate, useSearchParams, useLocation } 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 { authService } from "@/services/auth-service"; +import { FormField } from "@/components/shared"; +import { PrimaryButton } from "@/components/shared"; +import { showToast } from "@/utils/toast"; +import { AuthLayout } from "@/components/layout/AuthLayout"; // Zod validation schema - token is optional if provided in URL const createResetPasswordSchema = (hasTokenFromUrl: boolean) => @@ -16,26 +16,33 @@ const createResetPasswordSchema = (hasTokenFromUrl: boolean) => .object({ token: hasTokenFromUrl ? z.string().optional() - : z.string().min(1, 'Reset token is required'), + : z.string().min(1, "Reset token is required"), password: z .string() - .min(1, 'Password is required') - .min(6, 'Password must be at least 6 characters'), - confirmPassword: z.string().min(1, 'Please confirm your password'), + .min(1, "Password is required") + .min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(1, "Please confirm your password"), + acceptTerms: z.boolean().refine((val) => val === true, { + message: "You must accept the Terms of Service and Privacy Policy", + }), }) .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'], + message: "Passwords do not match", + path: ["confirmPassword"], }); const ResetPassword = (): ReactElement => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const location = useLocation(); const [isLoading, setIsLoading] = useState(false); - const [success, setSuccess] = useState(''); + const [success, setSuccess] = useState(""); - const tokenFromUrl = searchParams.get('token') || ''; - const reason = searchParams.get('reason') || ''; + const isTenantRoute = location.pathname.startsWith("/tenant"); + const portalType = isTenantRoute ? "tenant" : "admin"; + + const tokenFromUrl = searchParams.get("token") || ""; + const reason = searchParams.get("reason") || ""; const hasTokenFromUrl = Boolean(tokenFromUrl); const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl); @@ -50,57 +57,72 @@ const ResetPassword = (): ReactElement => { clearErrors, } = useForm({ resolver: zodResolver(resetPasswordSchema), - mode: 'onBlur', + mode: "onBlur", defaultValues: { token: tokenFromUrl || undefined, - password: '', - confirmPassword: '', + password: "", + confirmPassword: "", + acceptTerms: false as any, }, }); // Set token from URL if available useEffect(() => { if (tokenFromUrl) { - setValue('token', tokenFromUrl); + setValue("token", tokenFromUrl); } }, [tokenFromUrl, setValue]); const onSubmit = async (data: ResetPasswordFormData): Promise => { - setSuccess(''); + setSuccess(""); clearErrors(); setIsLoading(true); try { // Always use token from URL if available, otherwise use form data - const tokenToUse = tokenFromUrl || data.token || ''; + const tokenToUse = tokenFromUrl || data.token || ""; const response = await authService.resetPassword({ token: tokenToUse, password: data.password, }); if (response.success) { - const message = response.message || response.data?.message || 'Password reset successfully!'; - const description = (response.message || response.data?.message) ? undefined : 'You can now login with your new password'; - const successMessage = response.message || response.data?.message || 'Password reset successfully! You can now login with your new password.'; + const message = + response.message || + response.data?.message || + "Password reset successfully!"; + const description = + response.message || response.data?.message + ? undefined + : "You can now login with your new password"; + const successMessage = + response.message || + response.data?.message || + "Password reset successfully! You can now login with your new password."; setSuccess(successMessage); showToast.success(message, description); // Redirect to login after 2 seconds setTimeout(() => { - navigate('/'); + navigate("/"); }, 2000); } } catch (err: any) { - console.error('Reset password error:', err); + console.error("Reset password error:", err); // Handle validation errors from API - if (err?.response?.data?.details && Array.isArray(err.response.data.details)) { + if ( + err?.response?.data?.details && + Array.isArray(err.response.data.details) + ) { const validationErrors = err.response.data.details; - validationErrors.forEach((detail: { path: string; message: string }) => { - if (detail.path === 'token' || detail.path === 'password') { - setError(detail.path as keyof ResetPasswordFormData, { - type: 'server', - message: detail.message, - }); - } - }); + validationErrors.forEach( + (detail: { path: string; message: string }) => { + if (detail.path === "token" || detail.path === "password") { + setError(detail.path as keyof ResetPasswordFormData, { + type: "server", + message: detail.message, + }); + } + }, + ); } else { // Handle general errors const errorMessage = @@ -108,10 +130,13 @@ const ResetPassword = (): ReactElement => { err?.response?.data?.error || err?.response?.data?.message || err?.message || - 'Failed to reset password. Please try again.'; - setError('root', { - type: 'server', - message: typeof errorMessage === 'string' ? errorMessage : 'Failed to reset password. Please try again.', + "Failed to reset password. Please try again."; + setError("root", { + type: "server", + message: + typeof errorMessage === "string" + ? errorMessage + : "Failed to reset password. Please try again.", }); } } finally { @@ -120,140 +145,144 @@ const ResetPassword = (): ReactElement => { }; return ( -
-
- {/* Reset Password Card */} -
- {/* Logo Section */} -
-
-
- -
-
- QAssure -
-
-
-
-

- {reason === 'FIRST_LOGIN' ? 'Set Initial Password' : - reason === 'EXPIRED' ? 'Update Expired Password' : - 'Reset Password'} -

-

- {reason === 'FIRST_LOGIN' ? 'Welcome to QAssure! Please set a new password for your account to continue.' : - reason === 'EXPIRED' ? 'Your password has expired. For security, please choose a new password.' : - tokenFromUrl ? 'Enter your new password to reset' : - 'Enter your reset token and new password'} -

-
- - {/* Success Message */} - {success && ( -
-

{success}

-
- )} - - {!success ? ( -
{ - e.preventDefault(); - e.stopPropagation(); - handleSubmit(onSubmit)(e); - }} - className="space-y-4" - noValidate - > - {/* General Error Display */} - {errors.root && ( -
-

{errors.root.message}

-
- )} - - {/* Token Field - Only show if not in URL */} - {!hasTokenFromUrl && ( - - )} - - {/* Password Field */} - - - {/* Confirm Password Field */} - - - {/* Submit Button */} -
- - {isLoading ? 'Resetting...' : 'Reset Password'} - -
- - ) : ( -
-
- navigate('/')} - className="w-full h-12 text-sm font-medium" - > - Go to Login - -
-
- )} - - {/* Back to Login Link */} - {!success && !reason && ( -
- - - Back to Login - -
- )} + + {/* Header */} +
+ {/* Heading */} +
+

+ {reason === "FIRST_LOGIN" + ? "Set Initial Password" + : reason === "EXPIRED" + ? "Update Expired Password" + : "Set New Password"} +

- {/* Footer */} -
-

- © 2026 QAssure. All rights reserved. + {/* Description */} +

+

+ {reason === "FIRST_LOGIN" + ? "Welcome to QAssure! Please set a new password for your account to continue." + : reason === "EXPIRED" + ? "Your password has expired. For security, please choose a new password." + : "Create a strong password to activate your account and access the portal"}

-
+ + {/* Success Message */} + {success && ( +
+

{success}

+
+ )} + + {!success ? ( +
{ + e.preventDefault(); + e.stopPropagation(); + handleSubmit(onSubmit)(e); + }} + className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white space-y-4" + noValidate + > + {/* General Error Display */} + {errors.root && ( +
+

+ {errors.root.message} +

+
+ )} + + {/* Token Field - Only show if not in URL */} + {!hasTokenFromUrl && ( + + )} + + {/* Password Field */} + + + {/* Confirm Password Field */} + + + {/* Accept Terms Checkbox */} +
+ + {errors.acceptTerms ? ( + + {errors.acceptTerms.message} + + ) : ( + + You must accept to continue. + + )} +
+ + {/* Submit Button */} +
+ + {isLoading ? "Resetting..." : "Login"} + +

+ You will be redirected to the Tenant Admin Portal after saving. +

+
+ + ) : ( +
+
+ navigate("/")} + className="w-full h-11 text-base font-semibold rounded-md shadow-sm" + > + Go to Login + +
+
+ )} +
); }; diff --git a/src/pages/tenant/TenantLogin.tsx b/src/pages/tenant/TenantLogin.tsx index dc073d9..41f4eec 100644 --- a/src/pages/tenant/TenantLogin.tsx +++ b/src/pages/tenant/TenantLogin.tsx @@ -1,28 +1,28 @@ -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'; -import { useTenantTheme } from '@/hooks/useTenantTheme'; +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 { 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"; +import { useTenantTheme } from "@/hooks/useTenantTheme"; +import { AuthLayout } from "@/components/layout/AuthLayout"; // Zod validation schema const loginSchema = z.object({ email: z .string() - .min(1, 'Email is required') - .email('Please enter a valid email address'), + .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'), + .min(1, "Password is required") + .min(6, "Password must be at least 6 characters"), }); type LoginFormData = z.infer; @@ -30,9 +30,11 @@ type LoginFormData = z.infer; const TenantLogin = (): ReactElement => { const navigate = useNavigate(); const dispatch = useAppDispatch(); - const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth); + const { isLoading, error, isAuthenticated, roles } = useAppSelector( + (state) => state.auth, + ); const { theme, logoUrl } = useAppSelector((state) => state.theme); - + // Fetch and apply tenant theme useTenantTheme(); @@ -44,10 +46,10 @@ const TenantLogin = (): ReactElement => { clearErrors, } = useForm({ resolver: zodResolver(loginSchema), - mode: 'onBlur', + mode: "onBlur", }); - const [generalError, setGeneralError] = useState(''); + const [generalError, setGeneralError] = useState(""); const [rememberMe, setRememberMe] = useState(false); // Redirect if already authenticated @@ -58,18 +60,18 @@ const TenantLogin = (): ReactElement => { let rolesArray: string[] = []; if (Array.isArray(roles)) { rolesArray = roles; - } else if (typeof roles === 'string') { + } else if (typeof roles === "string") { try { rolesArray = JSON.parse(roles); } catch { rolesArray = []; } } - if (rolesArray.includes('super_admin')) { - navigate('/dashboard'); + if (rolesArray.includes("super_admin")) { + navigate("/dashboard"); } else { // Tenant admin - redirect to tenant landing page (workspace selector) - navigate('/tenant/landing'); + navigate("/tenant/landing"); } } }, [isAuthenticated, roles, navigate]); @@ -77,13 +79,13 @@ const TenantLogin = (): ReactElement => { // Clear errors on component mount useEffect(() => { dispatch(clearError()); - setGeneralError(''); + setGeneralError(""); clearErrors(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const onSubmit = async (data: LoginFormData): Promise => { - setGeneralError(''); + setGeneralError(""); clearErrors(); dispatch(clearError()); @@ -93,315 +95,232 @@ const TenantLogin = (): ReactElement => { // Check if password reset is required if (result.data.requirePasswordReset) { const { tempToken, resetReason } = result.data; - showToast.info('Password reset required', 'For security reasons, please update your password.'); + showToast.info( + "Password reset required", + "For security reasons, please update your password.", + ); navigate(`/reset-password?token=${tempToken}&reason=${resetReason}`); return; } - const message = result.message || 'Login successful'; + 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') { + } else if (typeof userRoles === "string") { try { rolesArray = JSON.parse(userRoles); } catch { rolesArray = []; } } - if (rolesArray.includes('super_admin')) { - navigate('/dashboard'); + if (rolesArray.includes("super_admin")) { + navigate("/dashboard"); } else { - navigate('/tenant/landing'); + navigate("/tenant/landing"); } } } catch (error: any) { dispatch(clearError()); const loginError = error as LoginError; - if (loginError && typeof loginError === 'object') { - if ('details' in loginError && Array.isArray(loginError.details)) { + if (loginError && typeof loginError === "object") { + if ("details" in loginError && Array.isArray(loginError.details)) { loginError.details.forEach((detail) => { - if (detail.path === 'email' || detail.path === 'password') { + if (detail.path === "email" || detail.path === "password") { setError(detail.path as keyof LoginFormData, { - type: 'server', + 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') { + } 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.'); + setGeneralError("Login failed. Please check your credentials."); } } else { - setGeneralError('An unexpected error occurred. Please try again.'); + 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.'); + setGeneralError( + "Login failed. Please check your credentials and try again.", + ); } } }; return ( -
- {/* Left Side - Blue Background */} -
+ {/* Header */} +
+ {/* Heading */} +
+

+ Sign in to your tenant +

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

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

+
+
+ + {/* Login Card Form */} +
{ + e.preventDefault(); + e.stopPropagation(); + handleSubmit(onSubmit)(e); }} + className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white" + noValidate > -
- {/* Logo Section */} -
-
- {logoUrl ? ( - Logo { - // Fallback to icon if image fails - e.currentTarget.style.display = 'none'; - const fallback = e.currentTarget.nextElementSibling as HTMLElement; - if (fallback) fallback.style.display = 'flex'; - }} - /> - ) : null} -
- -
-
- {/* {logoUrl ? '' : 'QAssure'} */} - QAssure -
-
-
-
Tenant
-
- - {/* Heading */} -
-

- Secure access for
every tenant. -

-
- - {/* Description */} -
-

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

-
+ {/* General Error Message */} + {generalError && ( +
+

{generalError}

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

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

{error}

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

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

-

Need help? Contact your workspace administrator.

-
-
+ {/* Email Field */} + - {/* 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. -

-
+ {/* Password Field */} + - {/* 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 + {/* Remember Me & Forgot Password */} +
+
+ setRememberMe(e.target.checked)} + className="w-[18px] h-[18px] rounded border-[#d1d5db] focus:ring-2" + style={ + { + backgroundColor: theme?.primary_color || "#112868", + accentColor: theme?.primary_color || "#112868", + } as any + } + /> +
- - {/* Footer Links */} - + { + e.preventDefault(); + navigate("/tenant/forgot-password"); + }} + className="text-[13px] font-medium underline" + style={{ color: theme?.primary_color || "#112868" }} + > + Forgot Password? +
-
-
+ + {/* Submit Button */} +
+ + {isLoading ? "Signing in..." : "Sign In"} + +
+ + {/* New to tenant */} + {/* */} + + {/* SSO Section */} + {/*
+
+

+ Single sign-on +

+
+ {["Google", "Microsoft", "Okta"].map((provider) => ( + + ))} +
+
+
*/} + + ); };