goobs-frontend 0.9.13 → 0.9.14
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.
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { within, userEvent, expect, fireEvent } from '@storybook/test'
|
|
|
5
5
|
import ConfirmationCodeInputs from './index'
|
|
6
6
|
|
|
7
7
|
// Helper function to set input values directly without relying on userEvent.clear()
|
|
8
|
-
const setInputValue = (input: HTMLInputElement, value: string) => {
|
|
8
|
+
const setInputValue = (input: HTMLInputElement, value: string): void => {
|
|
9
9
|
// Use fireEvent directly which is more reliable in test environments
|
|
10
10
|
fireEvent.change(input, { target: { value } })
|
|
11
11
|
|
|
@@ -14,7 +14,8 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// Helper function to wait for a specific time
|
|
17
|
-
const sleep = (ms: number)
|
|
17
|
+
const sleep = (ms: number): Promise<void> =>
|
|
18
|
+
new Promise(resolve => setTimeout(resolve, ms))
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Configure Storybook metadata
|
|
@@ -35,7 +36,8 @@ const meta: Meta<typeof ConfirmationCodeInputs> = {
|
|
|
35
36
|
onVerify: { action: 'onVerify clicked' },
|
|
36
37
|
onSendResend: { action: 'onSendResend clicked' },
|
|
37
38
|
},
|
|
38
|
-
}
|
|
39
|
+
} as const
|
|
40
|
+
|
|
39
41
|
export default meta
|
|
40
42
|
|
|
41
43
|
type Story = StoryObj<typeof ConfirmationCodeInputs>
|
|
@@ -53,6 +55,7 @@ export const Basic: Story = {
|
|
|
53
55
|
isValid={false}
|
|
54
56
|
aria-label="Basic Confirmation Code"
|
|
55
57
|
showActionButtons={false}
|
|
58
|
+
onDisableVerification={() => {}}
|
|
56
59
|
/>
|
|
57
60
|
),
|
|
58
61
|
play: ({ canvasElement }) => {
|
|
@@ -85,6 +88,7 @@ export const PrefilledValue: Story = {
|
|
|
85
88
|
value="1234"
|
|
86
89
|
aria-label="Prefilled Confirmation Code"
|
|
87
90
|
showActionButtons={false}
|
|
91
|
+
onDisableVerification={() => {}}
|
|
88
92
|
/>
|
|
89
93
|
),
|
|
90
94
|
play: ({ canvasElement }) => {
|
|
@@ -120,6 +124,7 @@ export const ValidCode: Story = {
|
|
|
120
124
|
isValid={true}
|
|
121
125
|
aria-label="Valid Confirmation Code"
|
|
122
126
|
showActionButtons={false}
|
|
127
|
+
onDisableVerification={() => {}}
|
|
123
128
|
/>
|
|
124
129
|
),
|
|
125
130
|
play: ({ canvasElement }) => {
|
|
@@ -158,6 +163,7 @@ export const ManualTyping: Story = {
|
|
|
158
163
|
value=""
|
|
159
164
|
aria-label="Manual Code Entry"
|
|
160
165
|
showActionButtons={false}
|
|
166
|
+
onDisableVerification={() => {}}
|
|
161
167
|
/>
|
|
162
168
|
),
|
|
163
169
|
play: async ({ canvasElement, step }) => {
|
|
@@ -168,7 +174,7 @@ export const ManualTyping: Story = {
|
|
|
168
174
|
// Get each input element
|
|
169
175
|
const element = canvas.getByTestId(`code-input-${i + 1}`)
|
|
170
176
|
// Convert to HTMLInputElement for proper typing
|
|
171
|
-
return element as
|
|
177
|
+
return element as HTMLInputElement
|
|
172
178
|
})
|
|
173
179
|
|
|
174
180
|
// Step 1: Verify initial state
|
|
@@ -213,6 +219,7 @@ export const ArrowAndBackspace: Story = {
|
|
|
213
219
|
value=""
|
|
214
220
|
aria-label="Arrow Navigation Code"
|
|
215
221
|
showActionButtons={false}
|
|
222
|
+
onDisableVerification={() => {}}
|
|
216
223
|
/>
|
|
217
224
|
),
|
|
218
225
|
play: async ({ canvasElement, step }) => {
|
|
@@ -223,7 +230,7 @@ export const ArrowAndBackspace: Story = {
|
|
|
223
230
|
// Get each input element
|
|
224
231
|
const element = canvas.getByTestId(`code-input-${i + 1}`)
|
|
225
232
|
// Convert to HTMLInputElement for proper typing
|
|
226
|
-
return element as
|
|
233
|
+
return element as HTMLInputElement
|
|
227
234
|
})
|
|
228
235
|
|
|
229
236
|
// Use individual variables for clarity
|
|
@@ -281,6 +288,7 @@ export const WithSendCodeButton: Story = {
|
|
|
281
288
|
codeSent: false,
|
|
282
289
|
onVerify: () => console.log('Verify clicked'),
|
|
283
290
|
onSendResend: () => console.log('Send Code clicked'),
|
|
291
|
+
onDisableVerification: () => {},
|
|
284
292
|
},
|
|
285
293
|
play: async ({ canvasElement }) => {
|
|
286
294
|
const canvas = within(canvasElement)
|
|
@@ -312,6 +320,7 @@ export const WithResendCodeButton: Story = {
|
|
|
312
320
|
codeSent: true,
|
|
313
321
|
onVerify: () => console.log('Verify clicked'),
|
|
314
322
|
onSendResend: () => console.log('Resend Code clicked'),
|
|
323
|
+
onDisableVerification: () => {},
|
|
315
324
|
},
|
|
316
325
|
play: async ({ canvasElement }) => {
|
|
317
326
|
const canvas = within(canvasElement)
|
|
@@ -339,6 +348,7 @@ export const ValidCodeWithButtons: Story = {
|
|
|
339
348
|
codeSent: true,
|
|
340
349
|
onVerify: () => console.log('Verify clicked'),
|
|
341
350
|
onSendResend: () => console.log('Resend Code clicked'),
|
|
351
|
+
onDisableVerification: () => {},
|
|
342
352
|
},
|
|
343
353
|
play: async ({ canvasElement }) => {
|
|
344
354
|
const canvas = within(canvasElement)
|
|
@@ -384,6 +394,7 @@ export const WithCustomButtonProps: Story = {
|
|
|
384
394
|
backgroundcolor: 'green',
|
|
385
395
|
fontvariant: 'merrihelperfooter',
|
|
386
396
|
},
|
|
397
|
+
onDisableVerification: () => {},
|
|
387
398
|
},
|
|
388
399
|
play: async ({ canvasElement }) => {
|
|
389
400
|
const canvas = within(canvasElement)
|
|
@@ -417,6 +428,7 @@ export const ButtonBehaviorTest: Story = {
|
|
|
417
428
|
codeSent={false}
|
|
418
429
|
onVerify={() => console.log('Verify clicked')}
|
|
419
430
|
onSendResend={() => console.log('Send/Resend Code clicked')}
|
|
431
|
+
onDisableVerification={() => {}}
|
|
420
432
|
/>
|
|
421
433
|
),
|
|
422
434
|
play: async ({ canvasElement, step }) => {
|
|
@@ -449,7 +461,7 @@ export const ButtonBehaviorTest: Story = {
|
|
|
449
461
|
// Get all inputs by testId
|
|
450
462
|
const inputs = Array.from({ length: 4 }, (_, i) => {
|
|
451
463
|
const element = canvas.getByTestId(`code-input-${i + 1}`)
|
|
452
|
-
return element as
|
|
464
|
+
return element as HTMLInputElement
|
|
453
465
|
})
|
|
454
466
|
|
|
455
467
|
// Fill each input individually and force blur/change events
|
|
@@ -551,6 +563,7 @@ export const WithCodeSentFalse: Story = {
|
|
|
551
563
|
codeSent: false,
|
|
552
564
|
onVerify: () => console.log('Verify clicked'),
|
|
553
565
|
onSendResend: () => console.log('Send/Resend Code clicked'),
|
|
566
|
+
onDisableVerification: () => {},
|
|
554
567
|
},
|
|
555
568
|
play: ({ canvasElement }) => {
|
|
556
569
|
// Get buttons with direct DOM queries to be more reliable
|
|
@@ -579,6 +592,7 @@ export const WithCodeSentTrue: Story = {
|
|
|
579
592
|
codeSent: true, // This is the key difference
|
|
580
593
|
onVerify: () => console.log('Verify clicked'),
|
|
581
594
|
onSendResend: () => console.log('Send/Resend Code clicked'),
|
|
595
|
+
onDisableVerification: () => {},
|
|
582
596
|
},
|
|
583
597
|
play: ({ canvasElement }) => {
|
|
584
598
|
// Get buttons with direct DOM queries to be more reliable
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
import React, {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
useEffect,
|
|
7
|
-
useCallback,
|
|
8
|
-
FC,
|
|
9
|
-
} from 'react'
|
|
10
|
-
import { Input, Box } from '@mui/material'
|
|
11
|
-
import { red, green } from '../../styles/palette'
|
|
2
|
+
import React, { ChangeEvent, useState, useEffect, FC } from 'react'
|
|
3
|
+
import { Box, Typography } from '@mui/material'
|
|
4
|
+
import { CheckCircleOutline } from '@mui/icons-material'
|
|
5
|
+
import { red, grey } from '../../styles/palette'
|
|
12
6
|
import CustomButton, { CustomButtonProps } from '../Button'
|
|
13
7
|
|
|
14
8
|
export interface ConfirmationCodeInputsProps {
|
|
@@ -30,144 +24,29 @@ export interface ConfirmationCodeInputsProps {
|
|
|
30
24
|
/** Callback function for when the Send/Resend button is clicked */
|
|
31
25
|
onSendResend?: () => void | Promise<void>
|
|
32
26
|
|
|
27
|
+
/** Required callback function for when verification is disabled */
|
|
28
|
+
onDisableVerification: () => void | Promise<void>
|
|
29
|
+
|
|
33
30
|
/** Custom props for the Verify button */
|
|
34
31
|
verifyButtonProps?: Partial<CustomButtonProps>
|
|
35
32
|
|
|
36
33
|
/** Custom props for the Send/Resend button */
|
|
37
34
|
sendResendButtonProps?: Partial<CustomButtonProps>
|
|
38
35
|
|
|
36
|
+
/** Custom props for the Disable Verification button */
|
|
37
|
+
disableVerificationButtonProps?: Partial<CustomButtonProps>
|
|
38
|
+
|
|
39
39
|
/** Whether to show action buttons (verify and send/resend) */
|
|
40
40
|
showActionButtons?: boolean
|
|
41
41
|
|
|
42
42
|
/** Whether to show the Send/Resend button. If false, only the Verify button will be shown. */
|
|
43
43
|
showSendResendButton?: boolean
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface UseCodeConfirmationProps {
|
|
47
|
-
codeLength: number
|
|
48
|
-
onChange?: (value: string) => void
|
|
49
|
-
inputRefs: React.MutableRefObject<(HTMLInputElement | null)[]>
|
|
50
|
-
setInternalValue: React.Dispatch<React.SetStateAction<string>>
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const useCodeConfirmation = ({
|
|
54
|
-
codeLength,
|
|
55
|
-
onChange,
|
|
56
|
-
inputRefs,
|
|
57
|
-
setInternalValue,
|
|
58
|
-
}: UseCodeConfirmationProps) => {
|
|
59
|
-
const [code, setCode] = useState<Record<string, string>>(
|
|
60
|
-
Object.fromEntries(
|
|
61
|
-
Array.from({ length: codeLength }, (_, i) => [`code${i + 1}`, ''])
|
|
62
|
-
)
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
const handleCodeChange = useCallback(
|
|
66
|
-
(event: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
|
67
|
-
// Only keep digits
|
|
68
|
-
const value = event.target.value.replace(/\D/g, '')
|
|
69
|
-
|
|
70
|
-
// Handle the case when the input is cleared
|
|
71
|
-
if (value === '') {
|
|
72
|
-
// When clearing, we just update the code
|
|
73
|
-
setCode(prevCode => {
|
|
74
|
-
const newCode = {
|
|
75
|
-
...prevCode,
|
|
76
|
-
[`code${index + 1}`]: value,
|
|
77
|
-
}
|
|
78
|
-
// Combine all code pieces
|
|
79
|
-
const combinedValue = Object.values(newCode).join('')
|
|
80
|
-
|
|
81
|
-
// Update both internal state and external handler
|
|
82
|
-
setInternalValue(combinedValue)
|
|
83
|
-
onChange?.(combinedValue)
|
|
84
|
-
return newCode
|
|
85
|
-
})
|
|
86
|
-
return
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Handle direct value setting via typing
|
|
90
|
-
if (value.length <= 1) {
|
|
91
|
-
// Only process if it's a single digit or empty
|
|
92
|
-
setCode(prevCode => {
|
|
93
|
-
const newCode = {
|
|
94
|
-
...prevCode,
|
|
95
|
-
[`code${index + 1}`]: value,
|
|
96
|
-
}
|
|
97
|
-
// Combine all code pieces
|
|
98
|
-
const combinedValue = Object.values(newCode).join('')
|
|
99
|
-
|
|
100
|
-
// Update both internal state and external handler
|
|
101
|
-
setInternalValue(combinedValue)
|
|
102
|
-
onChange?.(combinedValue)
|
|
103
|
-
return newCode
|
|
104
|
-
})
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
[onChange, setInternalValue]
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
const handleKeyDown = useCallback(
|
|
111
|
-
(event: React.KeyboardEvent<HTMLInputElement>, index: number) => {
|
|
112
|
-
// Allow only numeric keys, navigation keys, and backspace
|
|
113
|
-
const allowedKeys = [
|
|
114
|
-
'Backspace',
|
|
115
|
-
'ArrowLeft',
|
|
116
|
-
'ArrowRight',
|
|
117
|
-
'Tab',
|
|
118
|
-
'Delete',
|
|
119
|
-
'Home',
|
|
120
|
-
'End',
|
|
121
|
-
]
|
|
122
|
-
|
|
123
|
-
if (!allowedKeys.includes(event.key) && !/^\d$/.test(event.key)) {
|
|
124
|
-
event.preventDefault()
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// If user pressed backspace on an empty input, move cursor to previous
|
|
129
|
-
if (event.key === 'Backspace' && !code[`code${index + 1}`] && index > 0) {
|
|
130
|
-
event.preventDefault()
|
|
131
|
-
setCode(prevCode => {
|
|
132
|
-
const newCode = {
|
|
133
|
-
...prevCode,
|
|
134
|
-
[`code${index}`]: '',
|
|
135
|
-
}
|
|
136
|
-
const combinedValue = Object.values(newCode).join('')
|
|
137
44
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
onChange?.(combinedValue)
|
|
141
|
-
return newCode
|
|
142
|
-
})
|
|
45
|
+
/** Custom success message to display */
|
|
46
|
+
successMessage?: string
|
|
143
47
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (inputRefs && inputRefs.current && inputRefs.current[index - 1]) {
|
|
147
|
-
inputRefs.current[index - 1]?.focus()
|
|
148
|
-
}
|
|
149
|
-
}, 0)
|
|
150
|
-
} else if (event.key === 'ArrowLeft' && index > 0) {
|
|
151
|
-
// Move focus left
|
|
152
|
-
event.preventDefault()
|
|
153
|
-
if (inputRefs && inputRefs.current && inputRefs.current[index - 1]) {
|
|
154
|
-
inputRefs.current[index - 1]?.focus()
|
|
155
|
-
}
|
|
156
|
-
} else if (event.key === 'ArrowRight' && index < codeLength - 1) {
|
|
157
|
-
// Move focus right
|
|
158
|
-
event.preventDefault()
|
|
159
|
-
if (inputRefs && inputRefs.current && inputRefs.current[index + 1]) {
|
|
160
|
-
inputRefs.current[index + 1]?.focus()
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
},
|
|
164
|
-
[code, codeLength, onChange, inputRefs, setInternalValue]
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
handleCodeChange,
|
|
169
|
-
handleKeyDown,
|
|
170
|
-
}
|
|
48
|
+
/** Whether to show the success state UI */
|
|
49
|
+
showSuccessState?: boolean
|
|
171
50
|
}
|
|
172
51
|
|
|
173
52
|
const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
@@ -181,155 +60,95 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
181
60
|
codeSent = false,
|
|
182
61
|
onVerify,
|
|
183
62
|
onSendResend,
|
|
63
|
+
onDisableVerification,
|
|
184
64
|
verifyButtonProps = {},
|
|
185
65
|
sendResendButtonProps = {},
|
|
66
|
+
disableVerificationButtonProps = {},
|
|
186
67
|
showActionButtons = false,
|
|
187
68
|
showSendResendButton = true,
|
|
69
|
+
successMessage = 'Verification Successful',
|
|
70
|
+
showSuccessState = false,
|
|
188
71
|
...props
|
|
189
72
|
}) => {
|
|
190
73
|
// Initialize internal state with the value prop
|
|
191
74
|
const [internalValue, setInternalValue] = useState(value)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
// Initialize refs array
|
|
197
|
-
React.useEffect(() => {
|
|
198
|
-
inputRefs.current = inputRefs.current.slice(0, codeLength)
|
|
199
|
-
// Fill with nulls if needed
|
|
200
|
-
while (inputRefs.current.length < codeLength) {
|
|
201
|
-
inputRefs.current.push(null)
|
|
202
|
-
}
|
|
203
|
-
}, [codeLength])
|
|
75
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
76
|
+
const [cursorPosition, setCursorPosition] = useState(value.length)
|
|
77
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
204
78
|
|
|
205
79
|
// Update internal state when value prop changes
|
|
206
80
|
useEffect(() => {
|
|
207
|
-
// Only update if the value actually changed to avoid infinite loops
|
|
208
81
|
if (internalValue !== value) {
|
|
209
82
|
setInternalValue(value)
|
|
83
|
+
setCursorPosition(value.length)
|
|
210
84
|
}
|
|
211
85
|
}, [value, internalValue])
|
|
212
86
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
87
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
88
|
+
// Only allow digits and limit to codeLength
|
|
89
|
+
const newValue = event.target.value.replace(/\D/g, '').slice(0, codeLength)
|
|
90
|
+
|
|
216
91
|
if (internalValue !== newValue) {
|
|
217
92
|
setInternalValue(newValue)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// Call external onChange if provided
|
|
221
|
-
if (onChange && newValue !== value) {
|
|
222
|
-
onChange(newValue)
|
|
93
|
+
setCursorPosition(event.target.selectionStart || newValue.length)
|
|
94
|
+
onChange?.(newValue)
|
|
223
95
|
}
|
|
224
96
|
}
|
|
225
97
|
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
inputRefs,
|
|
230
|
-
setInternalValue, // Pass setInternalValue to ensure direct updates
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
// Special document-level handler for left arrow
|
|
234
|
-
useEffect(() => {
|
|
235
|
-
const handleGlobalKeyDown = (e: KeyboardEvent): void => {
|
|
236
|
-
if (e.key === 'ArrowLeft') {
|
|
237
|
-
const activeIndex = inputRefs.current.findIndex(
|
|
238
|
-
ref => ref === document.activeElement
|
|
239
|
-
)
|
|
240
|
-
if (activeIndex > 0) {
|
|
241
|
-
e.preventDefault()
|
|
242
|
-
inputRefs.current[activeIndex - 1]?.focus()
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
document.addEventListener(
|
|
248
|
-
'keydown',
|
|
249
|
-
handleGlobalKeyDown as unknown as EventListener
|
|
250
|
-
)
|
|
251
|
-
return () => {
|
|
252
|
-
document.removeEventListener(
|
|
253
|
-
'keydown',
|
|
254
|
-
handleGlobalKeyDown as unknown as EventListener
|
|
255
|
-
)
|
|
98
|
+
const handleSelect = () => {
|
|
99
|
+
if (inputRef.current) {
|
|
100
|
+
setCursorPosition(inputRef.current.selectionStart || internalValue.length)
|
|
256
101
|
}
|
|
257
|
-
}
|
|
102
|
+
}
|
|
258
103
|
|
|
259
104
|
// Calculate container width based on number of inputs
|
|
260
|
-
|
|
261
|
-
const inputAreaWidth = codeLength * 40 + (codeLength - 1) * 8 + 44 // Add 44px for the indicator dot and margin
|
|
105
|
+
const inputAreaWidth = codeLength * 40 + (codeLength - 1) * 8 + 44
|
|
262
106
|
|
|
263
|
-
// Calculate button container width
|
|
264
|
-
// Each button should be at least 120px for good UX
|
|
107
|
+
// Calculate button container width
|
|
265
108
|
const minButtonWidth = 120
|
|
266
|
-
// If only showing the verify button, we need less space
|
|
267
109
|
const buttonContainerWidth = showSendResendButton
|
|
268
|
-
? Math.max(inputAreaWidth, minButtonWidth * 2 + 16)
|
|
110
|
+
? Math.max(inputAreaWidth, minButtonWidth * 2 + 16)
|
|
269
111
|
: Math.max(inputAreaWidth, minButtonWidth)
|
|
270
112
|
|
|
271
|
-
//
|
|
272
|
-
const
|
|
273
|
-
event: ChangeEvent<HTMLInputElement>,
|
|
274
|
-
index: number
|
|
275
|
-
) => {
|
|
276
|
-
const inputValue = event.target.value.replace(/\D/g, '') // Only keep digits
|
|
277
|
-
|
|
278
|
-
// Handle case when user types multiple digits at once
|
|
279
|
-
if (inputValue.length > 1) {
|
|
280
|
-
// Take only the last character typed
|
|
281
|
-
const lastChar = inputValue.charAt(inputValue.length - 1)
|
|
282
|
-
event.target.value = lastChar
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Apply the change to state
|
|
286
|
-
handleCodeChange(event, index)
|
|
113
|
+
// Split the value into individual digits for display
|
|
114
|
+
const digits = internalValue.padEnd(codeLength, ' ').split('')
|
|
287
115
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// Focus next input
|
|
291
|
-
setTimeout(() => {
|
|
292
|
-
if (inputRefs.current && inputRefs.current[index + 1]) {
|
|
293
|
-
inputRefs.current[index + 1]?.focus()
|
|
294
|
-
}
|
|
295
|
-
}, 0)
|
|
296
|
-
} else if (inputValue === '' && index > 0) {
|
|
297
|
-
// If input is cleared and not the first input, try to focus the previous input
|
|
298
|
-
setTimeout(() => {
|
|
299
|
-
if (inputRefs.current && inputRefs.current[index - 1]) {
|
|
300
|
-
inputRefs.current[index - 1]?.focus()
|
|
301
|
-
}
|
|
302
|
-
}, 0)
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const handleKeyDownWrapper = (
|
|
307
|
-
event: KeyboardEvent<HTMLInputElement>,
|
|
308
|
-
index: number
|
|
309
|
-
) => {
|
|
310
|
-
// Direct focus handling for left arrow
|
|
311
|
-
if (event.key === 'ArrowLeft' && index > 0) {
|
|
312
|
-
event.preventDefault()
|
|
313
|
-
if (inputRefs.current && inputRefs.current[index - 1]) {
|
|
314
|
-
inputRefs.current[index - 1]?.focus()
|
|
315
|
-
}
|
|
316
|
-
return
|
|
317
|
-
}
|
|
116
|
+
// Check if all code fields are filled
|
|
117
|
+
const allFieldsFilled = internalValue.length >= codeLength
|
|
318
118
|
|
|
319
|
-
|
|
320
|
-
|
|
119
|
+
// If showing success state, render the success UI
|
|
120
|
+
if (showSuccessState) {
|
|
121
|
+
return (
|
|
122
|
+
<Box
|
|
123
|
+
display="flex"
|
|
124
|
+
flexDirection="column"
|
|
125
|
+
alignItems="center"
|
|
126
|
+
gap={2}
|
|
127
|
+
padding={3}
|
|
128
|
+
width="100%"
|
|
129
|
+
>
|
|
130
|
+
<CheckCircleOutline sx={{ fontSize: 60, color: 'green' }} />
|
|
131
|
+
<Typography variant="h5" align="center">
|
|
132
|
+
{successMessage}
|
|
133
|
+
</Typography>
|
|
134
|
+
<Box sx={{ display: 'flex', gap: 2, width: '100%' }}>
|
|
135
|
+
<CustomButton
|
|
136
|
+
text="Disable Verification"
|
|
137
|
+
fontcolor="white"
|
|
138
|
+
backgroundcolor="black"
|
|
139
|
+
width="100%"
|
|
140
|
+
height="40px"
|
|
141
|
+
variant="outlined"
|
|
142
|
+
{...disableVerificationButtonProps}
|
|
143
|
+
onClick={() => {
|
|
144
|
+
void onDisableVerification()
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
</Box>
|
|
148
|
+
</Box>
|
|
149
|
+
)
|
|
321
150
|
}
|
|
322
151
|
|
|
323
|
-
// Safely create an array of string digits from internal state
|
|
324
|
-
const digits: string[] = internalValue
|
|
325
|
-
? internalValue.split('')
|
|
326
|
-
: Array.from({ length: codeLength }, () => '')
|
|
327
|
-
|
|
328
|
-
// Check if all code fields are filled
|
|
329
|
-
const allFieldsFilled =
|
|
330
|
-
digits.filter(digit => digit !== '' && digit !== undefined).length >=
|
|
331
|
-
codeLength
|
|
332
|
-
|
|
333
152
|
return (
|
|
334
153
|
<Box
|
|
335
154
|
display="flex"
|
|
@@ -347,47 +166,97 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
347
166
|
position="relative"
|
|
348
167
|
marginBottom={2}
|
|
349
168
|
>
|
|
350
|
-
<Box
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
169
|
+
<Box
|
|
170
|
+
display="flex"
|
|
171
|
+
gap={1}
|
|
172
|
+
sx={{
|
|
173
|
+
position: 'relative',
|
|
174
|
+
width: '100%',
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{/* Hidden input for actual value */}
|
|
178
|
+
<input
|
|
179
|
+
ref={inputRef}
|
|
180
|
+
type="text"
|
|
181
|
+
inputMode="numeric"
|
|
182
|
+
pattern="[0-9]*"
|
|
183
|
+
value={internalValue}
|
|
184
|
+
onChange={handleChange}
|
|
185
|
+
onFocus={() => setIsFocused(true)}
|
|
186
|
+
onBlur={() => setIsFocused(false)}
|
|
187
|
+
onSelect={handleSelect}
|
|
188
|
+
onKeyUp={handleSelect}
|
|
189
|
+
onMouseUp={handleSelect}
|
|
190
|
+
aria-label={ariaLabel || 'Confirmation Code'}
|
|
191
|
+
aria-required={ariaRequired}
|
|
192
|
+
aria-invalid={ariaInvalid}
|
|
193
|
+
style={{
|
|
194
|
+
position: 'absolute',
|
|
195
|
+
top: 0,
|
|
196
|
+
left: 0,
|
|
197
|
+
width: '100%',
|
|
198
|
+
height: '50px',
|
|
199
|
+
opacity: 0,
|
|
200
|
+
cursor: 'text',
|
|
201
|
+
fontSize: '16px',
|
|
202
|
+
letterSpacing: '39px',
|
|
203
|
+
paddingLeft: '15px',
|
|
204
|
+
zIndex: 1,
|
|
205
|
+
}}
|
|
206
|
+
{...props}
|
|
207
|
+
/>
|
|
208
|
+
|
|
209
|
+
{/* Visual segments */}
|
|
210
|
+
{digits.map((digit, index) => (
|
|
211
|
+
<Box
|
|
212
|
+
key={index}
|
|
369
213
|
sx={{
|
|
370
|
-
border: '1px solid',
|
|
371
|
-
borderColor: 'black',
|
|
214
|
+
border: '1px solid black',
|
|
372
215
|
borderRadius: 1,
|
|
373
216
|
width: 40,
|
|
374
217
|
height: 50,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
218
|
+
display: 'flex',
|
|
219
|
+
alignItems: 'center',
|
|
220
|
+
justifyContent: 'center',
|
|
221
|
+
color: 'black',
|
|
222
|
+
backgroundColor: 'white',
|
|
223
|
+
fontSize: '16px',
|
|
224
|
+
userSelect: 'none',
|
|
225
|
+
pointerEvents: 'none',
|
|
226
|
+
position: 'relative',
|
|
227
|
+
'&::after':
|
|
228
|
+
isFocused && index === cursorPosition
|
|
229
|
+
? {
|
|
230
|
+
content: '""',
|
|
231
|
+
position: 'absolute',
|
|
232
|
+
right:
|
|
233
|
+
index === cursorPosition && cursorPosition > 0
|
|
234
|
+
? '0'
|
|
235
|
+
: 'auto',
|
|
236
|
+
left:
|
|
237
|
+
index === cursorPosition && cursorPosition === 0
|
|
238
|
+
? '0'
|
|
239
|
+
: 'auto',
|
|
240
|
+
transform: 'none',
|
|
241
|
+
top: '15%',
|
|
242
|
+
height: '70%',
|
|
243
|
+
width: '1px',
|
|
244
|
+
backgroundColor: 'black',
|
|
245
|
+
animation: 'blink 1s step-end infinite',
|
|
246
|
+
}
|
|
247
|
+
: {},
|
|
248
|
+
'@keyframes blink': {
|
|
249
|
+
'from, to': {
|
|
250
|
+
opacity: 1,
|
|
251
|
+
},
|
|
252
|
+
'50%': {
|
|
253
|
+
opacity: 0,
|
|
254
|
+
},
|
|
378
255
|
},
|
|
379
256
|
}}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) =>
|
|
384
|
-
handleKeyDownWrapper(event, index)
|
|
385
|
-
}
|
|
386
|
-
onFocus={() => {
|
|
387
|
-
inputRefs.current[index]?.focus()
|
|
388
|
-
}}
|
|
389
|
-
{...props}
|
|
390
|
-
/>
|
|
257
|
+
>
|
|
258
|
+
{digit.trim()}
|
|
259
|
+
</Box>
|
|
391
260
|
))}
|
|
392
261
|
</Box>
|
|
393
262
|
|
|
@@ -395,7 +264,7 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
395
264
|
width={20}
|
|
396
265
|
height={20}
|
|
397
266
|
borderRadius="50%"
|
|
398
|
-
bgcolor={isValid ?
|
|
267
|
+
bgcolor={isValid ? grey.main : red.main}
|
|
399
268
|
position="static"
|
|
400
269
|
role="status"
|
|
401
270
|
aria-label={isValid ? 'Code is valid' : 'Code is invalid'}
|
|
@@ -411,7 +280,6 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
411
280
|
width={`${buttonContainerWidth}px`}
|
|
412
281
|
marginTop={1}
|
|
413
282
|
sx={{
|
|
414
|
-
// Center the button container if it's wider than input area
|
|
415
283
|
marginLeft:
|
|
416
284
|
buttonContainerWidth > inputAreaWidth
|
|
417
285
|
? `${-(buttonContainerWidth - inputAreaWidth) / 2}px`
|
|
@@ -433,12 +301,11 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
433
301
|
onClick={() => {
|
|
434
302
|
if (onSendResend) void onSendResend()
|
|
435
303
|
}}
|
|
436
|
-
// Send/Resend button is always enabled, preserve any custom disableButton setting
|
|
437
304
|
disableButton={sendResendButtonProps?.disableButton || 'false'}
|
|
438
305
|
/>
|
|
439
306
|
)}
|
|
440
307
|
<CustomButton
|
|
441
|
-
text="Verify
|
|
308
|
+
text="Verify"
|
|
442
309
|
fontcolor="white"
|
|
443
310
|
backgroundcolor="black"
|
|
444
311
|
width={
|
|
@@ -8,8 +8,8 @@ import Typography from '../Typography'
|
|
|
8
8
|
/**
|
|
9
9
|
* Props for the QRCodeComponent
|
|
10
10
|
* @typedef {Object} QRCodeProps
|
|
11
|
-
* @property {string} username - The username for the MFA setup
|
|
12
|
-
* @property {string} appName - The name of the application for MFA
|
|
11
|
+
* @property {string} username - The username/email for the MFA setup
|
|
12
|
+
* @property {string} [appName] - The name of the application for MFA (defaults to "ThothOS")
|
|
13
13
|
* @property {number} [size] - The size of the QR code in pixels
|
|
14
14
|
* @property {string} [title] - An optional title to display above the QR code
|
|
15
15
|
* @property {SxProps} [sx] - Custom styles to apply to the component
|
|
@@ -17,7 +17,7 @@ import Typography from '../Typography'
|
|
|
17
17
|
*/
|
|
18
18
|
export interface QRCodeProps {
|
|
19
19
|
username: string
|
|
20
|
-
appName
|
|
20
|
+
appName?: string
|
|
21
21
|
size?: number
|
|
22
22
|
title?: string
|
|
23
23
|
sx?: SxProps
|
|
@@ -30,13 +30,22 @@ export interface QRCodeProps {
|
|
|
30
30
|
* @returns {React.ReactElement} The rendered QR code component
|
|
31
31
|
*/
|
|
32
32
|
const QRCodeComponent: React.FC<QRCodeProps> = React.memo(
|
|
33
|
-
({
|
|
33
|
+
({
|
|
34
|
+
username,
|
|
35
|
+
appName = 'ThothOS',
|
|
36
|
+
size = 256,
|
|
37
|
+
title,
|
|
38
|
+
sx,
|
|
39
|
+
onSecretGenerated,
|
|
40
|
+
}) => {
|
|
34
41
|
// Generate the secret and OTP auth URL
|
|
35
42
|
const { secret, otpAuth } = useMemo(() => {
|
|
36
43
|
const generatedSecret = authenticator.generateSecret()
|
|
44
|
+
|
|
45
|
+
// We're using the raw username (likely email) directly instead of "your%20account"
|
|
37
46
|
const otpAuthUrl = authenticator.keyuri(
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
username, // Use the raw username/email without encoding
|
|
48
|
+
appName, // Now defaulting to "ThothOS"
|
|
40
49
|
generatedSecret
|
|
41
50
|
)
|
|
42
51
|
return { secret: generatedSecret, otpAuth: otpAuthUrl }
|
|
@@ -100,6 +109,14 @@ const QRCodeComponent: React.FC<QRCodeProps> = React.memo(
|
|
|
100
109
|
size={responsiveSize}
|
|
101
110
|
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
|
|
102
111
|
aria-label={`QR Code for ${title || 'MFA Setup'}`}
|
|
112
|
+
data-testid="mfa-qrcode"
|
|
113
|
+
/>
|
|
114
|
+
</Box>
|
|
115
|
+
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
|
116
|
+
<Typography
|
|
117
|
+
text={`${appName}: ${username}`}
|
|
118
|
+
fontvariant="merriparagraph"
|
|
119
|
+
align="center"
|
|
103
120
|
/>
|
|
104
121
|
</Box>
|
|
105
122
|
</Paper>
|
|
@@ -127,5 +144,17 @@ export function verifyMFAToken(token: string, secret: string): boolean {
|
|
|
127
144
|
throw new Error('Invalid secret')
|
|
128
145
|
}
|
|
129
146
|
|
|
130
|
-
|
|
147
|
+
try {
|
|
148
|
+
// Configure authenticator options to match Microsoft Authenticator
|
|
149
|
+
authenticator.options = {
|
|
150
|
+
window: 1, // Allow codes from 1 step before and after
|
|
151
|
+
digits: 6, // Microsoft Authenticator uses 6-digit codes
|
|
152
|
+
step: 30, // 30-second interval for code generation
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return authenticator.verify({ token, secret })
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error('MFA verification error:', error)
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
131
160
|
}
|