@vivinkv28/strapi-2fa-admin-plugin 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ import { jsx, jsxs } from 'react/jsx-runtime';
2
+ import * as React from 'react';
3
+ import { Main, Box, Typography, Flex, Button, Link } from '@strapi/design-system';
4
+ import camelCase from 'lodash/camelCase';
5
+ import { useIntl } from 'react-intl';
6
+ import { useLocation, useNavigate, NavLink } from 'react-router-dom';
7
+ import * as yup from 'yup';
8
+ import { Form, useForm } from '../../../components/Form.mjs';
9
+ import { InputRenderer as MemoizedInputRenderer } from '../../../components/FormInputs/Renderer.mjs';
10
+ import { Logo } from '../../../components/UnauthenticatedLogo.mjs';
11
+ import { UnauthenticatedLayout, LayoutContent, Column } from '../../../layouts/UnauthenticatedLayout.mjs';
12
+ import { useTypedDispatch } from '../../../core/store/hooks.mjs';
13
+ import { useNotification } from '../../../features/Notifications.mjs';
14
+ import { login as loginAction } from '../../../reducer.mjs';
15
+ import { useAdminLoginWithOtpMutation, useVerifyAdminLoginOtpMutation, useResendAdminLoginOtpMutation } from '../../../services/auth.mjs';
16
+ import { getOrCreateDeviceId } from '../../../utils/deviceId.mjs';
17
+ import { translatedErrors as errorsTrads } from '../../../utils/translatedErrors.mjs';
18
+
19
+ const OTP_LENGTH = 6;
20
+ const OTP_DIGIT_INPUT_STYLE = {
21
+ width: 'min(3.75rem, calc((100vw - 7rem) / 6))',
22
+ minWidth: '2.7rem',
23
+ maxWidth: '3.75rem',
24
+ flex: '1 1 0',
25
+ height: 'min(4.5rem, calc((100vw - 7rem) / 5.4))',
26
+ borderRadius: '1rem',
27
+ borderStyle: 'solid',
28
+ borderWidth: '2px',
29
+ borderColor: 'var(--strapi-colors-neutral500)',
30
+ backgroundColor: 'var(--strapi-colors-neutral200)',
31
+ color: 'var(--strapi-colors-neutral800)',
32
+ fontSize: '1.85rem',
33
+ fontWeight: 700,
34
+ textAlign: 'center',
35
+ outline: 'none',
36
+ transition: 'all 160ms ease',
37
+ boxShadow: 'none'
38
+ };
39
+ const sanitizeOtp = (value = '')=>value.replace(/\D/g, '').slice(0, OTP_LENGTH);
40
+ const createOtpDigits = (value = '')=>Array.from({
41
+ length: OTP_LENGTH
42
+ }, (_, index)=>value[index] ?? '');
43
+ const LOGIN_SCHEMA = yup.object().shape({
44
+ email: yup.string().nullable().email({
45
+ id: errorsTrads.email.id,
46
+ defaultMessage: 'Not a valid email'
47
+ }).required(errorsTrads.required),
48
+ password: yup.string().required(errorsTrads.required).nullable(),
49
+ rememberMe: yup.bool().nullable()
50
+ });
51
+ const OTP_SCHEMA = yup.object().shape({
52
+ code: yup.string().nullable().matches(/^\d{6}$/, {
53
+ message: 'OTP code must be a 6-digit number'
54
+ }).required(errorsTrads.required)
55
+ });
56
+ const OtpField = ()=>{
57
+ const { values, errors, onChange, isSubmitting } = useForm('OtpField', (state)=>state);
58
+ const inputRefs = React.useRef([]);
59
+ const codeValue = typeof values.code === 'string' ? values.code : '';
60
+ const digits = React.useMemo(()=>createOtpDigits(codeValue), [
61
+ codeValue
62
+ ]);
63
+ const [activeIndex, setActiveIndex] = React.useState(0);
64
+ const focusInput = React.useCallback((index)=>{
65
+ window.requestAnimationFrame(()=>{
66
+ const element = inputRefs.current[index];
67
+ if (element) {
68
+ element.focus();
69
+ element.select();
70
+ }
71
+ });
72
+ }, []);
73
+ React.useEffect(()=>{
74
+ if (!codeValue) {
75
+ focusInput(0);
76
+ }
77
+ }, [
78
+ codeValue,
79
+ focusInput
80
+ ]);
81
+ const commitDigits = React.useCallback((nextDigits, focusIndex)=>{
82
+ const nextCode = nextDigits.join('');
83
+ onChange('code', nextCode || null);
84
+ if (typeof focusIndex === 'number') {
85
+ focusInput(focusIndex);
86
+ }
87
+ }, [
88
+ focusInput,
89
+ onChange
90
+ ]);
91
+ const handleChange = (index)=>(event)=>{
92
+ const incomingValue = sanitizeOtp(event.target.value);
93
+ const nextDigits = [
94
+ ...digits
95
+ ];
96
+ if (!incomingValue) {
97
+ nextDigits[index] = '';
98
+ commitDigits(nextDigits, index);
99
+ return;
100
+ }
101
+ incomingValue.split('').forEach((digit, offset)=>{
102
+ const targetIndex = index + offset;
103
+ if (targetIndex < OTP_LENGTH) {
104
+ nextDigits[targetIndex] = digit;
105
+ }
106
+ });
107
+ const nextFocusIndex = Math.min(index + incomingValue.length, OTP_LENGTH - 1);
108
+ commitDigits(nextDigits, nextFocusIndex);
109
+ };
110
+ const handleKeyDown = (index)=>(event)=>{
111
+ if (event.key === 'Backspace') {
112
+ event.preventDefault();
113
+ const nextDigits = [
114
+ ...digits
115
+ ];
116
+ if (nextDigits[index]) {
117
+ nextDigits[index] = '';
118
+ commitDigits(nextDigits, index);
119
+ } else if (index > 0) {
120
+ nextDigits[index - 1] = '';
121
+ commitDigits(nextDigits, index - 1);
122
+ }
123
+ return;
124
+ }
125
+ if (event.key === 'ArrowLeft' && index > 0) {
126
+ event.preventDefault();
127
+ focusInput(index - 1);
128
+ return;
129
+ }
130
+ if (event.key === 'ArrowRight' && index < OTP_LENGTH - 1) {
131
+ event.preventDefault();
132
+ focusInput(index + 1);
133
+ }
134
+ };
135
+ const handlePaste = (event)=>{
136
+ const pastedValue = sanitizeOtp(event.clipboardData.getData('text'));
137
+ if (!pastedValue) {
138
+ return;
139
+ }
140
+ event.preventDefault();
141
+ const nextDigits = createOtpDigits();
142
+ pastedValue.split('').forEach((digit, index)=>{
143
+ nextDigits[index] = digit;
144
+ });
145
+ commitDigits(nextDigits, Math.min(pastedValue.length, OTP_LENGTH) - 1);
146
+ };
147
+ return /*#__PURE__*/ jsxs(Box, {
148
+ padding: 5,
149
+ style: {
150
+ borderRadius: '1.25rem',
151
+ background: 'var(--strapi-colors-neutral150)',
152
+ border: '1px solid var(--strapi-colors-neutral300)'
153
+ },
154
+ children: [
155
+ /*#__PURE__*/ jsx(Box, {
156
+ paddingBottom: 2,
157
+ children: /*#__PURE__*/ jsx(Flex, {
158
+ gap: 2,
159
+ justifyContent: "center",
160
+ wrap: "nowrap",
161
+ width: "100%",
162
+ style: {
163
+ maxWidth: '24rem',
164
+ marginInline: 'auto'
165
+ },
166
+ onPaste: handlePaste,
167
+ children: digits.map((digit, index)=>/*#__PURE__*/ jsx("input", {
168
+ "aria-invalid": errors.code ? 'true' : 'false',
169
+ "aria-label": `OTP digit ${index + 1}`,
170
+ autoComplete: index === 0 ? 'one-time-code' : 'off',
171
+ disabled: isSubmitting,
172
+ inputMode: "numeric",
173
+ maxLength: 6,
174
+ onChange: handleChange(index),
175
+ onFocus: ()=>setActiveIndex(index),
176
+ onKeyDown: handleKeyDown(index),
177
+ pattern: "[0-9]*",
178
+ ref: (element)=>{
179
+ inputRefs.current[index] = element;
180
+ },
181
+ style: {
182
+ ...OTP_DIGIT_INPUT_STYLE,
183
+ borderColor: errors.code ? 'var(--strapi-colors-danger600)' : activeIndex === index ? 'var(--strapi-colors-primary600)' : 'var(--strapi-colors-neutral500)',
184
+ backgroundColor: activeIndex === index ? 'var(--strapi-colors-neutral0)' : 'var(--strapi-colors-neutral200)',
185
+ boxShadow: errors.code ? '0 0 0 1px var(--strapi-colors-danger600)' : activeIndex === index ? '0 0 0 3px rgba(73, 69, 255, 0.18)' : 'inset 0 0 0 1px var(--strapi-colors-neutral600)'
186
+ },
187
+ type: "text",
188
+ value: digit
189
+ }, index))
190
+ })
191
+ }),
192
+ errors.code ? /*#__PURE__*/ jsx(Box, {
193
+ paddingTop: 3,
194
+ children: /*#__PURE__*/ jsx(Typography, {
195
+ id: "otp-code-error",
196
+ variant: "pi",
197
+ textColor: "danger600",
198
+ children: errors.code
199
+ })
200
+ }) : null
201
+ ]
202
+ });
203
+ };
204
+ const Login = ({ children })=>{
205
+ const [apiError, setApiError] = React.useState();
206
+ const [otpStep, setOtpStep] = React.useState(null);
207
+ const { formatMessage } = useIntl();
208
+ const { search: searchString } = useLocation();
209
+ const query = React.useMemo(()=>new URLSearchParams(searchString), [
210
+ searchString
211
+ ]);
212
+ const navigate = useNavigate();
213
+ const dispatch = useTypedDispatch();
214
+ const { toggleNotification } = useNotification();
215
+ const [adminLoginWithOtp, { isLoading: isLoggingIn }] = useAdminLoginWithOtpMutation();
216
+ const [verifyAdminLoginOtp, { isLoading: isVerifyingOtp }] = useVerifyAdminLoginOtpMutation();
217
+ const [resendAdminLoginOtp, { isLoading: isResendingOtp }] = useResendAdminLoginOtpMutation();
218
+ React.useEffect(()=>{
219
+ document.title = 'Admin Dashboard';
220
+ }, []);
221
+ const handleLogin = async (body)=>{
222
+ setApiError(undefined);
223
+ const res = await adminLoginWithOtp({
224
+ ...body,
225
+ deviceId: getOrCreateDeviceId()
226
+ });
227
+ if ('error' in res) {
228
+ const message = res.error.message ?? 'Something went wrong';
229
+ if (camelCase(message).toLowerCase() === 'usernotactive') {
230
+ navigate('/auth/oops');
231
+ return;
232
+ }
233
+ setApiError(message);
234
+ } else {
235
+ setOtpStep({
236
+ challengeId: res.data.challengeId,
237
+ expiresAt: res.data.expiresAt,
238
+ maskedEmail: res.data.maskedEmail,
239
+ rememberMe: body.rememberMe
240
+ });
241
+ }
242
+ };
243
+ const handleVerifyOtp = async ({ code })=>{
244
+ if (!otpStep) {
245
+ return;
246
+ }
247
+ setApiError(undefined);
248
+ const res = await verifyAdminLoginOtp({
249
+ challengeId: otpStep.challengeId,
250
+ code
251
+ });
252
+ if ('error' in res) {
253
+ setApiError(res.error.message ?? 'Something went wrong');
254
+ } else {
255
+ toggleNotification({
256
+ type: 'success',
257
+ title: formatMessage({
258
+ id: 'Auth.notification.authenticated.title',
259
+ defaultMessage: 'Successfully authenticated'
260
+ })
261
+ });
262
+ dispatch(loginAction({
263
+ token: res.data.token,
264
+ persist: otpStep.rememberMe
265
+ }));
266
+ const redirectTo = query.get('redirectTo');
267
+ const redirectUrl = redirectTo ? decodeURIComponent(redirectTo) : '/';
268
+ navigate(redirectUrl);
269
+ }
270
+ };
271
+ const handleResendOtp = async ()=>{
272
+ if (!otpStep) {
273
+ return;
274
+ }
275
+ setApiError(undefined);
276
+ const res = await resendAdminLoginOtp({
277
+ challengeId: otpStep.challengeId
278
+ });
279
+ if ('error' in res) {
280
+ setApiError(res.error.message ?? 'Something went wrong');
281
+ } else {
282
+ setOtpStep({
283
+ ...otpStep,
284
+ expiresAt: res.data.expiresAt,
285
+ maskedEmail: res.data.maskedEmail
286
+ });
287
+ toggleNotification({
288
+ type: 'success',
289
+ title: formatMessage({
290
+ id: 'Auth.notification.otpResent.title',
291
+ defaultMessage: 'OTP resent'
292
+ }),
293
+ message: formatMessage({
294
+ id: 'Auth.notification.otpResent.message',
295
+ defaultMessage: `A new OTP has been sent to ${res.data.maskedEmail}.`
296
+ })
297
+ });
298
+ }
299
+ };
300
+ return /*#__PURE__*/ jsx(UnauthenticatedLayout, {
301
+ children: /*#__PURE__*/ jsxs(Main, {
302
+ children: [
303
+ /*#__PURE__*/ jsxs(LayoutContent, {
304
+ children: [
305
+ /*#__PURE__*/ jsxs(Column, {
306
+ children: [
307
+ /*#__PURE__*/ jsx(Logo, {}),
308
+ /*#__PURE__*/ jsx(Box, {
309
+ paddingTop: 6,
310
+ paddingBottom: 1,
311
+ children: /*#__PURE__*/ jsx(Typography, {
312
+ variant: "alpha",
313
+ tag: "h1",
314
+ textAlign: "center",
315
+ children: formatMessage({
316
+ id: otpStep ? 'Auth.form.otp.title' : 'Auth.form.welcome.title',
317
+ defaultMessage: otpStep ? 'Enter your OTP code' : 'Welcome!'
318
+ })
319
+ })
320
+ }),
321
+ /*#__PURE__*/ jsx(Box, {
322
+ paddingBottom: otpStep ? 5 : 7,
323
+ children: /*#__PURE__*/ jsx(Typography, {
324
+ variant: "epsilon",
325
+ textColor: "neutral600",
326
+ textAlign: "center",
327
+ display: "block",
328
+ children: formatMessage({
329
+ id: otpStep ? 'Auth.form.otp.subtitle' : 'Auth.form.welcome.subtitle',
330
+ defaultMessage: otpStep ? `We sent a 6-digit code to ${otpStep.maskedEmail}` : 'Log in to your Strapi account'
331
+ })
332
+ })
333
+ }),
334
+ otpStep ? /*#__PURE__*/ jsx(Box, {
335
+ paddingBottom: 5,
336
+ children: /*#__PURE__*/ jsx(Typography, {
337
+ variant: "pi",
338
+ textColor: "neutral600",
339
+ textAlign: "center",
340
+ children: `OTP expires at ${new Date(otpStep.expiresAt).toLocaleTimeString()}`
341
+ })
342
+ }) : null,
343
+ apiError ? /*#__PURE__*/ jsx(Box, {
344
+ paddingBottom: 4,
345
+ children: /*#__PURE__*/ jsx(Typography, {
346
+ id: "global-form-error",
347
+ role: "alert",
348
+ tabIndex: -1,
349
+ textColor: "danger600",
350
+ textAlign: "center",
351
+ children: apiError
352
+ })
353
+ }) : null
354
+ ]
355
+ }),
356
+ /*#__PURE__*/ jsx(Form, {
357
+ method: "PUT",
358
+ initialValues: otpStep ? {
359
+ code: ''
360
+ } : {
361
+ email: '',
362
+ password: '',
363
+ rememberMe: false
364
+ },
365
+ onSubmit: (values)=>{
366
+ if (otpStep) {
367
+ handleVerifyOtp(values);
368
+ } else {
369
+ handleLogin(values);
370
+ }
371
+ },
372
+ validationSchema: otpStep ? OTP_SCHEMA : LOGIN_SCHEMA,
373
+ children: /*#__PURE__*/ jsxs(Flex, {
374
+ direction: "column",
375
+ alignItems: "stretch",
376
+ gap: 6,
377
+ children: [
378
+ otpStep ? /*#__PURE__*/ jsx(OtpField, {}) : [
379
+ /*#__PURE__*/ jsx(MemoizedInputRenderer, {
380
+ label: formatMessage({
381
+ id: 'Auth.form.email.label',
382
+ defaultMessage: 'Email'
383
+ }),
384
+ name: "email",
385
+ placeholder: formatMessage({
386
+ id: 'Auth.form.email.placeholder',
387
+ defaultMessage: 'kai@doe.com'
388
+ }),
389
+ required: true,
390
+ type: "email"
391
+ }, "email"),
392
+ /*#__PURE__*/ jsx(MemoizedInputRenderer, {
393
+ label: formatMessage({
394
+ id: 'global.password',
395
+ defaultMessage: 'Password'
396
+ }),
397
+ name: "password",
398
+ required: true,
399
+ type: "password"
400
+ }, "password"),
401
+ /*#__PURE__*/ jsx(MemoizedInputRenderer, {
402
+ label: formatMessage({
403
+ id: 'Auth.form.rememberMe.label',
404
+ defaultMessage: 'Remember me'
405
+ }),
406
+ name: "rememberMe",
407
+ type: "checkbox"
408
+ }, "rememberMe")
409
+ ],
410
+ /*#__PURE__*/ jsxs(Flex, {
411
+ direction: "column",
412
+ gap: 3,
413
+ children: [
414
+ /*#__PURE__*/ jsx(Button, {
415
+ fullWidth: true,
416
+ type: "submit",
417
+ disabled: otpStep ? isVerifyingOtp : isLoggingIn,
418
+ loading: otpStep ? isVerifyingOtp : isLoggingIn,
419
+ children: formatMessage({
420
+ id: otpStep ? 'Auth.form.button.verifyOtp' : 'Auth.form.button.login',
421
+ defaultMessage: otpStep ? isVerifyingOtp ? 'Verifying...' : 'Verify OTP' : isLoggingIn ? 'Login...' : 'Login'
422
+ })
423
+ }),
424
+ otpStep ? /*#__PURE__*/ jsxs(Flex, {
425
+ gap: 2,
426
+ justifyContent: "space-between",
427
+ alignItems: "stretch",
428
+ children: [
429
+ /*#__PURE__*/ jsx(Button, {
430
+ fullWidth: true,
431
+ style: {
432
+ minWidth: '11rem'
433
+ },
434
+ type: "button",
435
+ variant: "secondary",
436
+ onClick: ()=>setOtpStep(null),
437
+ disabled: isResendingOtp,
438
+ children: formatMessage({
439
+ id: 'Auth.form.button.back',
440
+ defaultMessage: 'Back'
441
+ })
442
+ }),
443
+ /*#__PURE__*/ jsx(Button, {
444
+ fullWidth: true,
445
+ style: {
446
+ minWidth: '11rem'
447
+ },
448
+ type: "button",
449
+ variant: "tertiary",
450
+ onClick: handleResendOtp,
451
+ disabled: isResendingOtp,
452
+ children: formatMessage({
453
+ id: 'Auth.form.button.resendOtp',
454
+ defaultMessage: isResendingOtp ? 'Resending...' : 'Resend OTP'
455
+ })
456
+ })
457
+ ]
458
+ }) : null
459
+ ]
460
+ })
461
+ ]
462
+ })
463
+ }),
464
+ children
465
+ ]
466
+ }),
467
+ /*#__PURE__*/ jsx(Flex, {
468
+ justifyContent: "center",
469
+ children: /*#__PURE__*/ jsx(Box, {
470
+ paddingTop: 4,
471
+ children: /*#__PURE__*/ jsx(Link, {
472
+ isExternal: false,
473
+ tag: NavLink,
474
+ to: "/auth/forgot-password",
475
+ children: formatMessage({
476
+ id: 'Auth.link.forgot-password',
477
+ defaultMessage: 'Forgot your password?'
478
+ })
479
+ })
480
+ })
481
+ })
482
+ ]
483
+ })
484
+ });
485
+ };
486
+
487
+ export { Login };