Skip to content

Commit 80f3565

Browse files
authored
two factor authentication on fn (#615)
1 parent 4e1afad commit 80f3565

File tree

8 files changed

+917
-407
lines changed

8 files changed

+917
-407
lines changed

Diff for: src/containers/DashRoutes.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const ViewTraineeRatings = React.lazy(
4242
const TtlTraineeDashboard = React.lazy(
4343
() => import('../pages/ttlTraineeDashboard'),
4444
);
45+
const LoginWith2fa = React.lazy(
46+
() => import('../pages/LoginWith2fa'),
47+
);
4548

4649
const TraineeRatingDashboard = React.lazy(
4750
() => import('../pages/TraineeRatingDashboard'),

Diff for: src/containers/Routes.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import RemoveTokenPage from '../utils/RemoveTokenPage';
4343
import PrivateRoute from '../utils/PrivateRoute';
4444
import CalendarConfirmation from '../components/CalendarConfirmation';
4545
import NotFound from '../components/NotFoundPage';
46+
import TwoFactorPage from '../pages/LoginWith2fa';
4647

4748
function MainRoutes() {
4849
return (
@@ -123,13 +124,14 @@ function MainRoutes() {
123124
<Route
124125
path="/users/login"
125126
element={
126-
<ProtectedRoutes>
127+
<ProtectedRoutes>
127128
<Suspense fallback={<Skeleton />}>
128129
<Adminlogin />
129130
</Suspense>
130-
</ProtectedRoutes>
131+
</ProtectedRoutes>
131132
}
132133
/>
134+
<Route path="/users/LoginWith2fa" element={<TwoFactorPage/>}/>
133135
<Route
134136
path="/pricing"
135137
element={

Diff for: src/pages/LoginWith2fa.tsx

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

Diff for: src/pages/Organization/2faMutation.tsx

+26
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)