feat: introduce AuthLayout and update password reset validation to require terms acceptance
This commit is contained in:
parent
72a3c99299
commit
e6fb7687ba
198
src/components/layout/AuthLayout.tsx
Normal file
198
src/components/layout/AuthLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user