Implement password recovery features by adding Forgot Password and Reset Password pages, update routing in App component, and enhance dashboard statistics fetching with error handling. Update .env for API base URL.

This commit is contained in:
Yashwin 2026-01-21 11:20:16 +05:30
parent 9fa4e99dd2
commit f4f1225f02
10 changed files with 589 additions and 49 deletions

2
.env
View File

@ -1,2 +1,2 @@
# VITE_API_BASE_URL=http://localhost:3000/api/v1
VITE_API_BASE_URL=https://qasure.tech4bizsolutions.com/api/v1
VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1

View File

@ -8,12 +8,16 @@ import ProtectedRoute from "./pages/ProtectedRoute";
import Roles from "./pages/Roles";
import Modules from "./pages/Modules";
import AuditLogs from "./pages/AuditLogs";
import ForgotPassword from "./pages/ForgotPassword";
import ResetPassword from "./pages/ResetPassword";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/dashboard"
element={

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import type { ReactElement } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

View File

@ -1,47 +1,110 @@
import { useState, useEffect } from 'react';
import { Building2, CheckCircle2, Users, TrendingUp, Package, Heart } from 'lucide-react';
import { StatCard } from './StatCard';
import type { StatCardData } from '@/types/dashboard';
import { dashboardService } from '@/services/dashboard-service';
const statsData: StatCardData[] = [
export const StatsGrid = () => {
const [statsData, setStatsData] = useState<StatCardData[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStatistics = async () => {
try {
setIsLoading(true);
setError(null);
const response = await dashboardService.getStatistics();
const { data } = response;
const mappedStats: StatCardData[] = [
{
icon: Building2,
value: '12',
value: data.totalTenants,
label: 'Total Tenants',
badge: { text: '+3 this week', variant: 'green' },
badge: { text: `${data.activeTenants} active`, variant: 'green' },
},
{
icon: CheckCircle2,
value: '10',
value: data.activeTenants,
label: 'Active Tenants',
badge: { text: '92% Rate', variant: 'green' },
badge: {
text: data.totalTenants > 0
? `${Math.round((data.activeTenants / data.totalTenants) * 100)}% Rate`
: '0% Rate',
variant: 'green',
},
},
{
icon: Users,
value: '156',
value: data.totalUsers,
label: 'Total Users',
badge: { text: '+24 new', variant: 'green' },
badge: { text: 'All users', variant: 'gray' },
},
{
icon: TrendingUp,
value: '23',
value: data.activeSessions,
label: 'Active Sessions',
badge: { text: 'Peak 38', variant: 'gray' },
badge: { text: 'Live now', variant: 'gray' },
},
{
icon: Package,
value: '5',
value: data.registeredModules,
label: 'Registered Modules',
badge: { text: 'Last 24h', variant: 'gray' },
badge: { text: 'Total', variant: 'gray' },
},
{
icon: Heart,
value: '4',
value: data.healthyModules,
label: 'Healthy Modules',
badge: { text: '100% Uptime', variant: 'green' },
badge: {
text: data.registeredModules > 0
? `${Math.round((data.healthyModules / data.registeredModules) * 100)}% Uptime`
: '0% Uptime',
variant: data.healthyModules === data.registeredModules && data.registeredModules > 0
? 'green'
: 'gray',
},
];
},
];
setStatsData(mappedStats);
} catch (err) {
console.error('Failed to fetch dashboard statistics:', err);
setError('Failed to load statistics. Please try again.');
} finally {
setIsLoading(false);
}
};
fetchStatistics();
}, []);
if (isLoading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 mb-4 md:mb-6 auto-rows-fr">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-[17px] flex flex-col gap-3 h-[107px] animate-pulse"
>
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 md:mb-6">
<p className="text-red-800 text-sm">{error}</p>
</div>
);
}
export const StatsGrid = () => {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 mb-4 md:mb-6 auto-rows-fr">
{statsData.map((stat, index) => (

View File

@ -0,0 +1,176 @@
import { useState } from 'react';
import { useNavigate, 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';
// Zod validation schema
const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
});
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
const ForgotPassword = (): ReactElement => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [success, setSuccess] = useState<string>('');
const {
register,
handleSubmit,
formState: { errors },
clearErrors,
} = useForm<ForgotPasswordFormData>({
resolver: zodResolver(forgotPasswordSchema),
mode: 'onBlur',
});
const onSubmit = async (data: ForgotPasswordFormData): Promise<void> => {
setError('');
setSuccess('');
clearErrors();
setIsLoading(true);
try {
const response = await authService.forgotPassword({ email: data.email });
if (response.success && response.data) {
setSuccess(response.data.message || 'If an account exists with this email, a password reset link has been sent.');
}
} catch (err: any) {
console.error('Forgot password error:', err);
if (err?.response?.data?.error?.message) {
setError(err.response.data.error.message);
} else if (err?.response?.data?.error) {
setError(typeof err.response.data.error === 'string' ? err.response.data.error : 'Failed to send reset email');
} else if (err?.message) {
setError(err.message);
} else {
setError('Failed to send reset email. Please try again.');
}
} finally {
setIsLoading(false);
}
};
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>
)}
</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>
);
};
export default ForgotPassword;

