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=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 Roles from "./pages/Roles";
import Modules from "./pages/Modules"; import Modules from "./pages/Modules";
import AuditLogs from "./pages/AuditLogs"; import AuditLogs from "./pages/AuditLogs";
import ForgotPassword from "./pages/ForgotPassword";
import ResetPassword from "./pages/ResetPassword";
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Login />} /> <Route path="/" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; 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 { Building2, CheckCircle2, Users, TrendingUp, Package, Heart } from 'lucide-react';
import { StatCard } from './StatCard'; import { StatCard } from './StatCard';
import type { StatCardData } from '@/types/dashboard'; import type { StatCardData } from '@/types/dashboard';
import { dashboardService } from '@/services/dashboard-service';
const statsData: StatCardData[] = [
{
icon: Building2,
value: '12',
label: 'Total Tenants',
badge: { text: '+3 this week', variant: 'green' },
},
{
icon: CheckCircle2,
value: '10',
label: 'Active Tenants',
badge: { text: '92% Rate', variant: 'green' },
},
{
icon: Users,
value: '156',
label: 'Total Users',
badge: { text: '+24 new', variant: 'green' },
},
{
icon: TrendingUp,
value: '23',
label: 'Active Sessions',
badge: { text: 'Peak 38', variant: 'gray' },
},
{
icon: Package,
value: '5',
label: 'Registered Modules',
badge: { text: 'Last 24h', variant: 'gray' },
},
{
icon: Heart,
value: '4',
label: 'Healthy Modules',
badge: { text: '100% Uptime', variant: 'green' },
},
];
export const StatsGrid = () => { 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: data.totalTenants,
label: 'Total Tenants',
badge: { text: `${data.activeTenants} active`, variant: 'green' },
},
{
icon: CheckCircle2,
value: data.activeTenants,
label: 'Active Tenants',
badge: {
text: data.totalTenants > 0
? `${Math.round((data.activeTenants / data.totalTenants) * 100)}% Rate`
: '0% Rate',
variant: 'green',
},
},
{
icon: Users,
value: data.totalUsers,
label: 'Total Users',
badge: { text: 'All users', variant: 'gray' },
},
{
icon: TrendingUp,
value: data.activeSessions,
label: 'Active Sessions',
badge: { text: 'Live now', variant: 'gray' },
},
{
icon: Package,
value: data.registeredModules,
label: 'Registered Modules',
badge: { text: 'Total', variant: 'gray' },
},
{
icon: Heart,
value: data.healthyModules,
label: 'Healthy Modules',
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>
);
}
return ( 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"> <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) => ( {statsData.map((stat, index) => (

View File

@ -14,17 +14,17 @@ const Dashboard = (): ReactElement => {
description: 'Monitor system health, tenant activity, and user metrics in real-time.', description: 'Monitor system health, tenant activity, and user metrics in real-time.',
}} }}
> >
{/* Stats Grid */} {/* Stats Grid */}
<StatsGrid /> <StatsGrid />
{/* Bottom Section */} {/* Bottom Section */}
<div className="flex flex-col lg:flex-row gap-4 md:gap-6 items-start mt-4 md:mt-6"> <div className="flex flex-col lg:flex-row gap-4 md:gap-6 items-start mt-4 md:mt-6">
<RecentActivity /> <RecentActivity />
<div className="flex flex-col gap-4 md:gap-6 w-full lg:w-[300px] lg:shrink-0"> <div className="flex flex-col gap-4 md:gap-6 w-full lg:w-[300px] lg:shrink-0">
<QuickActions /> <QuickActions />
<SystemHealth /> <SystemHealth />
</div> </div>
</div> </div>
</Layout> </Layout>
); );
}; };

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')} {...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 */} {/* Submit Button */}
<div className="pt-2"> <div className="pt-2">
<PrimaryButton <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; 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 = { export const authService = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => { login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/auth/login', credentials); const response = await apiClient.post<LoginResponse>('/auth/login', credentials);
@ -61,4 +86,12 @@ export const authService = {
const response = await apiClient.post<LogoutResponse>('/auth/logout', {}); const response = await apiClient.post<LogoutResponse>('/auth/logout', {});
return response.data; 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;
},
};