Skip to content

Commit 859f30f

Browse files
Dawaic6janet-barbie
authored andcommitted
two factor authentication on fn
1 parent 708e519 commit 859f30f

File tree

9 files changed

+810
-429
lines changed

9 files changed

+810
-429
lines changed

src/components/Twofactorpopup.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from "react";
2+
interface TwoFactorPopupProps {
3+
isOpen: boolean;
4+
error: string;
5+
loading: boolean;
6+
input: string[];
7+
handleInput: (index: number, e: React.ChangeEvent<HTMLInputElement>) => void;
8+
verifyOtp: () => void;
9+
}
10+
const TwoFactorPopup: React.FC<TwoFactorPopupProps> = ({
11+
isOpen,
12+
error,
13+
loading,
14+
input,
15+
handleInput,
16+
verifyOtp,
17+
}) => {
18+
if (!isOpen) return null;
19+
return (
20+
<div className="popup">
21+
<h2>Enter your 2FA code</h2>
22+
{error && <p className="error">{error}</p>}
23+
<div className="otp-inputs">
24+
{input.map((value, index) => (
25+
<input
26+
key={index}
27+
type="text"
28+
maxLength={1}
29+
value={value}
30+
onChange={(e) => handleInput(index, e)}
31+
className="otp-input"
32+
/>
33+
))}
34+
</div>
35+
<button onClick={verifyOtp} disabled={loading}>
36+
{loading ? "Verifying..." : "Verify OTP"}
37+
</button>
38+
</div>
39+
);
40+
};
41+
export default TwoFactorPopup;

