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 { useNavigate, Link } from "react-router-dom";
|
||||
import { useNavigate, Link, useLocation } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import type { ReactElement } from "react";
|
||||
import { Shield, ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { authService } from "@/services/auth-service";
|
||||
import { FormField } from "@/components/shared";
|
||||
import { PrimaryButton } from "@/components/shared";
|
||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||
import { useTenantTheme } from "@/hooks/useTenantTheme";
|
||||
import { AuthLayout } from "@/components/layout/AuthLayout";
|
||||
|
||||
// Zod validation schema
|
||||
const forgotPasswordSchema = z.object({
|
||||
@ -21,10 +24,20 @@ type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||
|
||||
const ForgotPassword = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = 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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -70,114 +83,103 @@ const ForgotPassword = (): ReactElement => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f6f9ff] px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Forgot Password Card */}
|
||||
<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">
|
||||
{/* Logo Section */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<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)]">
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.4px]">
|
||||
QAssure
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
|
||||
Forgot Password
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-[#6b7280]">
|
||||
Enter your email address and we'll send you a link to reset your
|
||||
password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-[rgba(16,185,129,0.1)] border border-[#10b981] rounded-md">
|
||||
<p className="text-sm text-[#10b981]">{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>
|
||||
)}
|
||||
|
||||
{!success ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Email Field */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-sm font-medium"
|
||||
>
|
||||
{isLoading ? "Sending..." : "Send Reset Link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
size="large"
|
||||
onClick={() => navigate("/")}
|
||||
className="w-full h-12 text-sm font-medium"
|
||||
>
|
||||
Back to Login
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back to Login Link */}
|
||||
{!success && (
|
||||
<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>
|
||||
)}
|
||||
<AuthLayout
|
||||
portalType={portalType}
|
||||
primaryColor={theme?.primary_color || undefined}
|
||||
secondaryColor={theme?.secondary_color || undefined}
|
||||
logoUrl={logoUrl || undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-start gap-2 w-full">
|
||||
{/* Heading */}
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<h2 className="text-[20px] font-semibold text-[#0F1724] leading-none">
|
||||
Forgot Password
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs md:text-sm text-[#6b7280]">
|
||||
© 2026 QAssure. All rights reserved.
|
||||
{/* 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
|
||||
password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="w-full p-3 bg-[rgba(16,185,129,0.1)] border border-[#10b981] rounded-md">
|
||||
<p className="text-sm text-[#10b981] font-medium">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!success ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<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 */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
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")}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
{isLoading ? "Sending..." : "Send Reset Link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => navigate(loginPath)}
|
||||
className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
Back to Login
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back to Login Link */}
|
||||
{!success && (
|
||||
<div className="mt-6 text-center w-full flex justify-center">
|
||||
<Link
|
||||
to={loginPath}
|
||||
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>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { loginAsync, clearError } from '@/store/authSlice';
|
||||
import { FormField } from '@/components/shared';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import type { LoginError } from '@/services/auth-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import type { ReactElement } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks";
|
||||
import { loginAsync, clearError } from "@/store/authSlice";
|
||||
import { FormField } from "@/components/shared";
|
||||
import { PrimaryButton } from "@/components/shared";
|
||||
import type { LoginError } from "@/services/auth-service";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { AuthLayout } from "@/components/layout/AuthLayout";
|
||||
|
||||
// Zod validation schema
|
||||
const loginSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
.min(1, "Email is required")
|
||||
.email("Please enter a valid email address"),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
.min(1, "Password is required")
|
||||
.min(6, "Password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
@ -29,7 +29,9 @@ type LoginFormData = z.infer<typeof loginSchema>;
|
||||
const Login = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth);
|
||||
const { isLoading, error, isAuthenticated, roles } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
@ -39,10 +41,10 @@ const Login = (): ReactElement => {
|
||||
clearErrors,
|
||||
} = useForm<LoginFormData>({
|
||||
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
|
||||
useEffect(() => {
|
||||
@ -52,34 +54,33 @@ const Login = (): ReactElement => {
|
||||
let rolesArray: string[] = [];
|
||||
if (Array.isArray(roles)) {
|
||||
rolesArray = roles;
|
||||
} else if (typeof roles === 'string') {
|
||||
} else if (typeof roles === "string") {
|
||||
try {
|
||||
rolesArray = JSON.parse(roles);
|
||||
} catch {
|
||||
rolesArray = [];
|
||||
}
|
||||
}
|
||||
if (rolesArray.includes('super_admin')) {
|
||||
navigate('/dashboard');
|
||||
if (rolesArray.includes("super_admin")) {
|
||||
navigate("/dashboard");
|
||||
} else {
|
||||
// Tenant admin - redirect to tenant landing page (workspace selector)
|
||||
navigate('/tenant/landing');
|
||||
navigate("/tenant/landing");
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, roles, navigate]);
|
||||
|
||||
// Clear errors only on component mount, not on every auth state change
|
||||
useEffect(() => {
|
||||
// Only clear errors on initial mount
|
||||
dispatch(clearError());
|
||||
setGeneralError('');
|
||||
setGeneralError("");
|
||||
clearErrors();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty dependency array - only run on mount
|
||||
|
||||
const onSubmit = async (data: LoginFormData): Promise<void> => {
|
||||
// Clear previous errors
|
||||
setGeneralError('');
|
||||
setGeneralError("");
|
||||
clearErrors();
|
||||
dispatch(clearError());
|
||||
|
||||
@ -89,173 +90,172 @@ const Login = (): ReactElement => {
|
||||
// Check if password reset is required
|
||||
if (result.data.requirePasswordReset) {
|
||||
const { tempToken, resetReason } = result.data;
|
||||
showToast.info('Password reset required', 'For security reasons, please update your password.');
|
||||
showToast.info(
|
||||
"Password reset required",
|
||||
"For security reasons, please update your password.",
|
||||
);
|
||||
navigate(`/reset-password?token=${tempToken}&reason=${resetReason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = result.message || 'Login successful';
|
||||
const description = result.message ? undefined : 'Welcome back!';
|
||||
const message = result.message || "Login successful";
|
||||
const description = result.message ? undefined : "Welcome back!";
|
||||
showToast.success(message, description);
|
||||
|
||||
|
||||
// Check roles after login to redirect appropriately
|
||||
const userRoles = result.data.roles || [];
|
||||
if (userRoles.includes('super_admin')) {
|
||||
navigate('/dashboard');
|
||||
if (userRoles.includes("super_admin")) {
|
||||
navigate("/dashboard");
|
||||
} else {
|
||||
navigate('/tenant/landing');
|
||||
navigate("/tenant/landing");
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Clear Redux error state since we're handling errors locally
|
||||
dispatch(clearError());
|
||||
|
||||
// Handle error from unwrap() - error is the rejected value from rejectWithValue
|
||||
const loginError = error as LoginError;
|
||||
|
||||
if (loginError && typeof loginError === 'object') {
|
||||
if (loginError && typeof loginError === "object") {
|
||||
// Check for validation errors with details array
|
||||
if ('details' in loginError && Array.isArray(loginError.details)) {
|
||||
if ("details" in loginError && Array.isArray(loginError.details)) {
|
||||
// Validation errors from server - set field-specific errors
|
||||
loginError.details.forEach((detail) => {
|
||||
if (detail.path === 'email' || detail.path === 'password') {
|
||||
if (detail.path === "email" || detail.path === "password") {
|
||||
setError(detail.path as keyof LoginFormData, {
|
||||
type: 'server',
|
||||
type: "server",
|
||||
message: detail.message,
|
||||
});
|
||||
} else {
|
||||
// If error is for a field we don't handle, show as general error
|
||||
setGeneralError(detail.message);
|
||||
}
|
||||
});
|
||||
} else if ('error' in loginError) {
|
||||
// Check if error is an object with message property
|
||||
if (typeof loginError.error === 'object' && loginError.error !== null && 'message' in loginError.error) {
|
||||
// General error from server with object structure
|
||||
setGeneralError(loginError.error.message || 'Login failed');
|
||||
} else if (typeof loginError.error === 'string') {
|
||||
// Error is a string
|
||||
} else if ("error" in loginError) {
|
||||
if (
|
||||
typeof loginError.error === "object" &&
|
||||
loginError.error !== null &&
|
||||
"message" in loginError.error
|
||||
) {
|
||||
setGeneralError(loginError.error.message || "Login failed");
|
||||
} else if (typeof loginError.error === "string") {
|
||||
setGeneralError(loginError.error);
|
||||
} else {
|
||||
setGeneralError('Login failed. Please check your credentials.');
|
||||
setGeneralError("Login failed. Please check your credentials.");
|
||||
}
|
||||
} else {
|
||||
// Fallback for unknown error structure
|
||||
setGeneralError('An unexpected error occurred. Please try again.');
|
||||
setGeneralError("An unexpected error occurred. Please try again.");
|
||||
}
|
||||
} else if (error?.message) {
|
||||
// Network error or other error
|
||||
setGeneralError(error.message);
|
||||
} else {
|
||||
// Complete fallback
|
||||
setGeneralError('Login failed. Please check your credentials and try again.');
|
||||
setGeneralError(
|
||||
"Login failed. Please check your credentials and try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f6f9ff] px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Login Card */}
|
||||
<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">
|
||||
{/* Logo Section */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<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)]">
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.4px]">
|
||||
QAssure
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
{/* 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
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Email Field */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
{/* Forgot Password Link */}
|
||||
<div className="flex justify-end -mt-2">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate('/forgot-password');
|
||||
}}
|
||||
className="text-sm text-[#112868] hover:text-[#0d1f4d] transition-colors"
|
||||
>
|
||||
Forgot Password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-sm font-medium"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
<AuthLayout portalType="admin">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-start gap-2 w-full">
|
||||
{/* Heading */}
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<h2 className="text-[20px] font-semibold text-[#0F1724] leading-none">
|
||||
Sign in
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs md:text-sm text-[#6b7280]">
|
||||
© 2026 QAssure. All rights reserved.
|
||||
{/* 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]">
|
||||
Use your work email or configured SSO provider to access the
|
||||
admin portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white"
|
||||
noValidate
|
||||
>
|
||||
{/* General Error Message - Prioritize local error over Redux error */}
|
||||
{generalError && (
|
||||
<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
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="user@company.com"
|
||||
required
|
||||
helperText="Enter your work email"
|
||||
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")}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
helperText="Enter your password (min 8 characters)"
|
||||
error={errors.password?.message}
|
||||
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 */}
|
||||
<div className="flex items-center justify-between mt-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"
|
||||
/>
|
||||
Remember Me
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/forgot-password")}
|
||||
className="text-sm font-medium text-[#112868] hover:underline"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
<div className="pt-1">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Login"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Shield, ArrowLeft } from 'lucide-react';
|
||||
import { authService } from '@/services/auth-service';
|
||||
import { FormField } from '@/components/shared';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import type { ReactElement } from "react";
|
||||
import { authService } from "@/services/auth-service";
|
||||
import { FormField } from "@/components/shared";
|
||||
import { PrimaryButton } from "@/components/shared";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { AuthLayout } from "@/components/layout/AuthLayout";
|
||||
|
||||
// Zod validation schema - token is optional if provided in URL
|
||||
const createResetPasswordSchema = (hasTokenFromUrl: boolean) =>
|
||||
@ -16,26 +16,33 @@ const createResetPasswordSchema = (hasTokenFromUrl: boolean) =>
|
||||
.object({
|
||||
token: hasTokenFromUrl
|
||||
? z.string().optional()
|
||||
: z.string().min(1, 'Reset token is required'),
|
||||
: z.string().min(1, "Reset token is required"),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
.min(1, "Password is required")
|
||||
.min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||
acceptTerms: z.boolean().refine((val) => val === true, {
|
||||
message: "You must accept the Terms of Service and Privacy Policy",
|
||||
}),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
const ResetPassword = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>("");
|
||||
|
||||
const tokenFromUrl = searchParams.get('token') || '';
|
||||
const reason = searchParams.get('reason') || '';
|
||||
const isTenantRoute = location.pathname.startsWith("/tenant");
|
||||
const portalType = isTenantRoute ? "tenant" : "admin";
|
||||
|
||||
const tokenFromUrl = searchParams.get("token") || "";
|
||||
const reason = searchParams.get("reason") || "";
|
||||
const hasTokenFromUrl = Boolean(tokenFromUrl);
|
||||
|
||||
const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl);
|
||||
@ -50,57 +57,72 @@ const ResetPassword = (): ReactElement => {
|
||||
clearErrors,
|
||||
} = useForm<ResetPasswordFormData>({
|
||||
resolver: zodResolver(resetPasswordSchema),
|
||||
mode: 'onBlur',
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
token: tokenFromUrl || undefined,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
acceptTerms: false as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Set token from URL if available
|
||||
useEffect(() => {
|
||||
if (tokenFromUrl) {
|
||||
setValue('token', tokenFromUrl);
|
||||
setValue("token", tokenFromUrl);
|
||||
}
|
||||
}, [tokenFromUrl, setValue]);
|
||||
|
||||
const onSubmit = async (data: ResetPasswordFormData): Promise<void> => {
|
||||
setSuccess('');
|
||||
setSuccess("");
|
||||
clearErrors();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Always use token from URL if available, otherwise use form data
|
||||
const tokenToUse = tokenFromUrl || data.token || '';
|
||||
const tokenToUse = tokenFromUrl || data.token || "";
|
||||
const response = await authService.resetPassword({
|
||||
token: tokenToUse,
|
||||
password: data.password,
|
||||
});
|
||||
if (response.success) {
|
||||
const message = response.message || response.data?.message || 'Password reset successfully!';
|
||||
const description = (response.message || response.data?.message) ? undefined : 'You can now login with your new password';
|
||||
const successMessage = response.message || response.data?.message || 'Password reset successfully! You can now login with your new password.';
|
||||
const message =
|
||||
response.message ||
|
||||
response.data?.message ||
|
||||
"Password reset successfully!";
|
||||
const description =
|
||||
response.message || response.data?.message
|
||||
? undefined
|
||||
: "You can now login with your new password";
|
||||
const successMessage =
|
||||
response.message ||
|
||||
response.data?.message ||
|
||||
"Password reset successfully! You can now login with your new password.";
|
||||
setSuccess(successMessage);
|
||||
showToast.success(message, description);
|
||||
// Redirect to login after 2 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
navigate("/");
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Reset password error:', err);
|
||||
console.error("Reset password error:", err);
|
||||
// Handle validation errors from API
|
||||
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
||||
if (
|
||||
err?.response?.data?.details &&
|
||||
Array.isArray(err.response.data.details)
|
||||
) {
|
||||
const validationErrors = err.response.data.details;
|
||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||
if (detail.path === 'token' || detail.path === 'password') {
|
||||
setError(detail.path as keyof ResetPasswordFormData, {
|
||||
type: 'server',
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
validationErrors.forEach(
|
||||
(detail: { path: string; message: string }) => {
|
||||
if (detail.path === "token" || detail.path === "password") {
|
||||
setError(detail.path as keyof ResetPasswordFormData, {
|
||||
type: "server",
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Handle general errors
|
||||
const errorMessage =
|
||||
@ -108,10 +130,13 @@ const ResetPassword = (): ReactElement => {
|
||||
err?.response?.data?.error ||
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
'Failed to reset password. Please try again.';
|
||||
setError('root', {
|
||||
type: 'server',
|
||||
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to reset password. Please try again.',
|
||||
"Failed to reset password. Please try again.";
|
||||
setError("root", {
|
||||
type: "server",
|
||||
message:
|
||||
typeof errorMessage === "string"
|
||||
? errorMessage
|
||||
: "Failed to reset password. Please try again.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@ -120,140 +145,144 @@ const ResetPassword = (): ReactElement => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f6f9ff] px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Reset Password Card */}
|
||||
<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">
|
||||
{/* Logo Section */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<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)]">
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.4px]">
|
||||
QAssure
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
|
||||
{reason === 'FIRST_LOGIN' ? 'Set Initial Password' :
|
||||
reason === 'EXPIRED' ? 'Update Expired Password' :
|
||||
'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>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-[rgba(16,185,129,0.1)] border border-[#10b981] rounded-md">
|
||||
<p className="text-sm text-[#10b981]">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!success ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* General Error Display */}
|
||||
{errors.root && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Field - Only show if not in URL */}
|
||||
{!hasTokenFromUrl && (
|
||||
<FormField
|
||||
label="Reset Token"
|
||||
type="text"
|
||||
placeholder="Enter reset token from email"
|
||||
required
|
||||
error={errors.token?.message}
|
||||
{...register('token')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter your new password"
|
||||
required
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="Confirm your new password"
|
||||
required
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-sm font-medium"
|
||||
>
|
||||
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
size="large"
|
||||
onClick={() => navigate('/')}
|
||||
className="w-full h-12 text-sm font-medium"
|
||||
>
|
||||
Go to Login
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
<AuthLayout portalType={portalType}>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-start gap-2 w-full">
|
||||
{/* Heading */}
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<h2 className="text-[20px] font-semibold text-[#0F1724] leading-none">
|
||||
{reason === "FIRST_LOGIN"
|
||||
? "Set Initial Password"
|
||||
: reason === "EXPIRED"
|
||||
? "Update Expired Password"
|
||||
: "Set New Password"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs md:text-sm text-[#6b7280]">
|
||||
© 2026 QAssure. All rights reserved.
|
||||
{/* 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]">
|
||||
{reason === "FIRST_LOGIN"
|
||||
? "Welcome to QAssure! Please set a new password for your account to continue."
|
||||
: reason === "EXPIRED"
|
||||
? "Your password has expired. For security, please choose a new password."
|
||||
: "Create a strong password to activate your account and access the portal"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="w-full p-3 bg-[rgba(16,185,129,0.1)] border border-[#10b981] rounded-md">
|
||||
<p className="text-sm text-[#10b981] font-medium">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!success ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* General Error Display */}
|
||||
{errors.root && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-600 font-medium">
|
||||
{errors.root.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Field - Only show if not in URL */}
|
||||
{!hasTokenFromUrl && (
|
||||
<FormField
|
||||
label="Reset Token"
|
||||
type="text"
|
||||
placeholder="Enter reset token from email"
|
||||
required
|
||||
error={errors.token?.message}
|
||||
className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
|
||||
{...register("token")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
helperText="Min 8 characters with letters, numbers, and a symbol."
|
||||
error={errors.password?.message}
|
||||
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 */}
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
helperText="Must match the new password exactly."
|
||||
error={errors.confirmPassword?.message}
|
||||
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 */}
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
{isLoading ? "Resetting..." : "Login"}
|
||||
</PrimaryButton>
|
||||
<p className="text-xs text-[#6B7280] text-center mt-2">
|
||||
You will be redirected to the Tenant Admin Portal after saving.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
Go to Login
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,28 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { loginAsync, clearError } from '@/store/authSlice';
|
||||
import { FormField } from '@/components/shared';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import type { LoginError } from '@/services/auth-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { useTenantTheme } from '@/hooks/useTenantTheme';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import type { ReactElement } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks";
|
||||
import { loginAsync, clearError } from "@/store/authSlice";
|
||||
import { FormField } from "@/components/shared";
|
||||
import { PrimaryButton } from "@/components/shared";
|
||||
import type { LoginError } from "@/services/auth-service";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { useTenantTheme } from "@/hooks/useTenantTheme";
|
||||
import { AuthLayout } from "@/components/layout/AuthLayout";
|
||||
|
||||
// Zod validation schema
|
||||
const loginSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
.min(1, "Email is required")
|
||||
.email("Please enter a valid email address"),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
.min(1, "Password is required")
|
||||
.min(6, "Password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
@ -30,9 +30,11 @@ type LoginFormData = z.infer<typeof loginSchema>;
|
||||
const TenantLogin = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { isLoading, error, isAuthenticated, roles } = useAppSelector((state) => state.auth);
|
||||
const { isLoading, error, isAuthenticated, roles } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const { theme, logoUrl } = useAppSelector((state) => state.theme);
|
||||
|
||||
|
||||
// Fetch and apply tenant theme
|
||||
useTenantTheme();
|
||||
|
||||
@ -44,10 +46,10 @@ const TenantLogin = (): ReactElement => {
|
||||
clearErrors,
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onBlur',
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const [generalError, setGeneralError] = useState<string>('');
|
||||
const [generalError, setGeneralError] = useState<string>("");
|
||||
const [rememberMe, setRememberMe] = useState<boolean>(false);
|
||||
|
||||
// Redirect if already authenticated
|
||||
@ -58,18 +60,18 @@ const TenantLogin = (): ReactElement => {
|
||||
let rolesArray: string[] = [];
|
||||
if (Array.isArray(roles)) {
|
||||
rolesArray = roles;
|
||||
} else if (typeof roles === 'string') {
|
||||
} else if (typeof roles === "string") {
|
||||
try {
|
||||
rolesArray = JSON.parse(roles);
|
||||
} catch {
|
||||
rolesArray = [];
|
||||
}
|
||||
}
|
||||
if (rolesArray.includes('super_admin')) {
|
||||
navigate('/dashboard');
|
||||
if (rolesArray.includes("super_admin")) {
|
||||
navigate("/dashboard");
|
||||
} else {
|
||||
// Tenant admin - redirect to tenant landing page (workspace selector)
|
||||
navigate('/tenant/landing');
|
||||
navigate("/tenant/landing");
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, roles, navigate]);
|
||||
@ -77,13 +79,13 @@ const TenantLogin = (): ReactElement => {
|
||||
// Clear errors on component mount
|
||||
useEffect(() => {
|
||||
dispatch(clearError());
|
||||
setGeneralError('');
|
||||
setGeneralError("");
|
||||
clearErrors();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (data: LoginFormData): Promise<void> => {
|
||||
setGeneralError('');
|
||||
setGeneralError("");
|
||||
clearErrors();
|
||||
dispatch(clearError());
|
||||
|
||||
@ -93,315 +95,232 @@ const TenantLogin = (): ReactElement => {
|
||||
// Check if password reset is required
|
||||
if (result.data.requirePasswordReset) {
|
||||
const { tempToken, resetReason } = result.data;
|
||||
showToast.info('Password reset required', 'For security reasons, please update your password.');
|
||||
showToast.info(
|
||||
"Password reset required",
|
||||
"For security reasons, please update your password.",
|
||||
);
|
||||
navigate(`/reset-password?token=${tempToken}&reason=${resetReason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = result.message || 'Login successful';
|
||||
const message = result.message || "Login successful";
|
||||
showToast.success(message);
|
||||
|
||||
|
||||
// Check roles after login to redirect appropriately
|
||||
// Handle both array and JSON string formats
|
||||
const userRoles = result.data.roles || [];
|
||||
let rolesArray: string[] = [];
|
||||
if (Array.isArray(userRoles)) {
|
||||
rolesArray = userRoles;
|
||||
} else if (typeof userRoles === 'string') {
|
||||
} else if (typeof userRoles === "string") {
|
||||
try {
|
||||
rolesArray = JSON.parse(userRoles);
|
||||
} catch {
|
||||
rolesArray = [];
|
||||
}
|
||||
}
|
||||
if (rolesArray.includes('super_admin')) {
|
||||
navigate('/dashboard');
|
||||
if (rolesArray.includes("super_admin")) {
|
||||
navigate("/dashboard");
|
||||
} else {
|
||||
navigate('/tenant/landing');
|
||||
navigate("/tenant/landing");
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
dispatch(clearError());
|
||||
const loginError = error as LoginError;
|
||||
|
||||
if (loginError && typeof loginError === 'object') {
|
||||
if ('details' in loginError && Array.isArray(loginError.details)) {
|
||||
if (loginError && typeof loginError === "object") {
|
||||
if ("details" in loginError && Array.isArray(loginError.details)) {
|
||||
loginError.details.forEach((detail) => {
|
||||
if (detail.path === 'email' || detail.path === 'password') {
|
||||
if (detail.path === "email" || detail.path === "password") {
|
||||
setError(detail.path as keyof LoginFormData, {
|
||||
type: 'server',
|
||||
type: "server",
|
||||
message: detail.message,
|
||||
});
|
||||
} else {
|
||||
setGeneralError(detail.message);
|
||||
}
|
||||
});
|
||||
} else if ('error' in loginError) {
|
||||
if (typeof loginError.error === 'object' && loginError.error !== null && 'message' in loginError.error) {
|
||||
setGeneralError(loginError.error.message || 'Login failed');
|
||||
} else if (typeof loginError.error === 'string') {
|
||||
} else if ("error" in loginError) {
|
||||
if (
|
||||
typeof loginError.error === "object" &&
|
||||
loginError.error !== null &&
|
||||
"message" in loginError.error
|
||||
) {
|
||||
setGeneralError(loginError.error.message || "Login failed");
|
||||
} else if (typeof loginError.error === "string") {
|
||||
setGeneralError(loginError.error);
|
||||
} else {
|
||||
setGeneralError('Login failed. Please check your credentials.');
|
||||
setGeneralError("Login failed. Please check your credentials.");
|
||||
}
|
||||
} else {
|
||||
setGeneralError('An unexpected error occurred. Please try again.');
|
||||
setGeneralError("An unexpected error occurred. Please try again.");
|
||||
}
|
||||
} else if (error?.message) {
|
||||
setGeneralError(error.message);
|
||||
} else {
|
||||
setGeneralError('Login failed. Please check your credentials and try again.');
|
||||
setGeneralError(
|
||||
"Login failed. Please check your credentials and try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f6f9ff] relative flex">
|
||||
{/* Left Side - Blue Background */}
|
||||
<div
|
||||
className="hidden lg:flex lg:w-[48%] flex-col justify-between px-8 py-8 min-w-[320px]"
|
||||
style={{
|
||||
backgroundColor: theme?.primary_color || '#112868',
|
||||
<AuthLayout
|
||||
portalType="tenant"
|
||||
primaryColor={theme?.primary_color || undefined}
|
||||
secondaryColor={theme?.secondary_color || undefined}
|
||||
logoUrl={logoUrl || undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-start gap-2 w-full">
|
||||
{/* Heading */}
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<h2 className="text-[20px] font-semibold text-[#0F1724] leading-none">
|
||||
Sign in to your tenant
|
||||
</h2>
|
||||
</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]">
|
||||
Use your work email or configured SSO provider to access the admin
|
||||
portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Card Form */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="flex flex-col w-full p-5 border border-gray-300 rounded-lg bg-white"
|
||||
noValidate
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 py-22">
|
||||
{/* Logo Section */}
|
||||
<div className="flex flex-col gap-3 max-w-[280px]">
|
||||
<div className="flex items-center justify-between px-2 w-[206px]">
|
||||
{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 */}
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-2xl font-semibold text-white leading-normal">
|
||||
Secure access for<br />every tenant.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="opacity-90">
|
||||
<p className="text-sm font-medium text-white leading-[21px]">
|
||||
Log in to manage projects, approvals, documents, and more from a single, compliant control center.
|
||||
</p>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-2.5 mt-16 max-w-[377px]">
|
||||
{[
|
||||
{ label: 'Avg. tenant uptime', value: '99.98%' },
|
||||
{ label: 'Regions supported', value: '12+' },
|
||||
{ label: 'Active tenants', value: '480+' },
|
||||
{ label: 'Compliance checks', value: '24/7' },
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="backdrop-blur-[20px] border border-white rounded-md p-3"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(108.34deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.3) 100.2%)',
|
||||
}}
|
||||
>
|
||||
<div className="opacity-90 mb-1">
|
||||
<p className="text-xs font-normal text-white leading-normal">{stat.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base font-semibold text-white leading-normal">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{!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>
|
||||
</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>
|
||||
{/* Email Field */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
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")}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
error={errors.password?.message}
|
||||
className="h-11 px-4 border border-gray-300 rounded-md focus-visible:ring-blue-500 focus-visible:border-blue-500"
|
||||
{...register("password")}
|
||||
/>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-white border border-[#d1d5db] rounded-lg p-5 flex flex-col gap-2.5">
|
||||
{/* General Error Message */}
|
||||
{generalError && (
|
||||
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||
<p className="text-sm text-[#ef4444]">{generalError}</p>
|
||||
</div>
|
||||
)}
|
||||
{!generalError && error && !errors.email && !errors.password && (
|
||||
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember-me"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-[18px] h-[18px] rounded border-[#d1d5db] focus:ring-2"
|
||||
style={
|
||||
{
|
||||
backgroundColor: theme?.primary_color || "#112868",
|
||||
accentColor: theme?.primary_color || "#112868",
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember-me"
|
||||
className="text-sm font-normal text-[#0f1724] cursor-pointer"
|
||||
>
|
||||
{/* Email Field */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember-me"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-[18px] h-[18px] rounded border-[#d1d5db] focus:ring-2"
|
||||
style={{
|
||||
backgroundColor: theme?.primary_color || '#112868',
|
||||
accentColor: theme?.primary_color || '#112868',
|
||||
} as React.CSSProperties & { accentColor?: string }}
|
||||
/>
|
||||
<label htmlFor="remember-me" className="text-sm font-normal text-[#0f1724] cursor-pointer">
|
||||
Remember Me
|
||||
</label>
|
||||
</div>
|
||||
<a
|
||||
href="/tenant/forgot-password"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate('/tenant/forgot-password');
|
||||
}}
|
||||
className="text-[13px] font-medium underline"
|
||||
style={{ color: theme?.primary_color || '#112868' }}
|
||||
>
|
||||
Forgot Password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-1">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-base font-medium"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* New to tenant */}
|
||||
<div className="flex items-center justify-between h-8">
|
||||
<p className="text-xs font-normal text-[#6b7280]">New to this tenant?</p>
|
||||
<a
|
||||
href="/tenant/request-access"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate('/tenant/request-access');
|
||||
}}
|
||||
className="text-[13px] font-medium underline"
|
||||
style={{ color: theme?.secondary_color || '#23dce1' }}
|
||||
>
|
||||
Request Access
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* SSO Section */}
|
||||
<div className="pt-1">
|
||||
<div className="flex flex-col gap-2.5 h-[60px]">
|
||||
<p className="text-xs font-normal text-[#6b7280] uppercase tracking-[0.48px]">Single sign-on</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['Google', 'Microsoft', 'Okta'].map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
className="bg-white border border-[#9ca3af] rounded px-4 py-1 flex items-center justify-center gap-2 h-10 text-sm font-medium text-[#0f1724] hover:bg-[#f5f7fa] transition-colors"
|
||||
>
|
||||
{provider}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<a href="/tenant/status" className="text-xs font-normal text-[#6b7280]">
|
||||
Status page
|
||||
</a>
|
||||
<a href="/tenant/security" className="text-xs font-normal text-[#6b7280]">
|
||||
Security
|
||||
</a>
|
||||
<a href="/tenant/help" className="text-xs font-normal text-[#6b7280]">
|
||||
Help
|
||||
</a>
|
||||
Remember Me
|
||||
</label>
|
||||
</div>
|
||||
<a
|
||||
href="/tenant/forgot-password"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/tenant/forgot-password");
|
||||
}}
|
||||
className="text-[13px] font-medium underline"
|
||||
style={{ color: theme?.primary_color || "#112868" }}
|
||||
>
|
||||
Forgot Password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-11 text-base font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* New to tenant */}
|
||||
{/* <div className="flex items-center justify-between h-8 pt-1">
|
||||
<p className="text-xs font-normal text-[#6b7280]">
|
||||
New to this tenant?
|
||||
</p>
|
||||
<a
|
||||
href="/tenant/request-access"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/tenant/request-access");
|
||||
}}
|
||||
className="text-[13px] font-medium underline"
|
||||
style={{ color: theme?.secondary_color || "#23dce1" }}
|
||||
>
|
||||
Request Access
|
||||
</a>
|
||||
</div> */}
|
||||
|
||||
{/* SSO Section */}
|
||||
{/* <div className="pt-1">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p className="text-xs font-normal text-[#6b7280] uppercase tracking-[0.48px]">
|
||||
Single sign-on
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["Google", "Microsoft", "Okta"].map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
className="bg-white border border-[#9ca3af] rounded px-4 py-1 flex items-center justify-center gap-2 h-10 text-sm font-medium text-[#0f1724] hover:bg-[#f5f7fa] transition-colors"
|
||||
>
|
||||
{provider}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user