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:
parent
9fa4e99dd2
commit
f4f1225f02
2
.env
2
.env
@ -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
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
176
src/pages/ForgotPassword.tsx
Normal file
176
src/pages/ForgotPassword.tsx
Normal 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;
|
||||||
@ -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
228
src/pages/ResetPassword.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
22
src/services/dashboard-service.ts
Normal file
22
src/services/dashboard-service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user