src/containers/DashRoutes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const ViewTraineeRatings = React.lazy(
4141
const TtlTraineeDashboard = React.lazy(
4242
() => import('../pages/ttlTraineeDashboard'),
4343
);
44+
const LoginWith2fa = React.lazy(
45+
() => import('../pages/LoginWith2fa'),
46+
);
4447

4548
const TraineeRatingDashboard = React.lazy(
4649
() => import('../pages/TraineeRatingDashboard'),
@@ -157,6 +160,7 @@ function DashRoutes() {
157160
<Route path="/team-cards" element={<ManagersCards />} />
158161
<Route path="/teams/cards" element={<CoordinatorCards />} />
159162
<Route path="/ttl-trainees" element={<TtlTraineeDashboard />} />
163+
160164
</Routes>
161165
</Suspense>
162166
</main>

src/containers/Routes.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import ProtectedRoutes from '../ProtectedRoute';
4141
import RemoveTokenPage from '../utils/RemoveTokenPage';
4242
import PrivateRoute from '../utils/PrivateRoute'
4343
import CalendarConfirmation from '../components/CalendarConfirmation';
44+
import TwoFactorPage from '../pages/LoginWith2fa';
4445

4546
function MainRoutes() {
4647
return (
@@ -120,13 +121,14 @@ function MainRoutes() {
120121
<Route
121122
path="/users/login"
122123
element={
123-
<ProtectedRoutes>
124+
<ProtectedRoutes>
124125
<Suspense fallback={<Skeleton />}>
125126
<Adminlogin />
126127
</Suspense>
127-
</ProtectedRoutes>
128+
</ProtectedRoutes>
128129
}
129130
/>
131+
<Route path="/users/LoginWith2fa" element={<TwoFactorPage/>}/>
130132
<Route
131133
path="/pricing"
132134
element={

src/pages/LoginWith2fa.tsx

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import React, { useState, useEffect, useCallback, useContext } from 'react';
2+
import { useMutation, gql, useApolloClient } from '@apollo/client';
3+
import { useLocation, useNavigate } from 'react-router-dom';
4+
import { UserContext } from '../hook/useAuth';
5+
import { toast } from 'react-toastify';
6+
import { useTranslation } from 'react-i18next';
7+
8+
interface Profile {
9+
id: string;
10+
firstName: string;
11+
lastName: string;
12+
name: string | null;
13+
address: string | null;
14+
city: string | null;
15+
country: string | null;
16+
phoneNumber: string | null;
17+
biography: string | null;
18+
avatar: string | null;
19+
cover: string | null;
20+
__typename: 'Profile';
21+
}
22+
23+
interface User {
24+
id: string;
25+
role: string;
26+
email: string;
27+
profile: Profile;
28+
__typename: 'User';
29+
}
30+
31+
interface LoginResponse {
32+
loginWithTwoFactorAuthentication: {
33+
token: string;
34+
user: User;
35+
message: string;
36+
__typename: 'LoginResponse';
37+
};
38+
}
39+
40+
const LOGIN_WITH_2FA = gql`
41+
mutation LoginWithTwoFactorAuthentication(
42+
$email: String!
43+
$otp: String!
44+
$TwoWayVerificationToken: String!
45+
) {
46+
loginWithTwoFactorAuthentication(
47+
email: $email
48+
otp: $otp
49+
TwoWayVerificationToken: $TwoWayVerificationToken
50+
) {
51+
token
52+
user {
53+
id
54+
role
55+
email
56+
profile {
57+
id
58+
firstName
59+
lastName
60+
name
61+
address
62+
city
63+
country
64+
phoneNumber
65+
biography
66+
avatar
67+
cover
68+
}
69+
}
70+
message
71+
}
72+
}
73+
`;
74+
75+
const TwoFactorPage: React.FC = () => {
76+
const [input, setInput] = useState<string[]>(Array(6).fill(''));
77+
const [error, setError] = useState('');
78+
const [loading, setLoading] = useState(false);
79+
const [isDark, setIsDark] = useState(false);
80+
const { login } = useContext(UserContext);
81+
const client = useApolloClient();
82+
const { t } = useTranslation();
83+
84+
const location = useLocation();
85+
const navigate = useNavigate();
86+
const { email, TwoWayVerificationToken } = location.state || {};
87+
useEffect(() => {
88+
// Update document class and localStorage when theme changes
89+
if (isDark) {
90+
document.documentElement.classList.add('dark');
91+
localStorage.setItem('theme', 'dark');
92+
} else {
93+
document.documentElement.classList.remove('dark');
94+
localStorage.setItem('theme', 'light');
95+
}
96+
}, [isDark]);
97+
98+
useEffect(() => {
99+
if (!email || !TwoWayVerificationToken) {
100+
navigate('/login');
101+
}
102+
}, [email, TwoWayVerificationToken, navigate]);
103+
104+
const [loginWithTwoFactorAuthentication] = useMutation<LoginResponse>(LOGIN_WITH_2FA, {
105+
onCompleted: async (data) => {
106+
const response = data.loginWithTwoFactorAuthentication;
107+
try {
108+
localStorage.setItem('authToken', response.token);
109+
localStorage.setItem('user', JSON.stringify(response.user));
110+
await login(response);
111+
await client.resetStore();
112+
toast.success(response.message);
113+
114+
const rolePaths: Record<string, string> = {
115+
superAdmin: '/organizations',
116+
admin: '/trainees',
117+
coordinator: '/trainees',
118+
manager: '/dashboard',
119+
ttl: '/ttl-trainees',
120+
trainee: '/performance'
121+
};
122+
123+
const redirectPath = rolePaths[response.user.role] || '/dashboard';
124+
navigate(redirectPath, { replace: true });
125+
} catch (error) {
126+
console.error('Login error:', error);
127+
toast.error(('Login Error'));
128+
}
129+
},
130+
onError: (error) => {
131+
const errorMessage = error.message || ('Verification Failed');
132+
setError(errorMessage);
133+
toast.error(errorMessage);
134+
setInput(Array(6).fill(''));
135+
}
136+
});
137+
138+
const verifyOtp = async (currentInput = input) => {
139+
if (currentInput.some(val => !val)) {
140+
setError(('Please Enter All Digits'));
141+
return;
142+
}
143+
144+
setLoading(true);
145+
setError('');
146+
147+
try {
148+
await loginWithTwoFactorAuthentication({
149+
variables: {
150+
email,
151+
otp: currentInput.join(''),
152+
TwoWayVerificationToken
153+
}
154+
});
155+
} finally {
156+
setLoading(false);
157+
}
158+
};
159+
160+
const handleInput = useCallback((index: number, value: string) => {
161+
if (!/^\d*$/.test(value)) return;
162+
163+
const newInput = [...input];
164+
newInput[index] = value;
165+
setInput(newInput);
166+
167+
if (value && index < input.length - 1) {
168+
const nextInput = document.getElementById(`otp-input-${index + 1}`) as HTMLInputElement;
169+
nextInput?.focus();
170+
}
171+
172+
if (value && index === input.length - 1) {
173+
const allFilled = newInput.every(val => val !== '');
174+
if (allFilled) {
175+
verifyOtp(newInput);
176+
}
177+
}
178+
}, [input]);
179+
180+
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
181+
if (e.key === 'Backspace' && !input[index] && index > 0) {
182+
const prevInput = document.getElementById(`otp-input-${index - 1}`) as HTMLInputElement;
183+
prevInput?.focus();
184+
}
185+
};
186+
187+
const handlePaste = (e: React.ClipboardEvent) => {
188+
e.preventDefault();
189+
const pastedData = e.clipboardData.getData('text').trim();
190+
191+
if (!/^\d+$/.test(pastedData)) {
192+
setError(('Only Numbers Can Be Pasted'));
193+
return;
194+
}
195+
196+
const digits = pastedData.slice(0, 6).split('');
197+
const newInput = [...digits, ...Array(6 - digits.length).fill('')];
198+
199+
setInput(newInput);
200+
201+
if (digits.length < 6) {
202+
const nextEmptyIndex = digits.length;
203+
const nextInput = document.getElementById(`otp-input-${nextEmptyIndex}`) as HTMLInputElement;
204+
nextInput?.focus();
205+
} else {
206+
verifyOtp(newInput);
207+
}
208+
};
209+
210+
const toggleTheme = () => {
211+
setIsDark(!isDark);
212+
};
213+
214+
return (
215+
<div className="flex flex-col items-center justify-center min-h-screen transition-colors duration-200 bg-gray-100 dark:bg-gray-900">
216+
<div className="p-8 transition-colors duration-200 bg-white rounded-lg shadow-md dark:bg-gray-800 w-96">
217+
<h2 className="mb-6 text-2xl font-semibold text-center text-gray-800 dark:text-gray-100">
218+
{('Verification Required')}
219+
</h2>
220+
221+
<p className="mb-6 text-sm text-center text-gray-600 dark:text-gray-400">
222+
{('Enter Verification Code')}<br/>
223+
<span className="font-medium">{email}</span>
224+
</p>
225+
226+
{error && (
227+
<div className="p-3 mb-4 text-sm text-red-500 bg-red-100 rounded dark:bg-red-900/30">
228+
{error}
229+
</div>
230+
)}
231+
232+
<form onSubmit={(e) => {
233+
e.preventDefault();
234+
verifyOtp();
235+
}}>
236+
<div className="flex justify-center mb-6 space-x-2">
237+
{input.map((value, index) => (
238+
<input
239+
key={index}
240+
id={`otp-input-${index}`}
241+
type="text"
242+
inputMode="numeric"
243+
maxLength={1}
244+
value={value}
245+
onChange={(e) => handleInput(index, e.target.value)}
246+
onKeyDown={(e) => handleKeyDown(index, e)}
247+
onPaste={index === 0 ? handlePaste : undefined}
248+
className="w-12 h-12 text-lg font-semibold text-center text-gray-800 transition-colors bg-white border rounded dark:text-gray-100 dark:border-gray-600 dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
249+
disabled={loading}
250+
autoComplete="one-time-code"
251+
required
252+
/>
253+
))}
254+
</div>
255+
256+
<button
257+
type="submit"
258+
disabled={loading || input.some(val => !val)}
259+
className="w-full py-3 text-white transition-colors bg-primary rounded hover:bg-blue-600 disabled:bg-primary dark:disabled:bg-primary disabled:cursor-not-allowed"
260+
>
261+
{loading ? (
262+
<span className="flex items-center justify-center">
263+
{('Verifying')}
264+
</span>
265+
) : (
266+
('VerifyCode')
267+
)}
268+
</button>
269+
</form>
270+
271+
272+
</div>
273+
</div>
274+
);
275+
};
276+
277+
export default TwoFactorPage;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { gql } from '@apollo/client';
2+
3+
export const EnableTwoFactorAuth = gql`
4+
mutation EnableTwoFactorAuth($email: String!) {
5+
enableTwoFactorAuth(email: $email)
6+
}
7+
`;
8+
9+
10+
export const LoginWithTwoFactorAuthentication= gql`
11+
mutation LoginWithTwoFactorAuthentication($email: String!, $otp: String!, $twoWayVerificationToken: String!) {
12+
loginWithTwoFactorAuthentication(email: $email, otp: $otp, TwoWayVerificationToken: $twoWayVerificationToken) {
13+
message
14+
token
15+
user {
16+
email
17+
}
18+
}
19+
}
20+
`;
21+
22+
export const DisableTwoFactorAuth = gql`
23+
mutation DisableTwoFactorAuth($email: String!) {
24+
disableTwoFactorAuth(email: $email)
25+
}
26+
`;

0 commit comments

Comments
 (0)