goobs-frontend 0.9.13 → 0.9.15
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { Checkbox } from '@mui/material'
|
|
3
|
+
import { Checkbox, styled } from '@mui/material'
|
|
4
4
|
import React from 'react'
|
|
5
5
|
import * as palette from '../../styles/palette'
|
|
6
6
|
|
|
@@ -12,6 +12,40 @@ export interface CheckboxProps {
|
|
|
12
12
|
disabled?: boolean
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Create a styled version of MUI Checkbox with our custom styles
|
|
16
|
+
const StyledCheckbox = styled(Checkbox)(() => ({
|
|
17
|
+
padding: '8px',
|
|
18
|
+
'& .MuiSvgIcon-root': {
|
|
19
|
+
fontSize: '24px',
|
|
20
|
+
},
|
|
21
|
+
// Style for unchecked state
|
|
22
|
+
'&:not(.Mui-checked):not(.Mui-indeterminate):not(.Mui-disabled)': {
|
|
23
|
+
'& .MuiSvgIcon-root': {
|
|
24
|
+
color: 'transparent',
|
|
25
|
+
// Add a visible background to make it obvious it's a checkbox
|
|
26
|
+
backgroundColor: palette.grey.light,
|
|
27
|
+
border: `2px solid ${palette.marine.main}`,
|
|
28
|
+
borderRadius: '3px',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
'&.Mui-checked .MuiSvgIcon-root': {
|
|
32
|
+
color: palette.marine.main,
|
|
33
|
+
border: 'none',
|
|
34
|
+
},
|
|
35
|
+
'&.Mui-indeterminate .MuiSvgIcon-root': {
|
|
36
|
+
color: palette.marine.main,
|
|
37
|
+
border: 'none',
|
|
38
|
+
},
|
|
39
|
+
'&.Mui-disabled .MuiSvgIcon-root': {
|
|
40
|
+
color: palette.grey.main,
|
|
41
|
+
border: `2px solid ${palette.grey.main}`,
|
|
42
|
+
borderRadius: '3px',
|
|
43
|
+
},
|
|
44
|
+
'&:hover': {
|
|
45
|
+
backgroundColor: `${palette.marine.light}33`,
|
|
46
|
+
},
|
|
47
|
+
}))
|
|
48
|
+
|
|
15
49
|
function CustomCheckbox({
|
|
16
50
|
onClick,
|
|
17
51
|
checked,
|
|
@@ -35,28 +69,13 @@ function CustomCheckbox({
|
|
|
35
69
|
}
|
|
36
70
|
|
|
37
71
|
return (
|
|
38
|
-
<
|
|
39
|
-
sx={{
|
|
40
|
-
color: palette.marine.main,
|
|
41
|
-
'&.Mui-checked': {
|
|
42
|
-
color: palette.marine.main,
|
|
43
|
-
},
|
|
44
|
-
'&.Mui-indeterminate': {
|
|
45
|
-
color: palette.marine.main,
|
|
46
|
-
},
|
|
47
|
-
'&.Mui-disabled': {
|
|
48
|
-
color: palette.grey.main,
|
|
49
|
-
},
|
|
50
|
-
'&:hover': {
|
|
51
|
-
backgroundColor: palette.marine.light,
|
|
52
|
-
opacity: 0.1,
|
|
53
|
-
},
|
|
54
|
-
}}
|
|
72
|
+
<StyledCheckbox
|
|
55
73
|
checked={checked}
|
|
56
74
|
indeterminate={indeterminate}
|
|
57
75
|
onClick={handleClick}
|
|
58
76
|
onChange={handleChange}
|
|
59
77
|
disabled={disabled}
|
|
78
|
+
disableRipple
|
|
60
79
|
{...props}
|
|
61
80
|
/>
|
|
62
81
|
)
|
|
@@ -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,7 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
import React, {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
useState,
|
|
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, { useState, useEffect, FC, useRef } from 'react'
|
|
3
|
+
import { Box, Typography, styled } from '@mui/material'
|
|
4
|
+
import { CheckCircleOutline } from '@mui/icons-material'
|
|
12
5
|
import CustomButton, { CustomButtonProps } from '../Button'
|
|
13
6
|
|
|
14
7
|
export interface ConfirmationCodeInputsProps {
|
|
@@ -30,146 +23,54 @@ export interface ConfirmationCodeInputsProps {
|
|
|
30
23
|
/** Callback function for when the Send/Resend button is clicked */
|
|
31
24
|
onSendResend?: () => void | Promise<void>
|
|
32
25
|
|
|
26
|
+
/** Required callback function for when verification is disabled */
|
|
27
|
+
onDisableVerification: () => void | Promise<void>
|
|
28
|
+
|
|
33
29
|
/** Custom props for the Verify button */
|
|
34
30
|
verifyButtonProps?: Partial<CustomButtonProps>
|
|
35
31
|
|
|
36
32
|
/** Custom props for the Send/Resend button */
|
|
37
33
|
sendResendButtonProps?: Partial<CustomButtonProps>
|
|
38
34
|
|
|
35
|
+
/** Custom props for the Disable Verification button */
|
|
36
|
+
disableVerificationButtonProps?: Partial<CustomButtonProps>
|
|
37
|
+
|
|
39
38
|
/** Whether to show action buttons (verify and send/resend) */
|
|
40
39
|
showActionButtons?: boolean
|
|
41
40
|
|
|
42
41
|
/** Whether to show the Send/Resend button. If false, only the Verify button will be shown. */
|
|
43
42
|
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
43
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
onChange?.(combinedValue)
|
|
141
|
-
return newCode
|
|
142
|
-
})
|
|
44
|
+
/** Custom success message to display */
|
|
45
|
+
successMessage?: string
|
|
143
46
|
|
|
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
|
-
)
|
|
47
|
+
/** Whether to show the success state UI */
|
|
48
|
+
showSuccessState?: boolean
|
|
166
49
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
handleKeyDown,
|
|
170
|
-
}
|
|
50
|
+
/** Custom styling for the input fields */
|
|
51
|
+
inputStyle?: React.CSSProperties
|
|
171
52
|
}
|
|
172
53
|
|
|
54
|
+
// Custom styled input for verification code digits
|
|
55
|
+
const CodeInput = styled('input')(() => ({
|
|
56
|
+
width: '40px',
|
|
57
|
+
height: '50px',
|
|
58
|
+
padding: '0',
|
|
59
|
+
textAlign: 'center',
|
|
60
|
+
fontSize: '16px',
|
|
61
|
+
fontWeight: 'normal',
|
|
62
|
+
color: 'black',
|
|
63
|
+
backgroundColor: 'white',
|
|
64
|
+
border: '1px solid black',
|
|
65
|
+
borderRadius: '4px',
|
|
66
|
+
outline: 'none',
|
|
67
|
+
// The cursor is visible (not hiding with caretColor)
|
|
68
|
+
'&:focus': {
|
|
69
|
+
borderColor: 'black',
|
|
70
|
+
borderWidth: '2px',
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
|
|
173
74
|
const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
174
75
|
codeLength = 6,
|
|
175
76
|
isValid,
|
|
@@ -181,154 +82,255 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
181
82
|
codeSent = false,
|
|
182
83
|
onVerify,
|
|
183
84
|
onSendResend,
|
|
85
|
+
onDisableVerification,
|
|
184
86
|
verifyButtonProps = {},
|
|
185
87
|
sendResendButtonProps = {},
|
|
88
|
+
disableVerificationButtonProps = {},
|
|
186
89
|
showActionButtons = false,
|
|
187
90
|
showSendResendButton = true,
|
|
188
|
-
|
|
91
|
+
successMessage = 'Verification Successful',
|
|
92
|
+
showSuccessState = false,
|
|
93
|
+
inputStyle = {},
|
|
189
94
|
}) => {
|
|
190
95
|
// Initialize internal state with the value prop
|
|
191
96
|
const [internalValue, setInternalValue] = useState(value)
|
|
192
|
-
|
|
193
|
-
// Create refs for each input
|
|
194
|
-
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([])
|
|
97
|
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
|
195
98
|
|
|
196
99
|
// Initialize refs array
|
|
197
|
-
|
|
198
|
-
inputRefs.current =
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
inputRefs.current.push(null)
|
|
202
|
-
}
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
inputRefs.current = Array(codeLength).fill(
|
|
102
|
+
null
|
|
103
|
+
) as (HTMLInputElement | null)[]
|
|
203
104
|
}, [codeLength])
|
|
204
105
|
|
|
205
106
|
// Update internal state when value prop changes
|
|
206
107
|
useEffect(() => {
|
|
207
|
-
// Only update if the value actually changed to avoid infinite loops
|
|
208
108
|
if (internalValue !== value) {
|
|
209
109
|
setInternalValue(value)
|
|
210
110
|
}
|
|
211
111
|
}, [value, internalValue])
|
|
212
112
|
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
113
|
+
// Auto-focus first input field when component mounts
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
if (inputRefs.current[0] && internalValue.length === 0) {
|
|
117
|
+
inputRefs.current[0].focus()
|
|
118
|
+
}
|
|
119
|
+
}, 100)
|
|
120
|
+
return () => clearTimeout(timer)
|
|
121
|
+
}, [internalValue.length])
|
|
219
122
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
123
|
+
// Handle input change for a specific digit
|
|
124
|
+
const handleInputChange = (
|
|
125
|
+
e: React.ChangeEvent<HTMLInputElement>,
|
|
126
|
+
index: number
|
|
127
|
+
) => {
|
|
128
|
+
const target = e.target
|
|
129
|
+
const val = target.value
|
|
130
|
+
|
|
131
|
+
// Only accept numbers
|
|
132
|
+
if (!/^\d*$/.test(val)) {
|
|
133
|
+
return
|
|
223
134
|
}
|
|
224
|
-
}
|
|
225
135
|
|
|
226
|
-
|
|
227
|
-
codeLength,
|
|
228
|
-
onChange: handleChangeWithState,
|
|
229
|
-
inputRefs,
|
|
230
|
-
setInternalValue, // Pass setInternalValue to ensure direct updates
|
|
231
|
-
})
|
|
136
|
+
// Create a copy of the current value as an array of characters
|
|
137
|
+
let newValueArr = internalValue.padEnd(codeLength, '').split('')
|
|
232
138
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
)
|
|
240
|
-
if (activeIndex > 0) {
|
|
241
|
-
e.preventDefault()
|
|
242
|
-
inputRefs.current[activeIndex - 1]?.focus()
|
|
139
|
+
// If input has multiple characters (from paste), process them
|
|
140
|
+
if (val.length > 1) {
|
|
141
|
+
const digits = val.split('')
|
|
142
|
+
for (let i = 0; i < digits.length; i++) {
|
|
143
|
+
if (index + i < codeLength) {
|
|
144
|
+
newValueArr[index + i] = digits[i]
|
|
243
145
|
}
|
|
244
146
|
}
|
|
147
|
+
} else {
|
|
148
|
+
// Only replace the single character at the index
|
|
149
|
+
newValueArr[index] = val.charAt(val.length - 1)
|
|
245
150
|
}
|
|
246
151
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
handleGlobalKeyDown as unknown as EventListener
|
|
250
|
-
)
|
|
251
|
-
return () => {
|
|
252
|
-
document.removeEventListener(
|
|
253
|
-
'keydown',
|
|
254
|
-
handleGlobalKeyDown as unknown as EventListener
|
|
255
|
-
)
|
|
256
|
-
}
|
|
257
|
-
}, [])
|
|
152
|
+
// Convert back to string and remove trailing spaces
|
|
153
|
+
const newValue = newValueArr.join('').trimEnd()
|
|
258
154
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
155
|
+
// Update internal state and call onChange
|
|
156
|
+
setInternalValue(newValue)
|
|
157
|
+
onChange?.(newValue)
|
|
262
158
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
? Math.max(inputAreaWidth, minButtonWidth * 2 + 16) // 16px for gap between buttons
|
|
269
|
-
: Math.max(inputAreaWidth, minButtonWidth)
|
|
159
|
+
// Move focus to next input if we have a value and there's a next input
|
|
160
|
+
if (val && index < codeLength - 1) {
|
|
161
|
+
inputRefs.current[index + 1]?.focus()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
270
164
|
|
|
271
|
-
//
|
|
272
|
-
const
|
|
273
|
-
|
|
165
|
+
// Handle key down events for navigation
|
|
166
|
+
const handleKeyDown = (
|
|
167
|
+
e: React.KeyboardEvent<HTMLInputElement>,
|
|
274
168
|
index: number
|
|
275
169
|
) => {
|
|
276
|
-
const
|
|
170
|
+
const target = e.target as HTMLInputElement
|
|
171
|
+
|
|
172
|
+
switch (e.key) {
|
|
173
|
+
case 'Backspace': {
|
|
174
|
+
if (target.value === '') {
|
|
175
|
+
// If current field is empty and not the first field, move to previous field
|
|
176
|
+
if (index > 0) {
|
|
177
|
+
e.preventDefault()
|
|
178
|
+
inputRefs.current[index - 1]?.focus()
|
|
277
179
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
180
|
+
// Also clear the previous field if needed
|
|
181
|
+
const newValueArr = internalValue.split('')
|
|
182
|
+
newValueArr[index - 1] = ''
|
|
183
|
+
const newValue = newValueArr.join('').trimEnd()
|
|
184
|
+
setInternalValue(newValue)
|
|
185
|
+
onChange?.(newValue)
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
// Clear current field but don't move
|
|
189
|
+
const newValueArr = internalValue.split('')
|
|
190
|
+
newValueArr[index] = ''
|
|
191
|
+
const newValue = newValueArr.join('').trimEnd()
|
|
192
|
+
setInternalValue(newValue)
|
|
193
|
+
onChange?.(newValue)
|
|
194
|
+
}
|
|
195
|
+
break
|
|
196
|
+
}
|
|
284
197
|
|
|
285
|
-
|
|
286
|
-
|
|
198
|
+
case 'Delete': {
|
|
199
|
+
// Clear current field
|
|
200
|
+
const newValueArr = internalValue.split('')
|
|
201
|
+
newValueArr[index] = ''
|
|
202
|
+
const newValue = newValueArr.join('').trimEnd()
|
|
203
|
+
setInternalValue(newValue)
|
|
204
|
+
onChange?.(newValue)
|
|
205
|
+
break
|
|
206
|
+
}
|
|
287
207
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
208
|
+
case 'ArrowLeft':
|
|
209
|
+
// Move to previous input if exists
|
|
210
|
+
if (index > 0) {
|
|
211
|
+
e.preventDefault()
|
|
212
|
+
inputRefs.current[index - 1]?.focus()
|
|
213
|
+
}
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
case 'ArrowRight':
|
|
217
|
+
// Move to next input if exists
|
|
218
|
+
if (index < codeLength - 1) {
|
|
219
|
+
e.preventDefault()
|
|
293
220
|
inputRefs.current[index + 1]?.focus()
|
|
294
221
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
default: {
|
|
225
|
+
// For number keys, handle them directly
|
|
226
|
+
if (/^\d$/.test(e.key)) {
|
|
227
|
+
e.preventDefault()
|
|
228
|
+
|
|
229
|
+
// Update the value at this index
|
|
230
|
+
const newValueArr = internalValue.padEnd(codeLength, '').split('')
|
|
231
|
+
newValueArr[index] = e.key
|
|
232
|
+
const newValue = newValueArr.join('').trimEnd()
|
|
233
|
+
|
|
234
|
+
setInternalValue(newValue)
|
|
235
|
+
onChange?.(newValue)
|
|
236
|
+
|
|
237
|
+
// Move to next input if there's one
|
|
238
|
+
if (index < codeLength - 1) {
|
|
239
|
+
inputRefs.current[index + 1]?.focus()
|
|
240
|
+
}
|
|
301
241
|
}
|
|
302
|
-
|
|
242
|
+
break
|
|
243
|
+
}
|
|
303
244
|
}
|
|
304
245
|
}
|
|
305
246
|
|
|
306
|
-
|
|
307
|
-
|
|
247
|
+
// Handle paste event to distribute digits across inputs
|
|
248
|
+
const handlePaste = (
|
|
249
|
+
e: React.ClipboardEvent<HTMLInputElement>,
|
|
308
250
|
index: number
|
|
309
251
|
) => {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
252
|
+
e.preventDefault()
|
|
253
|
+
const pastedData = e.clipboardData.getData('text')
|
|
254
|
+
const digits = pastedData.replace(/\D/g, '').slice(0, codeLength - index)
|
|
255
|
+
|
|
256
|
+
if (digits) {
|
|
257
|
+
// Update the value from the current index onwards
|
|
258
|
+
const newValueArr = internalValue.padEnd(codeLength, '').split('')
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < digits.length; i++) {
|
|
261
|
+
if (index + i < codeLength) {
|
|
262
|
+
newValueArr[index + i] = digits[i]
|
|
263
|
+
}
|
|
315
264
|
}
|
|
316
|
-
return
|
|
317
|
-
}
|
|
318
265
|
|
|
319
|
-
|
|
320
|
-
|
|
266
|
+
const newValue = newValueArr.join('').trimEnd()
|
|
267
|
+
setInternalValue(newValue)
|
|
268
|
+
onChange?.(newValue)
|
|
269
|
+
|
|
270
|
+
// Focus the input after the last pasted digit or the last input
|
|
271
|
+
const focusIndex = Math.min(index + digits.length, codeLength - 1)
|
|
272
|
+
inputRefs.current[focusIndex]?.focus()
|
|
273
|
+
}
|
|
321
274
|
}
|
|
322
275
|
|
|
323
|
-
//
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
276
|
+
// Calculate container width based on number of inputs and spacing
|
|
277
|
+
const inputAreaWidth = codeLength * 40 + (codeLength - 1) * 8 + 44
|
|
278
|
+
|
|
279
|
+
// Calculate button container width
|
|
280
|
+
const minButtonWidth = 120
|
|
281
|
+
const buttonContainerWidth = showSendResendButton
|
|
282
|
+
? Math.max(inputAreaWidth, minButtonWidth * 2 + 16)
|
|
283
|
+
: Math.max(inputAreaWidth, minButtonWidth)
|
|
284
|
+
|
|
285
|
+
// Split the value into individual digits
|
|
286
|
+
const digits = internalValue.padEnd(codeLength, '').split('')
|
|
327
287
|
|
|
328
288
|
// Check if all code fields are filled
|
|
329
|
-
const allFieldsFilled =
|
|
330
|
-
|
|
331
|
-
|
|
289
|
+
const allFieldsFilled = internalValue.length >= codeLength
|
|
290
|
+
|
|
291
|
+
// If showing success state, render the success UI
|
|
292
|
+
if (showSuccessState) {
|
|
293
|
+
return (
|
|
294
|
+
<Box
|
|
295
|
+
display="flex"
|
|
296
|
+
flexDirection="column"
|
|
297
|
+
alignItems="center"
|
|
298
|
+
gap={2}
|
|
299
|
+
padding={3}
|
|
300
|
+
width="100%"
|
|
301
|
+
>
|
|
302
|
+
<CheckCircleOutline sx={{ fontSize: 60, color: 'green' }} />
|
|
303
|
+
<Typography variant="h5" align="center">
|
|
304
|
+
{successMessage}
|
|
305
|
+
</Typography>
|
|
306
|
+
<Box sx={{ display: 'flex', gap: 2, width: '100%' }}>
|
|
307
|
+
<CustomButton
|
|
308
|
+
text="Disable Verification"
|
|
309
|
+
fontcolor="white"
|
|
310
|
+
backgroundcolor="black"
|
|
311
|
+
width="100%"
|
|
312
|
+
height="40px"
|
|
313
|
+
variant="outlined"
|
|
314
|
+
{...disableVerificationButtonProps}
|
|
315
|
+
onClick={() => {
|
|
316
|
+
void onDisableVerification()
|
|
317
|
+
}}
|
|
318
|
+
/>
|
|
319
|
+
</Box>
|
|
320
|
+
</Box>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const statusIndicator = (
|
|
325
|
+
<Box
|
|
326
|
+
width={20}
|
|
327
|
+
height={20}
|
|
328
|
+
borderRadius="50%"
|
|
329
|
+
bgcolor={isValid ? 'green' : 'red'}
|
|
330
|
+
role="status"
|
|
331
|
+
aria-label={isValid ? 'Code is valid' : 'Code is invalid'}
|
|
332
|
+
/>
|
|
333
|
+
)
|
|
332
334
|
|
|
333
335
|
return (
|
|
334
336
|
<Box
|
|
@@ -347,61 +349,33 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
347
349
|
position="relative"
|
|
348
350
|
marginBottom={2}
|
|
349
351
|
>
|
|
350
|
-
<Box display="flex"
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
textAlign: 'center',
|
|
377
|
-
color: 'black',
|
|
378
|
-
},
|
|
379
|
-
}}
|
|
380
|
-
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
381
|
-
handleChange(event, index)
|
|
382
|
-
}
|
|
383
|
-
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) =>
|
|
384
|
-
handleKeyDownWrapper(event, index)
|
|
385
|
-
}
|
|
386
|
-
onFocus={() => {
|
|
387
|
-
inputRefs.current[index]?.focus()
|
|
388
|
-
}}
|
|
389
|
-
{...props}
|
|
390
|
-
/>
|
|
391
|
-
))}
|
|
352
|
+
<Box display="flex" alignItems="center" width="100%">
|
|
353
|
+
<Box display="flex" gap={1} width="100%">
|
|
354
|
+
{Array.from({ length: codeLength }).map((_, index) => (
|
|
355
|
+
<CodeInput
|
|
356
|
+
key={index}
|
|
357
|
+
ref={el => {
|
|
358
|
+
inputRefs.current[index] = el
|
|
359
|
+
}}
|
|
360
|
+
type="text"
|
|
361
|
+
inputMode="numeric"
|
|
362
|
+
pattern="[0-9]*"
|
|
363
|
+
maxLength={1}
|
|
364
|
+
value={digits[index] || ''}
|
|
365
|
+
onChange={e => handleInputChange(e, index)}
|
|
366
|
+
onKeyDown={e => handleKeyDown(e, index)}
|
|
367
|
+
onPaste={e => handlePaste(e, index)}
|
|
368
|
+
aria-label={`${ariaLabel || 'Confirmation Code'} digit ${index + 1}`}
|
|
369
|
+
aria-required={ariaRequired}
|
|
370
|
+
aria-invalid={ariaInvalid}
|
|
371
|
+
style={{
|
|
372
|
+
...inputStyle,
|
|
373
|
+
}}
|
|
374
|
+
/>
|
|
375
|
+
))}
|
|
376
|
+
</Box>
|
|
377
|
+
<Box ml={2}>{statusIndicator}</Box>
|
|
392
378
|
</Box>
|
|
393
|
-
|
|
394
|
-
<Box
|
|
395
|
-
width={20}
|
|
396
|
-
height={20}
|
|
397
|
-
borderRadius="50%"
|
|
398
|
-
bgcolor={isValid ? green.main : red.main}
|
|
399
|
-
position="static"
|
|
400
|
-
role="status"
|
|
401
|
-
aria-label={isValid ? 'Code is valid' : 'Code is invalid'}
|
|
402
|
-
alignSelf="center"
|
|
403
|
-
marginRight={2}
|
|
404
|
-
/>
|
|
405
379
|
</Box>
|
|
406
380
|
|
|
407
381
|
{showActionButtons && (
|
|
@@ -411,7 +385,6 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
411
385
|
width={`${buttonContainerWidth}px`}
|
|
412
386
|
marginTop={1}
|
|
413
387
|
sx={{
|
|
414
|
-
// Center the button container if it's wider than input area
|
|
415
388
|
marginLeft:
|
|
416
389
|
buttonContainerWidth > inputAreaWidth
|
|
417
390
|
? `${-(buttonContainerWidth - inputAreaWidth) / 2}px`
|
|
@@ -433,12 +406,11 @@ const ConfirmationCodeInputs: FC<ConfirmationCodeInputsProps> = ({
|
|
|
433
406
|
onClick={() => {
|
|
434
407
|
if (onSendResend) void onSendResend()
|
|
435
408
|
}}
|
|
436
|
-
// Send/Resend button is always enabled, preserve any custom disableButton setting
|
|
437
409
|
disableButton={sendResendButtonProps?.disableButton || 'false'}
|
|
438
410
|
/>
|
|
439
411
|
)}
|
|
440
412
|
<CustomButton
|
|
441
|
-
text="Verify
|
|
413
|
+
text="Verify"
|
|
442
414
|
fontcolor="white"
|
|
443
415
|
backgroundcolor="black"
|
|
444
416
|
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
|
}
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { RawSeverityLevel } from './components/ProjectBoard/types'
|
|
|
45
45
|
// Here is the new horizontal `Tabs` import
|
|
46
46
|
import Tabs, { TabsProps } from './components/Tabs'
|
|
47
47
|
import { Task } from './components/ProjectBoard/types'
|
|
48
|
+
import Checkbox, { CheckboxProps } from './components/Checkbox'
|
|
48
49
|
|
|
49
50
|
// New imports
|
|
50
51
|
import DateField, { DateFieldProps } from './components/Field/Date'
|
|
@@ -172,7 +173,7 @@ export { CompanyAddTaskCustomerDropdown }
|
|
|
172
173
|
export { CompanyAddTaskCustomerProvided }
|
|
173
174
|
export { CustomerAddTask }
|
|
174
175
|
export { NoUserAddTask }
|
|
175
|
-
|
|
176
|
+
export { Checkbox }
|
|
176
177
|
// New named exports
|
|
177
178
|
export { DateField }
|
|
178
179
|
export { Dropdown }
|
|
@@ -195,6 +196,7 @@ export type { SearchableDropdownProps }
|
|
|
195
196
|
export { ShowTask }
|
|
196
197
|
export type { Task }
|
|
197
198
|
export type { RawCustomer }
|
|
199
|
+
export type { CheckboxProps }
|
|
198
200
|
/* -------------------------------------------------------------------------- */
|
|
199
201
|
/* Named Type Exports */
|
|
200
202
|
/* -------------------------------------------------------------------------- */
|