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
  {
2
2
  "name": "goobs-frontend",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "type": "module",
5
5
  "description": "A comprehensive React-based libary that extends the functionality of Material-UI",
6
6
  "license": "MIT",
@@ -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
- <Checkbox
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) => new Promise(resolve => setTimeout(resolve, ms))
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 unknown as HTMLInputElement
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 unknown as HTMLInputElement
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 unknown as HTMLInputElement
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
- ChangeEvent,
4
- KeyboardEvent,
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
- // Update both internal state and external handler
139
- setInternalValue(combinedValue)
140
- onChange?.(combinedValue)
141
- return newCode
142
- })
44
+ /** Custom success message to display */
45
+ successMessage?: string
143
46
 
144
- // Move focus to previous input
145
- setTimeout(() => {
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
- return {
168
- handleCodeChange,
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
- ...props
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
- 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
- }
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
- // Custom onChange handler that updates both internal state and calls the external onChange
214
- const handleChangeWithState = (newValue: string) => {
215
- // Set internal state only if it's changed
216
- if (internalValue !== newValue) {
217
- setInternalValue(newValue)
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
- // Call external onChange if provided
221
- if (onChange && newValue !== value) {
222
- onChange(newValue)
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
- const { handleCodeChange, handleKeyDown } = useCodeConfirmation({
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
- // 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()
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
- 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
- )
256
- }
257
- }, [])
152
+ // Convert back to string and remove trailing spaces
153
+ const newValue = newValueArr.join('').trimEnd()
258
154
 
259
- // Calculate container width based on number of inputs
260
- // Each input is 40px + 4px gap (MUI spacing 1) between them
261
- const inputAreaWidth = codeLength * 40 + (codeLength - 1) * 8 + 44 // Add 44px for the indicator dot and margin
155
+ // Update internal state and call onChange
156
+ setInternalValue(newValue)
157
+ onChange?.(newValue)
262
158
 
263
- // Calculate button container width - ensure minimum width for buttons
264
- // Each button should be at least 120px for good UX
265
- const minButtonWidth = 120
266
- // If only showing the verify button, we need less space
267
- const buttonContainerWidth = showSendResendButton
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
- // For manual changes (typing digits), also handle auto-focus next field
272
- const handleChange = (
273
- event: ChangeEvent<HTMLInputElement>,
165
+ // Handle key down events for navigation
166
+ const handleKeyDown = (
167
+ e: React.KeyboardEvent<HTMLInputElement>,
274
168
  index: number
275
169
  ) => {
276
- const inputValue = event.target.value.replace(/\D/g, '') // Only keep digits
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
- // 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
- }
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
- // Apply the change to state
286
- handleCodeChange(event, index)
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
- // Only move focus if there's a non-empty value and not the last input
289
- if (inputValue && inputValue.length > 0 && index < codeLength - 1) {
290
- // Focus next input
291
- setTimeout(() => {
292
- if (inputRefs.current && inputRefs.current[index + 1]) {
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
- }, 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()
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
- }, 0)
242
+ break
243
+ }
303
244
  }
304
245
  }
305
246
 
306
- const handleKeyDownWrapper = (
307
- event: KeyboardEvent<HTMLInputElement>,
247
+ // Handle paste event to distribute digits across inputs
248
+ const handlePaste = (
249
+ e: React.ClipboardEvent<HTMLInputElement>,
308
250
  index: number
309
251
  ) => {
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()
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
- // For other keys, use the normal handler
320
- handleKeyDown(event, index)
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
- // Safely create an array of string digits from internal state
324
- const digits: string[] = internalValue
325
- ? internalValue.split('')
326
- : Array.from({ length: codeLength }, () => '')
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
- digits.filter(digit => digit !== '' && digit !== undefined).length >=
331
- codeLength
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" gap={1}>
351
- {Array.from({ length: codeLength }, (_, index) => (
352
- <Input
353
- key={`code-input-${index}-${digits[index] || ''}`}
354
- name={`code${index + 1}`}
355
- value={digits[index] || ''}
356
- inputRef={(el: HTMLInputElement | null) => {
357
- inputRefs.current[index] = el
358
- }}
359
- inputProps={{
360
- maxLength: 1,
361
- pattern: '[0-9]*',
362
- inputMode: 'numeric',
363
- 'aria-label': `Code Digit ${index + 1}`,
364
- 'aria-required': ariaRequired,
365
- 'aria-invalid': ariaInvalid,
366
- 'data-testid': `code-input-${index + 1}`,
367
- 'data-index': index,
368
- }}
369
- sx={{
370
- border: '1px solid',
371
- borderColor: 'black',
372
- borderRadius: 1,
373
- width: 40,
374
- height: 50,
375
- input: {
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 Phone"
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: string
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
- ({ username, appName, size = 256, title, sx, onSecretGenerated }) => {
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
- encodeURIComponent(username),
39
- encodeURIComponent(appName),
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
- return authenticator.verify({ token, secret })
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
  /* -------------------------------------------------------------------------- */