View File

@ -185,6 +185,20 @@ const Login = (): ReactElement => {
{...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

228
src/pages/ResetPassword.tsx Normal file
View File

@ -0,0 +1,228 @@
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';
// Zod validation schema
const resetPasswordSchema = z
.object({
token: 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'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
const ResetPassword = (): ReactElement => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [success, setSuccess] = useState<string>('');
const tokenFromUrl = searchParams.get('token') || '';
const {
register,
handleSubmit,
setValue,
formState: { errors },
clearErrors,
} = useForm<ResetPasswordFormData>({
resolver: zodResolver(resetPasswordSchema),
mode: 'onBlur',
defaultValues: {
token: tokenFromUrl,
},
});
// Set token from URL if available
useEffect(() => {
if (tokenFromUrl) {
setValue('token', tokenFromUrl);
}
}, [tokenFromUrl, setValue]);
const onSubmit = async (data: ResetPasswordFormData): Promise<void> => {
setError('');
setSuccess('');
clearErrors();
setIsLoading(true);
try {
const response = await authService.resetPassword({
token: data.token,
password: data.password,
});
if (response.success) {
setSuccess(response.data?.message || response.message || 'Password reset successfully! You can now login with your new password.');
// Redirect to login after 2 seconds
setTimeout(() => {
navigate('/');
}, 2000);
}
} catch (err: any) {
console.error('Reset password error:', err);
if (err?.response?.data?.error?.message) {
setError(err.response.data.error.message);
} else if (err?.response?.data?.error) {
setError(typeof err.response.data.error === 'string' ? err.response.data.error : 'Failed to reset password');
} else if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
// Handle validation errors
const firstError = err.response.data.details[0];
setError(firstError.message || 'Validation error');
} else if (err?.message) {
setError(err.message);
} else {
setError('Failed to reset password. Please check your token and try again.');
}
} finally {
setIsLoading(false);
}
};
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">
Reset Password
</h1>
<p className="text-sm md:text-base text-[#6b7280]">
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>
)}
{/* 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
>
{/* Token Field */}
<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 && (
<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>
);
};
export default ResetPassword;

View File

@ -51,6 +51,31 @@ export interface LogoutResponse {
message?: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ForgotPasswordResponse {
success: boolean;
data: {
success: boolean;
message: string;
};
}
export interface ResetPasswordRequest {
token: string;
password: string;
}
export interface ResetPasswordResponse {
success: boolean;
data?: {
message?: string;
};
message?: string;
}
export const authService = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/auth/login', credentials);
@ -61,4 +86,12 @@ export const authService = {
const response = await apiClient.post<LogoutResponse>('/auth/logout', {});
return response.data;
},
forgotPassword: async (data: ForgotPasswordRequest): Promise<ForgotPasswordResponse> => {
const response = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', data);
return response.data;
},
resetPassword: async (data: ResetPasswordRequest): Promise<ResetPasswordResponse> => {
const response = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', data);
return response.data;
},
};

View File

@ -0,0 +1,22 @@
import apiClient from './api-client';
export interface DashboardStatistics {
totalTenants: number;
activeTenants: number;
totalUsers: number;
activeSessions: number;
registeredModules: number;
healthyModules: number;
}
export interface DashboardStatisticsResponse {
success: boolean;
data: DashboardStatistics;
}
export const dashboardService = {
getStatistics: async (): Promise<DashboardStatisticsResponse> => {
const response = await apiClient.get<DashboardStatisticsResponse>('/dashboard/statistics');
return response.data;
},
};