feat: introduce AuthLayout and update password reset validation to require terms acceptance

This commit is contained in:
Yashwin 2026-06-10 11:08:59 +05:30
parent 72a3c99299
commit e6fb7687ba
5 changed files with 858 additions and 710 deletions

View File

@ -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 (
<div className="min-h-screen lg:h-screen lg:overflow-hidden flex flex-col lg:flex-row bg-[#F9F9F9]">
{/* Left Section */}
<div
className="hidden lg:flex lg:w-1/2 h-full px-8 pt-8 pb-10 flex-col justify-between items-start"
style={{ backgroundColor: primaryColor || "#112868" }}
>
<div className="w-full flex flex-col items-start gap-[10px] pt-8 pb-8">
<div className="flex flex-col items-start w-full gap-[10px]">
{/* Content Block */}
<div className="flex flex-col items-start gap-3 max-w-[280px]">
<div className="flex items-center gap-3">
{logoUrl ? (
<img
src={logoUrl}
alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain"
onError={(e) => {
e.currentTarget.style.display = "none";
const fallback = e.currentTarget
.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = "flex";
}}
/>
) : null}
<div
className="w-9 h-9 rounded-xl flex items-center justify-center shadow-[0_4px_12px_rgba(0,0,0,0.15)]"
style={{
display: logoUrl ? "none" : "flex",
backgroundColor: secondaryColor || "#00d2c4",
}}
>
<Shield className="w-5 h-5 text-white" strokeWidth={2} />
</div>
<div className="flex items-center gap-2">
<span
className="text-[18px] font-semibold tracking-tight"
style={{ color: secondaryColor || "#00d2c4" }}
>
QAssure
</span>
<span className="text-white text-[13px] font-medium tracking-wider uppercase">
- {portalType}
</span>
</div>
</div>
<h1 className="w-[373px] self-stretch text-white text-[24px] font-semibold leading-normal">
{isTenant
? "Secure access for every tenant."
: "Secure control across all products"}
</h1>
<p className="w-[373px] text-white text-[14px] font-medium leading-[21px]">
{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."}
</p>
</div>
{/* Stats Grid */}
<div className="inline-grid grid-cols-2 gap-x-[10px] gap-y-[10px]">
{stats.map((item) => (
<div
key={item.title}
className="flex flex-col items-start w-[183.59px] p-3 gap-1 rounded-[6px] border border-white backdrop-blur-[20px]"
style={{
background:
"linear-gradient(96deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.30) 100.2%)",
}}
>
<p className="text-white text-xs font-medium opacity-90">
{item.title}
</p>
<p className="text-white text-xl font-semibold">
{item.value}
</p>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="flex flex-col items-start self-stretch">
<p className="text-white text-sm leading-[21px]">
{isTenant
? "SSO, MFA, and audit-ready access for every tenant."
: "Centralized control with secure access for all admin operations."}
</p>
<p className="text-white text-sm leading-[21px]">
Need help? Contact your workspace administrator.
</p>
</div>
</div>
{/* Right Section */}
<div className="w-full lg:w-1/2 min-h-screen lg:h-full p-4 sm:p-8 lg:p-16 flex flex-col justify-center items-center">
<div className="w-full max-w-[507px] bg-white p-5 sm:p-7 flex flex-col items-start gap-5">
<div className="flex justify-between items-center w-full">
<span className="flex flex-col items-start rounded-full bg-[#EDF3FE] px-[10px] py-1 text-xs font-medium text-[#2563EB] text-[11px]">
{isTenant ? "Tenant Admin Portal" : "Admin Portal"}
</span>
</div>
{children}
{/* Footer Links */}
<div className="flex items-start gap-4 w-full mt-auto">
<button
type="button"
className="text-xs font-normal text-[#6B7280] hover:text-[#374151]"
>
Status page
</button>
<button
type="button"
className="text-xs font-normal text-[#6B7280] hover:text-[#374151]"
>
Security
</button>
<button
type="button"
className="text-xs font-normal text-[#6B7280] hover:text-[#374151]"
>
Help
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,13 +1,16 @@
import { useState } from "react"; 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 { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { Shield, ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { authService } from "@/services/auth-service"; import { authService } from "@/services/auth-service";
import { FormField } from "@/components/shared"; import { FormField } from "@/components/shared";
import { PrimaryButton } 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 // Zod validation schema
const forgotPasswordSchema = z.object({ const forgotPasswordSchema = z.object({
@ -21,10 +24,20 @@ type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
const ForgotPassword = (): ReactElement => { const ForgotPassword = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [success, setSuccess] = useState<string>(""); const [success, setSuccess] = useState<string>("");
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 { const {
register, register,
handleSubmit, handleSubmit,
@ -70,42 +83,34 @@ const ForgotPassword = (): ReactElement => {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#f6f9ff] px-4"> <AuthLayout
<div className="w-full max-w-md"> portalType={portalType}
{/* Forgot Password Card */} primaryColor={theme?.primary_color || undefined}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-6 md:p-8"> secondaryColor={theme?.secondary_color || undefined}
{/* Logo Section */} logoUrl={logoUrl || undefined}
<div className="flex justify-center mb-8"> >
<div className="flex items-center gap-3"> {/* Header */}
<div className="w-12 h-12 bg-[#112868] rounded-xl flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)]"> <div className="flex flex-col items-start gap-2 w-full">
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} /> {/* Heading */}
</div> <div className="flex flex-col items-start w-full">
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.4px]"> <h2 className="text-[20px] font-semibold text-[#0F1724] leading-none">
QAssure
</div>
</div>
</div>
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
Forgot Password Forgot Password
</h1> </h2>
<p className="text-sm md:text-base text-[#6b7280]"> </div>
{/* Description */}
<div className="flex flex-col items-start self-stretch">
<p className="w-full max-w-[503px] font-inter text-[12px] font-normal leading-normal text-[#6B7280]">
Enter your email address and we'll send you a link to reset your Enter your email address and we'll send you a link to reset your
password password
</p> </p>
</div> </div>
</div>
{/* Success Message */} {/* Success Message */}
{success && ( {success && (
<div className="mb-4 p-3 bg-[rgba(16,185,129,0.1)] border border-[#10b981] rounded-md"> <div className="w-full p-3 bg-[rgba(16,185,129,0.1)] border border-[#10b981] rounded-md">
<p className="text-sm text-[#10b981]">{success}</p> <p className="text-sm text-[#10b981] font-medium">{success}</p>
</div>
)}
{/* Error Message */}
{error && (
<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> </div>
)} )}
@ -116,9 +121,16 @@ const ForgotPassword = (): ReactElement => {
e.stopPropagation(); e.stopPropagation();
handleSubmit(onSubmit)(e); handleSubmit(onSubmit)(e);
}} }}
className="space-y-4" className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white space-y-4"
noValidate noValidate
> >
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600 font-medium">{error}</p>
</div>
)}
{/* Email Field */} {/* Email Field */}
<FormField <FormField
label="Email" label="Email"
@ -126,6 +138,7 @@ const ForgotPassword = (): ReactElement => {
placeholder="Enter your email" placeholder="Enter your email"
required required
error={errors.email?.message} error={errors.email?.message}
className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
{...register("email")} {...register("email")}
/> />
@ -133,22 +146,20 @@ const ForgotPassword = (): ReactElement => {
<div className="pt-2"> <div className="pt-2">
<PrimaryButton <PrimaryButton
type="submit" type="submit"
size="large"
disabled={isLoading} disabled={isLoading}
className="w-full h-12 text-sm font-medium" className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
> >
{isLoading ? "Sending..." : "Send Reset Link"} {isLoading ? "Sending..." : "Send Reset Link"}
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>
) : ( ) : (
<div className="space-y-4"> <div className="w-full space-y-4">
<div className="pt-2"> <div className="pt-2">
<PrimaryButton <PrimaryButton
type="button" type="button"
size="large" onClick={() => navigate(loginPath)}
onClick={() => navigate("/")} className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
className="w-full h-12 text-sm font-medium"
> >
Back to Login Back to Login
</PrimaryButton> </PrimaryButton>
@ -158,9 +169,9 @@ const ForgotPassword = (): ReactElement => {
{/* Back to Login Link */} {/* Back to Login Link */}
{!success && ( {!success && (
<div className="mt-6 text-center"> <div className="mt-6 text-center w-full flex justify-center">
<Link <Link
to="/" to={loginPath}
className="inline-flex items-center gap-2 text-sm text-[#112868] hover:text-[#0d1f4d] transition-colors" className="inline-flex items-center gap-2 text-sm text-[#112868] hover:text-[#0d1f4d] transition-colors"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
@ -168,16 +179,7 @@ const ForgotPassword = (): ReactElement => {
</Link> </Link>
</div> </div>
)} )}
</div> </AuthLayout>
{/* Footer */}
<div className="mt-6 text-center">
<p className="text-xs md:text-sm text-[#6b7280]">
© 2026 QAssure. All rights reserved.
</p>
</div>
</div>
</div>
); );
}; };

View File

@ -1,27 +1,27 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod'; import { z } from "zod";
import type { ReactElement } from 'react'; import type { ReactElement } from "react";
import { Shield } from 'lucide-react'; import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks";
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; import { loginAsync, clearError } from "@/store/authSlice";
import { loginAsync, clearError } from '@/store/authSlice'; import { FormField } from "@/components/shared";
import { FormField } from '@/components/shared'; import { PrimaryButton } from "@/components/shared";
import { PrimaryButton } from '@/components/shared'; import type { LoginError } from "@/services/auth-service";
import type { LoginError } from '@/services/auth-service'; import { showToast } from "@/utils/toast";
import { showToast } from '@/utils/toast'; import { AuthLayout } from "@/components/layout/AuthLayout";
// Zod validation schema // Zod validation schema
const loginSchema = z.object({ const loginSchema = z.object({
email: z email: z
.string() .string()
.min(1, 'Email is required') .min(1, "Email is required")
.email('Please enter a valid email address'), .email("Please enter a valid email address"),
password: z password: z
.string() .string()
.min(1, 'Password is required') .min(1, "Password is required")
.min(6, 'Password must be at least 6 characters'), .min(6, "Password must be at least 6 characters"),
}); });
type LoginFormData = z.infer<typeof loginSchema>; type LoginFormData = z.infer<typeof loginSchema>;
@ -29,7 +29,9 @@ 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, roles } = useAppSelector((state) => state.auth); const { isLoading, error, isAuthenticated, roles } = useAppSelector(
(state) => state.auth,
);
const { const {
register, register,
@ -39,10 +41,10 @@ const Login = (): ReactElement => {
clearErrors, clearErrors,
} = useForm<LoginFormData>({ } = useForm<LoginFormData>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
mode: 'onBlur', // Validate on blur for better UX mode: "onBlur", // Validate on blur for better UX
}); });
const [generalError, setGeneralError] = useState<string>(''); const [generalError, setGeneralError] = useState<string>("");
// Redirect if already authenticated // Redirect if already authenticated
useEffect(() => { useEffect(() => {
@ -52,34 +54,33 @@ const Login = (): ReactElement => {
let rolesArray: string[] = []; let rolesArray: string[] = [];
if (Array.isArray(roles)) { if (Array.isArray(roles)) {
rolesArray = roles; rolesArray = roles;
} else if (typeof roles === 'string') { } else if (typeof roles === "string") {
try { try {
rolesArray = JSON.parse(roles); rolesArray = JSON.parse(roles);
} catch { } catch {
rolesArray = []; rolesArray = [];
} }
} }
if (rolesArray.includes('super_admin')) { if (rolesArray.includes("super_admin")) {
navigate('/dashboard'); navigate("/dashboard");
} else { } else {
// Tenant admin - redirect to tenant landing page (workspace selector) // Tenant admin - redirect to tenant landing page (workspace selector)
navigate('/tenant/landing'); navigate("/tenant/landing");
} }
} }
}, [isAuthenticated, roles, 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(() => {
// Only clear errors on initial mount
dispatch(clearError()); dispatch(clearError());
setGeneralError(''); setGeneralError("");
clearErrors(); clearErrors();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array - only run on mount }, []); // Empty dependency array - only run on mount
const onSubmit = async (data: LoginFormData): Promise<void> => { const onSubmit = async (data: LoginFormData): Promise<void> => {
// Clear previous errors // Clear previous errors
setGeneralError(''); setGeneralError("");
clearErrors(); clearErrors();
dispatch(clearError()); dispatch(clearError());
@ -89,173 +90,172 @@ const Login = (): ReactElement => {
// Check if password reset is required // Check if password reset is required
if (result.data.requirePasswordReset) { if (result.data.requirePasswordReset) {
const { tempToken, resetReason } = result.data; 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}`); navigate(`/reset-password?token=${tempToken}&reason=${resetReason}`);
return; return;
} }
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);
// Check roles after login to redirect appropriately // Check roles after login to redirect appropriately
const userRoles = result.data.roles || []; const userRoles = result.data.roles || [];
if (userRoles.includes('super_admin')) { if (userRoles.includes("super_admin")) {
navigate('/dashboard'); navigate("/dashboard");
} else { } else {
navigate('/tenant/landing'); navigate("/tenant/landing");
} }
} }
} 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
dispatch(clearError()); dispatch(clearError());
// Handle error from unwrap() - error is the rejected value from rejectWithValue
const loginError = error as LoginError; const loginError = error as LoginError;
if (loginError && typeof loginError === 'object') { if (loginError && typeof loginError === "object") {
// Check for validation errors with details array // 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 // Validation errors from server - set field-specific errors
loginError.details.forEach((detail) => { loginError.details.forEach((detail) => {
if (detail.path === 'email' || detail.path === 'password') { if (detail.path === "email" || detail.path === "password") {
setError(detail.path as keyof LoginFormData, { setError(detail.path as keyof LoginFormData, {
type: 'server', type: "server",
message: detail.message, message: detail.message,
}); });
} else { } else {
// If error is for a field we don't handle, show as general error
setGeneralError(detail.message); setGeneralError(detail.message);
} }
}); });
} else if ('error' in loginError) { } else if ("error" in loginError) {
// Check if error is an object with message property if (
if (typeof loginError.error === 'object' && loginError.error !== null && 'message' in loginError.error) { typeof loginError.error === "object" &&
// General error from server with object structure loginError.error !== null &&
setGeneralError(loginError.error.message || 'Login failed'); "message" in loginError.error
} else if (typeof loginError.error === 'string') { ) {
// Error is a string setGeneralError(loginError.error.message || "Login failed");
} else if (typeof loginError.error === "string") {
setGeneralError(loginError.error); setGeneralError(loginError.error);
} else { } else {
setGeneralError('Login failed. Please check your credentials.'); setGeneralError("Login failed. Please check your credentials.");
} }
} else { } 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) { } else if (error?.message) {
// Network error or other error
setGeneralError(error.message); setGeneralError(error.message);
} else { } else {
// Complete fallback setGeneralError(
setGeneralError('Login failed. Please check your credentials and try again.'); "Login failed. Please check your credentials and try again.",
);
} }
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#f6f9ff] px-4"> <AuthLayout portalType="admin">
<div className="w-full max-w-md"> {/* Header */}
{/* Login Card */} <div className="flex flex-col items-start gap-2 w-full">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-6 md:p-8"> {/* Heading */}
{/* Logo Section */} <div className="flex flex-col items-start w-full">
<div className="flex justify-center mb-8"> <h2 className="text-[20px] font-semibold text-[#0F1724] leading-none">
<div className="flex items-center gap-3"> Sign in
<div className="w-12 h-12 bg-[#112868] rounded-xl flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)]"> </h2>
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div> </div>
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.4px]">
QAssure {/* Description */}
</div> <div className="flex flex-col items-start self-stretch">
</div> <p className="w-full max-w-[503px] font-inter text-[12px] font-normal leading-normal text-[#6B7280]">
</div> Use your work email or configured SSO provider to access the
<div className="mb-6"> admin portal.
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
Welcome Back
</h1>
<p className="text-sm md:text-base text-[#6b7280]">
Sign in to your account to continue
</p> </p>
</div> </div>
{/* General Error Message - Prioritize local error over Redux error */}
{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> </div>
)}
{/* Show Redux error only if no local error and no field errors */}
{!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 Card */}
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleSubmit(onSubmit)(e); handleSubmit(onSubmit)(e);
}} }}
className="space-y-4" className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white"
noValidate noValidate
> >
{/* Email Field */} {/* General Error Message - Prioritize local error over Redux error */}
{generalError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600 font-medium">
{generalError}
</p>
</div>
)}
{/* Show Redux error only if no local error and no field errors */}
{!generalError && error && !errors.email && !errors.password && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600 font-medium">{error}</p>
</div>
)}
{/* Email */}
<FormField <FormField
label="Email" label="Email"
type="email" type="email"
placeholder="Enter your email" placeholder="user@company.com"
required required
helperText="Enter your work email"
error={errors.email?.message} error={errors.email?.message}
{...register('email')} className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
{...register("email")}
/> />
{/* Password Field */} {/* Password */}
<FormField <FormField
label="Password" label="Password"
type="password" type="password"
placeholder="Enter your password" placeholder="••••••••"
required required
helperText="Enter your password (min 8 characters)"
error={errors.password?.message} error={errors.password?.message}
{...register('password')} className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
{...register("password")}
/> />
{/* Forgot Password Link */} {/* Remember Me */}
<div className="flex justify-end -mt-2"> <div className="flex items-center justify-between mt-1">
<a <label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
href="/forgot-password" <input
onClick={(e) => { type="checkbox"
e.preventDefault(); className="rounded border-gray-300 text-[#112868] focus:ring-[#112868]/20"
navigate('/forgot-password'); />
}} Remember Me
className="text-sm text-[#112868] hover:text-[#0d1f4d] transition-colors" </label>
<button
type="button"
onClick={() => navigate("/forgot-password")}
className="text-sm font-medium text-[#112868] hover:underline"
> >
Forgot Password? Forgot Password?
</a> </button>
</div> </div>
{/* Submit Button */} {/* Login Button */}
<div className="pt-2"> <div className="pt-1">
<PrimaryButton <PrimaryButton
type="submit" type="submit"
size="large"
disabled={isLoading} disabled={isLoading}
className="w-full h-12 text-sm font-medium" className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
> >
{isLoading ? 'Signing in...' : 'Sign In'} {isLoading ? "Signing in..." : "Login"}
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>
</div> </AuthLayout>
{/* Footer */}
<div className="mt-6 text-center">
<p className="text-xs md:text-sm text-[#6b7280]">
© 2026 QAssure. All rights reserved.
</p>
</div>
</div>
</div>
); );
}; };

View File

@ -1,14 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod'; import { z } from "zod";
import type { ReactElement } from 'react'; import type { ReactElement } from "react";
import { Shield, ArrowLeft } from 'lucide-react'; import { authService } from "@/services/auth-service";
import { authService } from '@/services/auth-service'; import { FormField } from "@/components/shared";
import { FormField } from '@/components/shared'; import { PrimaryButton } from "@/components/shared";
import { PrimaryButton } from '@/components/shared'; import { showToast } from "@/utils/toast";
import { showToast } from '@/utils/toast'; import { AuthLayout } from "@/components/layout/AuthLayout";
// Zod validation schema - token is optional if provided in URL // Zod validation schema - token is optional if provided in URL
const createResetPasswordSchema = (hasTokenFromUrl: boolean) => const createResetPasswordSchema = (hasTokenFromUrl: boolean) =>
@ -16,26 +16,33 @@ const createResetPasswordSchema = (hasTokenFromUrl: boolean) =>
.object({ .object({
token: hasTokenFromUrl token: hasTokenFromUrl
? z.string().optional() ? z.string().optional()
: z.string().min(1, 'Reset token is required'), : z.string().min(1, "Reset token is required"),
password: z password: z
.string() .string()
.min(1, 'Password is required') .min(1, "Password is required")
.min(6, 'Password must be at least 6 characters'), .min(8, "Password must be at least 8 characters"),
confirmPassword: z.string().min(1, 'Please confirm your password'), 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, { .refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match', message: "Passwords do not match",
path: ['confirmPassword'], path: ["confirmPassword"],
}); });
const ResetPassword = (): ReactElement => { const ResetPassword = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const location = useLocation();
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [success, setSuccess] = useState<string>(''); const [success, setSuccess] = useState<string>("");
const tokenFromUrl = searchParams.get('token') || ''; const isTenantRoute = location.pathname.startsWith("/tenant");
const reason = searchParams.get('reason') || ''; const portalType = isTenantRoute ? "tenant" : "admin";
const tokenFromUrl = searchParams.get("token") || "";
const reason = searchParams.get("reason") || "";
const hasTokenFromUrl = Boolean(tokenFromUrl); const hasTokenFromUrl = Boolean(tokenFromUrl);
const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl); const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl);
@ -50,57 +57,72 @@ const ResetPassword = (): ReactElement => {
clearErrors, clearErrors,
} = useForm<ResetPasswordFormData>({ } = useForm<ResetPasswordFormData>({
resolver: zodResolver(resetPasswordSchema), resolver: zodResolver(resetPasswordSchema),
mode: 'onBlur', mode: "onBlur",
defaultValues: { defaultValues: {
token: tokenFromUrl || undefined, token: tokenFromUrl || undefined,
password: '', password: "",
confirmPassword: '', confirmPassword: "",
acceptTerms: false as any,
}, },
}); });
// Set token from URL if available // Set token from URL if available
useEffect(() => { useEffect(() => {
if (tokenFromUrl) { if (tokenFromUrl) {
setValue('token', tokenFromUrl); setValue("token", tokenFromUrl);
} }
}, [tokenFromUrl, setValue]); }, [tokenFromUrl, setValue]);
const onSubmit = async (data: ResetPasswordFormData): Promise<void> => { const onSubmit = async (data: ResetPasswordFormData): Promise<void> => {
setSuccess(''); setSuccess("");
clearErrors(); clearErrors();
setIsLoading(true); setIsLoading(true);
try { try {
// Always use token from URL if available, otherwise use form data // 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({ const response = await authService.resetPassword({
token: tokenToUse, token: tokenToUse,
password: data.password, password: data.password,
}); });
if (response.success) { if (response.success) {
const message = response.message || response.data?.message || 'Password reset successfully!'; const message =
const description = (response.message || response.data?.message) ? undefined : 'You can now login with your new password'; response.message ||
const successMessage = response.message || response.data?.message || 'Password reset successfully! You can now login with your new password.'; 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); setSuccess(successMessage);
showToast.success(message, description); showToast.success(message, description);
// Redirect to login after 2 seconds // Redirect to login after 2 seconds
setTimeout(() => { setTimeout(() => {
navigate('/'); navigate("/");
}, 2000); }, 2000);
} }
} catch (err: any) { } catch (err: any) {
console.error('Reset password error:', err); console.error("Reset password error:", err);
// Handle validation errors from API // 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; const validationErrors = err.response.data.details;
validationErrors.forEach((detail: { path: string; message: string }) => { validationErrors.forEach(
if (detail.path === 'token' || detail.path === 'password') { (detail: { path: string; message: string }) => {
if (detail.path === "token" || detail.path === "password") {
setError(detail.path as keyof ResetPasswordFormData, { setError(detail.path as keyof ResetPasswordFormData, {
type: 'server', type: "server",
message: detail.message, message: detail.message,
}); });
} }
}); },
);
} else { } else {
// Handle general errors // Handle general errors
const errorMessage = const errorMessage =
@ -108,10 +130,13 @@ const ResetPassword = (): ReactElement => {
err?.response?.data?.error || err?.response?.data?.error ||
err?.response?.data?.message || err?.response?.data?.message ||
err?.message || err?.message ||
'Failed to reset password. Please try again.'; "Failed to reset password. Please try again.";
setError('root', { setError("root", {
type: 'server', type: "server",
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to reset password. Please try again.', message:
typeof errorMessage === "string"
? errorMessage
: "Failed to reset password. Please try again.",
}); });
} }
} finally { } finally {
@ -120,39 +145,36 @@ const ResetPassword = (): ReactElement => {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#f6f9ff] px-4"> <AuthLayout portalType={portalType}>
<div className="w-full max-w-md"> {/* Header */}
{/* Reset Password Card */} <div className="flex flex-col items-start gap-2 w-full">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-6 md:p-8"> {/* Heading */}
{/* Logo Section */} <div className="flex flex-col items-start w-full">
<div className="flex justify-center mb-8"> <h2 className="text-[20px] font-semibold text-[#0F1724] leading-none">
<div className="flex items-center gap-3"> {reason === "FIRST_LOGIN"
<div className="w-12 h-12 bg-[#112868] rounded-xl flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)]"> ? "Set Initial Password"
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} /> : reason === "EXPIRED"
? "Update Expired Password"
: "Set New Password"}
</h2>
</div> </div>
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.4px]">
QAssure {/* Description */}
</div> <div className="flex flex-col items-start self-stretch">
</div> <p className="w-full max-w-[503px] font-inter text-[12px] font-normal leading-normal text-[#6B7280]">
</div> {reason === "FIRST_LOGIN"
<div className="mb-6"> ? "Welcome to QAssure! Please set a new password for your account to continue."
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2"> : reason === "EXPIRED"
{reason === 'FIRST_LOGIN' ? 'Set Initial Password' : ? "Your password has expired. For security, please choose a new password."
reason === 'EXPIRED' ? 'Update Expired Password' : : "Create a strong password to activate your account and access the portal"}
'Reset Password'}
</h1>
<p className="text-sm md:text-base text-[#6b7280]">
{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'}
</p> </p>
</div> </div>
</div>
{/* Success Message */} {/* Success Message */}
{success && ( {success && (
<div className="mb-4 p-3 bg-[rgba(16,185,129,0.1)] border border-[#10b981] rounded-md"> <div className="w-full p-3 bg-[rgba(16,185,129,0.1)] border border-[#10b981] rounded-md">
<p className="text-sm text-[#10b981]">{success}</p> <p className="text-sm text-[#10b981] font-medium">{success}</p>
</div> </div>
)} )}
@ -163,13 +185,15 @@ const ResetPassword = (): ReactElement => {
e.stopPropagation(); e.stopPropagation();
handleSubmit(onSubmit)(e); handleSubmit(onSubmit)(e);
}} }}
className="space-y-4" className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white space-y-4"
noValidate noValidate
> >
{/* General Error Display */} {/* General Error Display */}
{errors.root && ( {errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4"> <div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p> <p className="text-sm text-red-600 font-medium">
{errors.root.message}
</p>
</div> </div>
)} )}
@ -181,7 +205,8 @@ const ResetPassword = (): ReactElement => {
placeholder="Enter reset token from email" placeholder="Enter reset token from email"
required required
error={errors.token?.message} error={errors.token?.message}
{...register('token')} className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
{...register("token")}
/> />
)} )}
@ -189,71 +214,75 @@ const ResetPassword = (): ReactElement => {
<FormField <FormField
label="New Password" label="New Password"
type="password" type="password"
placeholder="Enter your new password" placeholder="Create a strong password"
required required
helperText="Min 8 characters with letters, numbers, and a symbol."
error={errors.password?.message} error={errors.password?.message}
{...register('password')} className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
{...register("password")}
/> />
{/* Confirm Password Field */} {/* Confirm Password Field */}
<FormField <FormField
label="Confirm Password" label="Confirm Password"
type="password" type="password"
placeholder="Confirm your new password" placeholder="Re-enter your password"
required required
helperText="Must match the new password exactly."
error={errors.confirmPassword?.message} error={errors.confirmPassword?.message}
{...register('confirmPassword')} className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
{...register("confirmPassword")}
/> />
{/* Accept Terms Checkbox */}
<div className="flex flex-col gap-1">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-[#112868] focus:ring-[#112868]/20"
{...register("acceptTerms")}
/>
I accept the Terms of Service and Privacy Policy
</label>
{errors.acceptTerms ? (
<span className="text-[11px] text-red-600">
{errors.acceptTerms.message}
</span>
) : (
<span className="text-[11px] text-[#6B7280]">
You must accept to continue.
</span>
)}
</div>
{/* Submit Button */} {/* Submit Button */}
<div className="pt-2"> <div className="pt-2">
<PrimaryButton <PrimaryButton
type="submit" type="submit"
size="large"
disabled={isLoading} disabled={isLoading}
className="w-full h-12 text-sm font-medium" className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
> >
{isLoading ? 'Resetting...' : 'Reset Password'} {isLoading ? "Resetting..." : "Login"}
</PrimaryButton> </PrimaryButton>
<p className="text-xs text-[#6B7280] text-center mt-2">
You will be redirected to the Tenant Admin Portal after saving.
</p>
</div> </div>
</form> </form>
) : ( ) : (
<div className="space-y-4"> <div className="w-full space-y-4">
<div className="pt-2"> <div className="pt-2">
<PrimaryButton <PrimaryButton
type="button" type="button"
size="large" onClick={() => navigate("/")}
onClick={() => navigate('/')} className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
className="w-full h-12 text-sm font-medium"
> >
Go to Login Go to Login
</PrimaryButton> </PrimaryButton>
</div> </div>
</div> </div>
)} )}
</AuthLayout>
{/* Back to Login Link */}
{!success && !reason && (
<div className="mt-6 text-center">
<Link
to="/"
className="inline-flex items-center gap-2 text-sm text-[#112868] hover:text-[#0d1f4d] transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Login
</Link>
</div>
)}
</div>
{/* Footer */}
<div className="mt-6 text-center">
<p className="text-xs md:text-sm text-[#6b7280]">
© 2026 QAssure. All rights reserved.
</p>
</div>
</div>
</div>
); );
}; };

View File

@ -1,28 +1,28 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod'; import { z } from "zod";
import type { ReactElement } from 'react'; import type { ReactElement } from "react";
import { Shield } from 'lucide-react'; import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks";
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; import { loginAsync, clearError } from "@/store/authSlice";
import { loginAsync, clearError } from '@/store/authSlice'; import { FormField } from "@/components/shared";
import { FormField } from '@/components/shared'; import { PrimaryButton } from "@/components/shared";
import { PrimaryButton } from '@/components/shared'; import type { LoginError } from "@/services/auth-service";
import type { LoginError } from '@/services/auth-service'; import { showToast } from "@/utils/toast";
import { showToast } from '@/utils/toast'; import { useTenantTheme } from "@/hooks/useTenantTheme";
import { useTenantTheme } from '@/hooks/useTenantTheme'; import { AuthLayout } from "@/components/layout/AuthLayout";
// Zod validation schema // Zod validation schema
const loginSchema = z.object({ const loginSchema = z.object({
email: z email: z
.string() .string()
.min(1, 'Email is required') .min(1, "Email is required")
.email('Please enter a valid email address'), .email("Please enter a valid email address"),
password: z password: z
.string() .string()
.min(1, 'Password is required') .min(1, "Password is required")
.min(6, 'Password must be at least 6 characters'), .min(6, "Password must be at least 6 characters"),
}); });
type LoginFormData = z.infer<typeof loginSchema>; type LoginFormData = z.infer<typeof loginSchema>;
@ -30,7 +30,9 @@ type LoginFormData = z.infer<typeof loginSchema>;
const TenantLogin = (): ReactElement => { const TenantLogin = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); 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); const { theme, logoUrl } = useAppSelector((state) => state.theme);
// Fetch and apply tenant theme // Fetch and apply tenant theme
@ -44,10 +46,10 @@ const TenantLogin = (): ReactElement => {
clearErrors, clearErrors,
} = useForm<LoginFormData>({ } = useForm<LoginFormData>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
mode: 'onBlur', mode: "onBlur",
}); });
const [generalError, setGeneralError] = useState<string>(''); const [generalError, setGeneralError] = useState<string>("");
const [rememberMe, setRememberMe] = useState<boolean>(false); const [rememberMe, setRememberMe] = useState<boolean>(false);
// Redirect if already authenticated // Redirect if already authenticated
@ -58,18 +60,18 @@ const TenantLogin = (): ReactElement => {
let rolesArray: string[] = []; let rolesArray: string[] = [];
if (Array.isArray(roles)) { if (Array.isArray(roles)) {
rolesArray = roles; rolesArray = roles;
} else if (typeof roles === 'string') { } else if (typeof roles === "string") {
try { try {
rolesArray = JSON.parse(roles); rolesArray = JSON.parse(roles);
} catch { } catch {
rolesArray = []; rolesArray = [];
} }
} }
if (rolesArray.includes('super_admin')) { if (rolesArray.includes("super_admin")) {
navigate('/dashboard'); navigate("/dashboard");
} else { } else {
// Tenant admin - redirect to tenant landing page (workspace selector) // Tenant admin - redirect to tenant landing page (workspace selector)
navigate('/tenant/landing'); navigate("/tenant/landing");
} }
} }
}, [isAuthenticated, roles, navigate]); }, [isAuthenticated, roles, navigate]);
@ -77,13 +79,13 @@ const TenantLogin = (): ReactElement => {
// Clear errors on component mount // Clear errors on component mount
useEffect(() => { useEffect(() => {
dispatch(clearError()); dispatch(clearError());
setGeneralError(''); setGeneralError("");
clearErrors(); clearErrors();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const onSubmit = async (data: LoginFormData): Promise<void> => { const onSubmit = async (data: LoginFormData): Promise<void> => {
setGeneralError(''); setGeneralError("");
clearErrors(); clearErrors();
dispatch(clearError()); dispatch(clearError());
@ -93,12 +95,15 @@ const TenantLogin = (): ReactElement => {
// Check if password reset is required // Check if password reset is required
if (result.data.requirePasswordReset) { if (result.data.requirePasswordReset) {
const { tempToken, resetReason } = result.data; 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}`); navigate(`/reset-password?token=${tempToken}&reason=${resetReason}`);
return; return;
} }
const message = result.message || 'Login successful'; const message = result.message || "Login successful";
showToast.success(message); showToast.success(message);
// Check roles after login to redirect appropriately // Check roles after login to redirect appropriately
@ -107,187 +112,107 @@ const TenantLogin = (): ReactElement => {
let rolesArray: string[] = []; let rolesArray: string[] = [];
if (Array.isArray(userRoles)) { if (Array.isArray(userRoles)) {
rolesArray = userRoles; rolesArray = userRoles;
} else if (typeof userRoles === 'string') { } else if (typeof userRoles === "string") {
try { try {
rolesArray = JSON.parse(userRoles); rolesArray = JSON.parse(userRoles);
} catch { } catch {
rolesArray = []; rolesArray = [];
} }
} }
if (rolesArray.includes('super_admin')) { if (rolesArray.includes("super_admin")) {
navigate('/dashboard'); navigate("/dashboard");
} else { } else {
navigate('/tenant/landing'); navigate("/tenant/landing");
} }
} }
} catch (error: any) { } catch (error: any) {
dispatch(clearError()); dispatch(clearError());
const loginError = error as LoginError; const loginError = error as LoginError;
if (loginError && typeof loginError === 'object') { if (loginError && typeof loginError === "object") {
if ('details' in loginError && Array.isArray(loginError.details)) { if ("details" in loginError && Array.isArray(loginError.details)) {
loginError.details.forEach((detail) => { loginError.details.forEach((detail) => {
if (detail.path === 'email' || detail.path === 'password') { if (detail.path === "email" || detail.path === "password") {
setError(detail.path as keyof LoginFormData, { setError(detail.path as keyof LoginFormData, {
type: 'server', type: "server",
message: detail.message, message: detail.message,
}); });
} else { } else {
setGeneralError(detail.message); setGeneralError(detail.message);
} }
}); });
} else if ('error' in loginError) { } else if ("error" in loginError) {
if (typeof loginError.error === 'object' && loginError.error !== null && 'message' in loginError.error) { if (
setGeneralError(loginError.error.message || 'Login failed'); typeof loginError.error === "object" &&
} else if (typeof loginError.error === 'string') { loginError.error !== null &&
"message" in loginError.error
) {
setGeneralError(loginError.error.message || "Login failed");
} else if (typeof loginError.error === "string") {
setGeneralError(loginError.error); setGeneralError(loginError.error);
} else { } else {
setGeneralError('Login failed. Please check your credentials.'); setGeneralError("Login failed. Please check your credentials.");
} }
} else { } else {
setGeneralError('An unexpected error occurred. Please try again.'); setGeneralError("An unexpected error occurred. Please try again.");
} }
} else if (error?.message) { } else if (error?.message) {
setGeneralError(error.message); setGeneralError(error.message);
} else { } else {
setGeneralError('Login failed. Please check your credentials and try again.'); setGeneralError(
"Login failed. Please check your credentials and try again.",
);
} }
} }
}; };
return ( return (
<div className="min-h-screen bg-[#f6f9ff] relative flex"> <AuthLayout
{/* Left Side - Blue Background */} portalType="tenant"
<div primaryColor={theme?.primary_color || undefined}
className="hidden lg:flex lg:w-[48%] flex-col justify-between px-8 py-8 min-w-[320px]" secondaryColor={theme?.secondary_color || undefined}
style={{ logoUrl={logoUrl || undefined}
backgroundColor: theme?.primary_color || '#112868',
}}
> >
<div className="flex flex-col gap-2.5 py-22"> {/* Header */}
{/* Logo Section */} <div className="flex flex-col items-start gap-2 w-full">
<div className="flex flex-col gap-3 max-w-[280px]">
<div className="flex items-center justify-between px-2 w-[206px]">
{logoUrl ? (
<img
src={logoUrl}
alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain"
onError={(e) => {
// 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}
<div
className="rounded-[10px] shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] w-9 h-9 flex items-center justify-center shrink-0"
style={{
display: logoUrl ? 'none' : 'flex',
backgroundColor: theme?.secondary_color || '#23dce1',
}}
>
<Shield className="w-5 h-5 text-white" strokeWidth={1.67} />
</div>
<div
className="text-[18px] font-bold tracking-[-0.36px]"
style={{ color: theme?.secondary_color || '#23dce1' }}
>
{/* {logoUrl ? '' : 'QAssure'} */}
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 */} {/* Heading */}
<div className="flex flex-col"> <div className="flex flex-col items-start w-full">
<h2 className="text-2xl font-semibold text-white leading-normal"> <h2 className="text-[20px] font-semibold text-[#0F1724] leading-none">
Secure access for<br />every tenant. Sign in to your tenant
</h2> </h2>
</div> </div>
{/* Description */} {/* Description */}
<div className="opacity-90"> <div className="flex flex-col items-start self-stretch">
<p className="text-sm font-medium text-white leading-[21px]"> <p className="w-full max-w-[503px] font-inter text-[12px] font-normal leading-normal text-[#6B7280]">
Log in to manage projects, approvals, documents, and more from a single, compliant control center. Use your work email or configured SSO provider to access the admin
portal.
</p> </p>
</div> </div>
</div> </div>
{/* Stats Grid */} {/* Login Card Form */}
<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 <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleSubmit(onSubmit)(e); handleSubmit(onSubmit)(e);
}} }}
className="space-y-4" className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white"
noValidate noValidate
> >
{/* General Error Message */}
{generalError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600 font-medium">{generalError}</p>
</div>
)}
{!generalError && error && !errors.email && !errors.password && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600 font-medium">{error}</p>
</div>
)}
{/* Email Field */} {/* Email Field */}
<FormField <FormField
label="Email" label="Email"
@ -295,7 +220,8 @@ const TenantLogin = (): ReactElement => {
placeholder="Enter your email" placeholder="Enter your email"
required required
error={errors.email?.message} error={errors.email?.message}
{...register('email')} className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
{...register("email")}
/> />
{/* Password Field */} {/* Password Field */}
@ -305,7 +231,8 @@ const TenantLogin = (): ReactElement => {
placeholder="Enter your password" placeholder="Enter your password"
required required
error={errors.password?.message} error={errors.password?.message}
{...register('password')} className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
{...register("password")}
/> />
{/* Remember Me & Forgot Password */} {/* Remember Me & Forgot Password */}
@ -317,12 +244,17 @@ const TenantLogin = (): ReactElement => {
checked={rememberMe} checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)} onChange={(e) => setRememberMe(e.target.checked)}
className="w-[18px] h-[18px] rounded border-[#d1d5db] focus:ring-2" className="w-[18px] h-[18px] rounded border-[#d1d5db] focus:ring-2"
style={{ style={
backgroundColor: theme?.primary_color || '#112868', {
accentColor: theme?.primary_color || '#112868', backgroundColor: theme?.primary_color || "#112868",
} as React.CSSProperties & { accentColor?: string }} accentColor: theme?.primary_color || "#112868",
} as any
}
/> />
<label htmlFor="remember-me" className="text-sm font-normal text-[#0f1724] cursor-pointer"> <label
htmlFor="remember-me"
className="text-sm font-normal text-[#0f1724] cursor-pointer"
>
Remember Me Remember Me
</label> </label>
</div> </div>
@ -330,49 +262,52 @@ const TenantLogin = (): ReactElement => {
href="/tenant/forgot-password" href="/tenant/forgot-password"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
navigate('/tenant/forgot-password'); navigate("/tenant/forgot-password");
}} }}
className="text-[13px] font-medium underline" className="text-[13px] font-medium underline"
style={{ color: theme?.primary_color || '#112868' }} style={{ color: theme?.primary_color || "#112868" }}
> >
Forgot Password? Forgot Password?
</a> </a>
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<div className="pt-1"> <div className="pt-3">
<PrimaryButton <PrimaryButton
type="submit" type="submit"
size="large"
disabled={isLoading} disabled={isLoading}
className="w-full h-12 text-base font-medium" className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
> >
{isLoading ? 'Signing in...' : 'Sign In'} {isLoading ? "Signing in..." : "Sign In"}
</PrimaryButton> </PrimaryButton>
</div> </div>
{/* New to tenant */} {/* New to tenant */}
<div className="flex items-center justify-between h-8"> {/* <div className="flex items-center justify-between h-8 pt-1">
<p className="text-xs font-normal text-[#6b7280]">New to this tenant?</p> <p className="text-xs font-normal text-[#6b7280]">
New to this tenant?
</p>
<a <a
href="/tenant/request-access" href="/tenant/request-access"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
navigate('/tenant/request-access'); navigate("/tenant/request-access");
}} }}
className="text-[13px] font-medium underline" className="text-[13px] font-medium underline"
style={{ color: theme?.secondary_color || '#23dce1' }} style={{ color: theme?.secondary_color || "#23dce1" }}
> >
Request Access Request Access
</a> </a>
</div> </div> */}
{/* SSO Section */} {/* SSO Section */}
<div className="pt-1"> {/* <div className="pt-1">
<div className="flex flex-col gap-2.5 h-[60px]"> <div className="flex flex-col gap-2.5">
<p className="text-xs font-normal text-[#6b7280] uppercase tracking-[0.48px]">Single sign-on</p> <p className="text-xs font-normal text-[#6b7280] uppercase tracking-[0.48px]">
Single sign-on
</p>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{['Google', 'Microsoft', 'Okta'].map((provider) => ( {["Google", "Microsoft", "Okta"].map((provider) => (
<button <button
key={provider} key={provider}
type="button" type="button"
@ -383,25 +318,9 @@ const TenantLogin = (): ReactElement => {
))} ))}
</div> </div>
</div> </div>
</div> </div> */}
</form> </form>
</div> </AuthLayout>
{/* 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>
); );
}; };