goobs-frontend 0.8.4 → 0.8.6

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,7 @@
1
1
  {
2
2
  "name": "goobs-frontend",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
+ "type": "module",
4
5
  "description": "A comprehensive React-based UI library built on Material-UI, offering a wide range of customizable components including grids, typography, buttons, cards, forms, navigation, pricing tables, steppers, tooltips, accordions, and more. Designed for building responsive and consistent user interfaces with advanced features like form validation, theming, and code syntax highlighting.",
5
6
  "license": "MIT",
6
7
  "main": "./src/index.ts",
@@ -21,31 +22,32 @@
21
22
  "@emotion/cache": "^11.13.1",
22
23
  "@emotion/react": "^11.13.3",
23
24
  "@emotion/styled": "^11.13.0",
24
- "@mui/icons-material": "^6.1.1",
25
- "@mui/material": "^6.1.1",
26
- "@types/lodash": "^4.17.9",
25
+ "@mui/icons-material": "^6.1.4",
26
+ "@mui/material": "^6.1.4",
27
+ "@types/lodash": "^4.17.12",
27
28
  "highlight.js": "^11.10.0",
28
- "jotai": "^2.10.0",
29
+ "jotai": "^2.10.1",
29
30
  "lodash": "^4.17.21",
30
- "next": "14.2.13",
31
- "react-datepicker": "^7.4.0",
31
+ "next": "15.0.0",
32
+ "otplib": "^12.0.1",
33
+ "react-datepicker": "^7.5.0",
32
34
  "react-qr-code": "^2.0.15"
33
35
  },
34
36
  "devDependencies": {
35
- "@next/eslint-plugin-next": "^14.2.13",
36
- "@types/node": "^22.7.3",
37
- "@types/react": "18.3.9",
38
- "@types/react-dom": "^18.3.0",
39
- "@typescript-eslint/eslint-plugin": "^8.7.0",
40
- "@typescript-eslint/parser": "^8.7.0",
41
- "eslint": "^9.11.1",
42
- "eslint-config-next": "^14.2.13",
37
+ "@next/eslint-plugin-next": "^15.0.0",
38
+ "@types/node": "^22.7.8",
39
+ "@types/react": "18.3.11",
40
+ "@types/react-dom": "^18.3.1",
41
+ "@typescript-eslint/eslint-plugin": "^8.11.0",
42
+ "@typescript-eslint/parser": "^8.11.0",
43
+ "eslint": "^9.13.0",
44
+ "eslint-config-next": "^15.0.0",
43
45
  "eslint-config-prettier": "^9.1.0",
44
46
  "eslint-plugin-prettier": "^5.2.1",
45
47
  "prettier": "^3.3.3",
46
48
  "react": "^18.3.1",
47
49
  "react-dom": "^18.3.1",
48
- "typescript": "^5.6.2"
50
+ "typescript": "^5.6.3"
49
51
  },
50
52
  "files": [
51
53
  "src"
@@ -19,7 +19,6 @@ export interface CustomButtonProps extends ButtonProps {
19
19
 
20
20
  const CustomButton: React.FC<CustomButtonProps> = React.memo(
21
21
  props => {
22
- console.log('[trace-button] CustomButton: Rendering component', { props })
23
22
  const {
24
23
  text,
25
24
  variant,
@@ -40,7 +39,6 @@ const CustomButton: React.FC<CustomButtonProps> = React.memo(
40
39
  const handleButtonClick = (
41
40
  event: React.MouseEvent<HTMLButtonElement>
42
41
  ): void => {
43
- console.log('[trace-button] CustomButton: Button clicked')
44
42
  event.preventDefault()
45
43
  onClick?.(event)
46
44
  }
@@ -63,13 +61,6 @@ const CustomButton: React.FC<CustomButtonProps> = React.memo(
63
61
  })
64
62
  : null
65
63
 
66
- console.log('[trace-button] CustomButton: Rendering button', {
67
- variant,
68
- style: buttonStyle,
69
- disableButton,
70
- isDisabled,
71
- })
72
-
73
64
  const buttonContent = (
74
65
  <>
75
66
  {iconlocation === 'above' && IconComponent}
@@ -140,15 +131,9 @@ const CustomButton: React.FC<CustomButtonProps> = React.memo(
140
131
  prevProps.iconlocation === nextProps.iconlocation &&
141
132
  prevProps.fontlocation === nextProps.fontlocation
142
133
 
143
- console.log('[trace-button] CustomButton: Props comparison', {
144
- propsAreEqual,
145
- prevProps: Object.keys(prevProps),
146
- nextProps: Object.keys(nextProps),
147
- })
148
134
  return propsAreEqual
149
135
  }
150
136
  )
151
137
 
152
138
  CustomButton.displayName = 'CustomButton'
153
- console.log('[trace-button] CustomButton: Component defined')
154
139
  export default CustomButton
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  import React, { useState } from 'react'
2
4
  import { Box, Paper, BoxProps } from '@mui/material'
3
5
  import Typography from '../../../../components/Typography'
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  import React, { useState } from 'react'
2
4
  import { Box, Paper, Switch } from '@mui/material'
3
5
  import { CardProps } from '../../index'
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  import React, { useRef, useEffect } from 'react'
2
4
  import { Box } from '@mui/material'
3
5
  import CustomButton from '../../components/Button'
@@ -6,7 +6,7 @@ type ExtendedColumnConfig = Omit<columnconfig, 'component'> & {
6
6
  component?: columnconfig['component']
7
7
  }
8
8
 
9
- export interface ExtendedQRCodeProps extends Omit<QRCodeProps, 'sx'> {
9
+ export interface ExtendedQRCodeProps extends QRCodeProps {
10
10
  columnconfig?: ExtendedColumnConfig
11
11
  cellconfig?: cellconfig
12
12
  }
@@ -21,9 +21,11 @@ const useQRCode = (grid: {
21
21
  index: number
22
22
  ): columnconfig => {
23
23
  const {
24
- value,
24
+ username,
25
+ appName,
25
26
  size,
26
27
  title,
28
+ onSecretGenerated,
27
29
  columnconfig: itemColumnConfig,
28
30
  cellconfig,
29
31
  ...restProps
@@ -48,9 +50,11 @@ const useQRCode = (grid: {
48
50
  component: (
49
51
  <QRCodeComponent
50
52
  key={`qrcode-${index}`}
51
- value={value}
53
+ username={username}
54
+ appName={appName}
52
55
  size={size}
53
56
  title={title}
57
+ onSecretGenerated={onSecretGenerated}
54
58
  {...restProps}
55
59
  />
56
60
  ),
@@ -1,4 +1,6 @@
1
- import React, { useState } from 'react'
1
+ 'use client'
2
+
3
+ import React, { useState, useEffect } from 'react'
2
4
  import {
3
5
  Select,
4
6
  MenuItem,
@@ -6,9 +8,12 @@ import {
6
8
  InputLabel,
7
9
  SelectProps,
8
10
  FormHelperText,
9
- Typography,
11
+ Box,
12
+ CircularProgress,
10
13
  } from '@mui/material'
11
14
  import { styled } from '@mui/material/styles'
15
+ import { black, white } from '../../styles/palette'
16
+ import Typography from '../Typography'
12
17
 
13
18
  export interface SimpleDropdownOption {
14
19
  value: string
@@ -38,37 +43,62 @@ export interface DropdownProps extends Omit<SelectProps, 'onChange'> {
38
43
  onFocus?: SelectProps['onFocus']
39
44
  }
40
45
 
46
+ const StyledBox = styled(Box)(() => ({
47
+ position: 'relative',
48
+ width: '100%',
49
+ height: '50px',
50
+ marginTop: '5px',
51
+ }))
52
+
41
53
  const StyledFormControl = styled(FormControl)<{
42
54
  backgroundcolor?: string
43
55
  outlinecolor?: string
44
- }>(({ theme, backgroundcolor, outlinecolor }) => ({
45
- width: '100%',
56
+ }>(({ outlinecolor }) => ({
57
+ position: 'absolute',
58
+ top: 0,
59
+ left: 0,
60
+ right: 0,
61
+ bottom: 0,
46
62
  '& .MuiOutlinedInput-root': {
47
- backgroundColor: backgroundcolor || theme.palette.background.paper,
63
+ backgroundColor: white.main,
64
+ height: '45px',
48
65
  '& fieldset': {
49
- borderColor: outlinecolor || theme.palette.primary.main,
66
+ borderColor: outlinecolor || black.main,
50
67
  },
51
68
  '&:hover fieldset': {
52
- borderColor: outlinecolor || theme.palette.primary.main,
69
+ borderColor: outlinecolor || black.main,
53
70
  },
54
71
  '&.Mui-focused fieldset': {
55
- borderColor: outlinecolor || theme.palette.primary.main,
72
+ borderColor: outlinecolor || black.main,
56
73
  },
57
74
  },
58
75
  }))
59
76
 
60
77
  const StyledInputLabel = styled(InputLabel)<{ shrunkfontcolor?: string }>(
61
- ({ theme, shrunkfontcolor }) => ({
78
+ () => ({
79
+ color: black.main,
62
80
  '&.Mui-focused': {
63
- color: shrunkfontcolor || theme.palette.primary.main,
81
+ color: black.main,
82
+ },
83
+ '&.MuiInputLabel-shrink': {
84
+ transform: 'translate(13px, -7px) scale(0.75)',
85
+ color: black.main,
64
86
  },
65
87
  })
66
88
  )
67
89
 
68
- const StyledMenuItem = styled(MenuItem)(({ theme }) => ({
90
+ const StyledMenuItem = styled(MenuItem)(() => ({
69
91
  display: 'flex',
70
92
  flexDirection: 'column',
71
93
  alignItems: 'flex-start',
94
+ backgroundColor: white.main,
95
+ }))
96
+
97
+ const LoadingContainer = styled(Box)(() => ({
98
+ display: 'flex',
99
+ justifyContent: 'center',
100
+ alignItems: 'center',
101
+ height: '50px',
72
102
  }))
73
103
 
74
104
  const capitalizeFirstLetter = (string: string) => {
@@ -92,23 +122,33 @@ const Dropdown: React.FC<DropdownProps> = ({
92
122
  onFocus,
93
123
  ...rest
94
124
  }) => {
95
- const [selectedValue, setSelectedValue] = useState(() => {
125
+ const [selectedValue, setSelectedValue] = useState<string>('')
126
+ const [isLoading, setIsLoading] = useState(true)
127
+
128
+ useEffect(() => {
96
129
  const defaultOption = options.find(option => option.value === defaultValue)
97
- return defaultOption ? defaultOption.value : ''
98
- })
130
+ setSelectedValue(defaultOption ? defaultOption.value : '')
131
+ setIsLoading(false)
132
+ }, [defaultValue, options])
99
133
 
100
134
  const handleChange: SelectProps['onChange'] = (event, child) => {
101
135
  const newValue = event.target.value as string
102
136
  setSelectedValue(newValue)
103
- onChange?.(event, child)
137
+ if (onChange) {
138
+ onChange(event, child)
139
+ }
104
140
  }
105
141
 
106
142
  const handleBlur: SelectProps['onBlur'] = event => {
107
- onBlur?.(event)
143
+ if (onBlur) {
144
+ onBlur(event)
145
+ }
108
146
  }
109
147
 
110
148
  const handleFocus: SelectProps['onFocus'] = event => {
111
- onFocus?.(event)
149
+ if (onFocus) {
150
+ onFocus(event)
151
+ }
112
152
  }
113
153
 
114
154
  const renderMenuItem = (option: DropdownOption) => {
@@ -116,48 +156,107 @@ const Dropdown: React.FC<DropdownProps> = ({
116
156
  if (!('attribute1' in option)) {
117
157
  return (
118
158
  <MenuItem key={option.value} value={option.value}>
119
- {label}
159
+ <Typography fontvariant="merriparagraph" text={label} />
120
160
  </MenuItem>
121
161
  )
122
162
  } else {
123
163
  return (
124
164
  <StyledMenuItem key={option.value} value={option.value}>
125
- <Typography variant="body1">{label}</Typography>
126
- <Typography variant="caption" color="textSecondary">
127
- {option.attribute1}
128
- {option.attribute2 && ` | ${option.attribute2}`}
129
- </Typography>
165
+ <Typography fontvariant="merriparagraph" text={label} />
166
+ <Typography
167
+ fontvariant="merriparagraph"
168
+ text={`${option.attribute1}${option.attribute2 ? ` | ${option.attribute2}` : ''}`}
169
+ fontcolor="textSecondary"
170
+ />
130
171
  </StyledMenuItem>
131
172
  )
132
173
  }
133
174
  }
134
175
 
176
+ if (isLoading) {
177
+ return (
178
+ <LoadingContainer>
179
+ <CircularProgress size={24} />
180
+ </LoadingContainer>
181
+ )
182
+ }
183
+
135
184
  return (
136
- <StyledFormControl
137
- backgroundcolor={backgroundcolor}
138
- outlinecolor={outlinecolor}
139
- error={error}
140
- required={required}
141
- >
142
- <StyledInputLabel id={`${name}-label`} shrunkfontcolor={shrunkfontcolor}>
143
- {label}
144
- </StyledInputLabel>
145
- <Select
146
- labelId={`${name}-label`}
147
- value={selectedValue}
148
- onChange={handleChange}
149
- onBlur={handleBlur}
150
- onFocus={handleFocus}
151
- label={label}
152
- sx={{ color: fontcolor }}
153
- name={name}
154
- aria-labelledby={`${name}-label`}
155
- {...rest}
185
+ <StyledBox>
186
+ <StyledFormControl
187
+ backgroundcolor={backgroundcolor}
188
+ outlinecolor={outlinecolor}
189
+ error={error}
190
+ required={required}
191
+ fullWidth
156
192
  >
157
- {options.map(renderMenuItem)}
158
- </Select>
159
- {helperText && <FormHelperText>{helperText}</FormHelperText>}
160
- </StyledFormControl>
193
+ <StyledInputLabel
194
+ id={`${name}-label`}
195
+ shrunkfontcolor={shrunkfontcolor}
196
+ >
197
+ {label}
198
+ </StyledInputLabel>
199
+ <Select
200
+ labelId={`${name}-label`}
201
+ value={selectedValue}
202
+ onChange={handleChange}
203
+ onBlur={handleBlur}
204
+ onFocus={handleFocus}
205
+ label={label}
206
+ sx={{
207
+ color: fontcolor || black.main,
208
+ height: '45px',
209
+ backgroundColor: white.main,
210
+ '& .MuiSelect-select': {
211
+ paddingTop: '10px',
212
+ paddingBottom: '10px',
213
+ },
214
+ '& .MuiSvgIcon-root': {
215
+ color: black.main,
216
+ },
217
+ '& .MuiOutlinedInput-notchedOutline': {
218
+ borderColor: black.main,
219
+ },
220
+ '&:hover .MuiOutlinedInput-notchedOutline': {
221
+ borderColor: black.main,
222
+ },
223
+ '&.Mui-focused .MuiOutlinedInput-notchedOutline': {
224
+ borderColor: black.main,
225
+ },
226
+ '& .MuiInputBase-input': {
227
+ color: black.main,
228
+ },
229
+ '& .MuiInputBase-input::placeholder': {
230
+ color: black.main,
231
+ opacity: 1,
232
+ },
233
+ '& .MuiPaper-root': {
234
+ backgroundColor: white.main,
235
+ },
236
+ '& .MuiMenu-list': {
237
+ backgroundColor: white.main,
238
+ },
239
+ }}
240
+ MenuProps={{
241
+ PaperProps: {
242
+ sx: {
243
+ backgroundColor: white.main,
244
+ },
245
+ },
246
+ }}
247
+ name={name}
248
+ aria-labelledby={`${name}-label`}
249
+ {...rest}
250
+ >
251
+ {options.map(renderMenuItem)}
252
+ </Select>
253
+ {helperText && (
254
+ <FormHelperText>
255
+ <Typography fontvariant="merriparagraph" text={helperText} />
256
+ </FormHelperText>
257
+ )}
258
+ </StyledFormControl>
259
+ </StyledBox>
161
260
  )
162
261
  }
163
262
 
@@ -247,7 +247,9 @@ const CustomGrid: React.FC<CustomGridProps> = ({
247
247
  : '100%',
248
248
  }}
249
249
  >
250
- {currentColumnConfig?.component || null}
250
+ {React.isValidElement(currentColumnConfig?.component)
251
+ ? currentColumnConfig?.component
252
+ : null}
251
253
  </Box>
252
254
  </Grid2>
253
255
  )
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  import React, { useState } from 'react'
2
4
  import { IconButton } from '@mui/material'
3
5
  import FavoriteIcon from '@mui/icons-material/Favorite'
@@ -2,78 +2,43 @@
2
2
  import React, { useState, useEffect } from 'react'
3
3
  import { Box, Tabs, Tab } from '@mui/material'
4
4
  import { NavProps, SubNav, View } from '../index'
5
+ import { usePathname } from 'next/navigation'
5
6
 
6
- /**
7
- * Represents the possible alignment options for the horizontal navigation.
8
- */
9
7
  type Alignment = 'left' | 'center' | 'right' | 'inherit' | 'justify'
10
8
 
11
- /**
12
- * Represents the structure of an active tab value.
13
- */
14
9
  export interface ActiveTabValue {
15
- /** The unique identifier of the active tab. */
16
- tabId: string
10
+ tabId: string | false
17
11
  }
18
12
 
19
- /**
20
- * Props for the HorizontalVariant component.
21
- */
22
13
  export interface HorizontalVariantProps {
23
- /** An array of navigation items, sub-navigation items, or views. */
24
14
  items: (NavProps | SubNav | View)[]
25
- /** The height of the navigation bar. Defaults to '80px'. */
26
15
  height?: string
27
- /** The alignment of the navigation items. Defaults to 'left'. */
28
16
  alignment?: Alignment
29
- /** A unique name for this navigation component. Used for state management. */
30
17
  navname?: string
31
18
  }
32
19
 
33
- /**
34
- * HorizontalVariant component that renders a horizontal navigation bar.
35
- * It supports dynamic tab management, routing, and custom click handlers.
36
- *
37
- * @param {HorizontalVariantProps} props - The props for the HorizontalVariant component.
38
- * @returns {JSX.Element} The rendered HorizontalVariant component.
39
- */
40
20
  function HorizontalVariant({
41
21
  items,
42
22
  height = '80px',
43
23
  alignment = 'left',
44
24
  navname = '',
45
25
  }: HorizontalVariantProps) {
46
- /**
47
- * State to keep track of active tab values for different navigation components.
48
- */
49
26
  const [activeTabValues, setActiveTabValues] = useState<
50
27
  Record<string, ActiveTabValue>
51
28
  >({})
29
+ const pathname = usePathname()
52
30
 
53
- /**
54
- * Effect hook to initialize the active tab values when the component mounts.
55
- */
56
31
  useEffect(() => {
57
- if (!activeTabValues[navname]) {
58
- const firstTab = items.find(item => 'orientation' in item) as
59
- | NavProps
60
- | undefined
61
- if (firstTab && firstTab.title) {
62
- setActiveTabValues(prev => ({
63
- ...prev,
64
- [navname]: { tabId: firstTab.title as string },
65
- }))
66
- }
67
- }
68
- }, [items, navname, activeTabValues])
32
+ const currentTab = items.find(
33
+ item => 'orientation' in item && item.route === pathname
34
+ ) as NavProps | undefined
35
+
36
+ setActiveTabValues(prev => ({
37
+ ...prev,
38
+ [navname]: { tabId: currentTab?.title || false },
39
+ }))
40
+ }, [items, navname, pathname])
69
41
 
70
- /**
71
- * Handles tab change events.
72
- * Updates the active tab values in the state.
73
- *
74
- * @param {React.SyntheticEvent} event - The event object.
75
- * @param {string} newValue - The new value of the selected tab.
76
- */
77
42
  const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
78
43
  setActiveTabValues(prev => ({
79
44
  ...prev,
@@ -81,12 +46,6 @@ function HorizontalVariant({
81
46
  }))
82
47
  }
83
48
 
84
- /**
85
- * Handles click events on individual tabs.
86
- * Supports different trigger types: route, onClick, and routeonhorizontal.
87
- *
88
- * @param {NavProps} tab - The tab object that was clicked.
89
- */
90
49
  const handleTabClick = (tab: NavProps) => {
91
50
  if (tab.trigger === 'route') {
92
51
  if (tab.route) {
@@ -116,7 +75,7 @@ function HorizontalVariant({
116
75
  }}
117
76
  >
118
77
  <Tabs
119
- value={activeTabValues[navname]?.tabId || ''}
78
+ value={activeTabValues[navname]?.tabId || false}
120
79
  onChange={handleTabChange}
121
80
  aria-label="nav tabs"
122
81
  sx={{
@@ -136,8 +95,8 @@ function HorizontalVariant({
136
95
  return (
137
96
  <Tab
138
97
  key={tab.title}
139
- value={tab.title}
140
- label={tab.title}
98
+ value={tab.title || ''}
99
+ label={tab.title || ''}
141
100
  onClick={() => handleTabClick(tab)}
142
101
  sx={{
143
102
  minHeight: 0,
@@ -1,6 +1,6 @@
1
1
  'use client'
2
- import React, { useState, useCallback, useMemo } from 'react'
3
- import { Box, Paper, SelectChangeEvent } from '@mui/material'
2
+ import React, { useState, useCallback, useMemo, useEffect } from 'react'
3
+ import { Box, Paper, SelectChangeEvent, CircularProgress } from '@mui/material'
4
4
  import InfoIcon from '@mui/icons-material/Info'
5
5
  import CheckCircleIcon from '@mui/icons-material/CheckCircle'
6
6
  import { Typography } from '../Typography'
@@ -72,17 +72,37 @@ export interface PricingProps {
72
72
  const PricingTable: React.FC<PricingProps> = props => {
73
73
  const router = useRouter()
74
74
  const [selectedPackageIndex, setSelectedPackageIndex] = useState(0)
75
+ const [selectedPackage, setSelectedPackage] = useState('')
76
+ const [isLoading, setIsLoading] = useState(true)
75
77
 
76
78
  const config = useMemo(() => {
77
79
  return { ...defaultConfig, ...props }
78
80
  }, [props])
79
81
 
82
+ useEffect(() => {
83
+ const timer = setTimeout(() => {
84
+ setIsLoading(false)
85
+ }, 100) // Simulating a 100ms loading time
86
+
87
+ return () => clearTimeout(timer)
88
+ }, [])
89
+
90
+ useEffect(() => {
91
+ if (
92
+ config.packagecolumns?.packagenames &&
93
+ config.packagecolumns.packagenames.length > 0
94
+ ) {
95
+ setSelectedPackage(config.packagecolumns.packagenames[0])
96
+ }
97
+ }, [config.packagecolumns?.packagenames])
98
+
80
99
  const handlePackageChange = useCallback(
81
100
  (event: SelectChangeEvent<unknown>) => {
82
101
  const newValue = event.target.value as string
83
102
  const newIndex =
84
103
  config.packagecolumns?.packagenames?.indexOf(newValue) ?? 0
85
104
  setSelectedPackageIndex(newIndex)
105
+ setSelectedPackage(newValue)
86
106
  console.log('Package selection changed to:', newValue)
87
107
  },
88
108
  [config.packagecolumns?.packagenames]
@@ -116,7 +136,7 @@ const PricingTable: React.FC<PricingProps> = props => {
116
136
  value: name,
117
137
  label: name,
118
138
  }))}
119
- defaultValue={config.packagecolumns.packagenames?.[0] || ''}
139
+ defaultValue={selectedPackage}
120
140
  backgroundcolor={semiTransparentBlack.main}
121
141
  outlinecolor={black.main}
122
142
  fontcolor={black.main}
@@ -276,10 +296,46 @@ const PricingTable: React.FC<PricingProps> = props => {
276
296
  }
277
297
 
278
298
  return { headerColumnConfigs, featureColumnConfigs }
279
- }, [config, selectedPackageIndex, router, handlePackageChange])
299
+ }, [
300
+ config,
301
+ selectedPackageIndex,
302
+ selectedPackage,
303
+ router,
304
+ handlePackageChange,
305
+ ])
280
306
 
281
307
  const { headerColumnConfigs, featureColumnConfigs } = renderColumnConfigs()
282
308
 
309
+ if (isLoading) {
310
+ return (
311
+ <CustomGrid
312
+ gridconfig={{
313
+ gridwidth: '100%',
314
+ alignment: 'center',
315
+ }}
316
+ columnconfig={[
317
+ {
318
+ row: 1,
319
+ column: 1,
320
+ alignment: 'center',
321
+ component: (
322
+ <Box
323
+ display="flex"
324
+ justifyContent="center"
325
+ alignItems="center"
326
+ height="350px"
327
+ width="100%"
328
+ overflow="auto"
329
+ >
330
+ <CircularProgress size={240} thickness={2} />
331
+ </Box>
332
+ ),
333
+ },
334
+ ]}
335
+ />
336
+ )
337
+ }
338
+
283
339
  return (
284
340
  <Paper
285
341
  elevation={1}
@@ -2,52 +2,58 @@ import React, { useMemo } from 'react'
2
2
  import QRCode from 'react-qr-code'
3
3
  import { Box, Typography, Paper, Theme, CircularProgress } from '@mui/material'
4
4
  import { SxProps } from '@mui/system'
5
+ import { authenticator } from 'otplib'
5
6
 
6
7
  /**
7
8
  * Props for the QRCodeComponent
8
9
  * @typedef {Object} QRCodeProps
9
- * @property {string} value - The value to be encoded in the QR code
10
+ * @property {string} username - The username for the MFA setup
11
+ * @property {string} appName - The name of the application for MFA
10
12
  * @property {number} [size] - The size of the QR code in pixels
11
13
  * @property {string} [title] - An optional title to display above the QR code
12
14
  * @property {SxProps<Theme>} [sx] - Custom styles to apply to the component
15
+ * @property {(secret: string) => void} [onSecretGenerated] - Callback function to receive the generated secret
13
16
  */
14
17
  export interface QRCodeProps {
15
- value: string
18
+ username: string
19
+ appName: string
16
20
  size?: number
17
21
  title?: string
18
22
  sx?: SxProps<Theme>
23
+ onSecretGenerated?: (secret: string) => void
19
24
  }
20
25
 
21
26
  /**
22
- * A component that displays a QR code with Material-UI styling
27
+ * A component that displays a QR code for MFA setup with Material-UI styling
23
28
  * @param {QRCodeProps} props - The props for the component
24
29
  * @returns {React.ReactElement} The rendered QR code component
25
30
  */
26
31
  const QRCodeComponent: React.FC<QRCodeProps> = React.memo(
27
- ({ value, size = 256, title, sx }) => {
28
- // Validate the QR code value
29
- const isValidValue = useMemo(() => {
30
- if (!value) return false
31
- try {
32
- // Check if the value is a valid URL
33
- new URL(value)
34
- return true
35
- } catch {
36
- // If not a URL, check if it's a non-empty string
37
- return typeof value === 'string' && value.trim().length > 0
32
+ ({ username, appName, size = 256, title, sx, onSecretGenerated }) => {
33
+ // Generate the secret and OTP auth URL
34
+ const { secret, otpAuth } = useMemo(() => {
35
+ const generatedSecret = authenticator.generateSecret()
36
+ const otpAuthUrl = authenticator.keyuri(
37
+ encodeURIComponent(username),
38
+ encodeURIComponent(appName),
39
+ generatedSecret
40
+ )
41
+ if (onSecretGenerated) {
42
+ onSecretGenerated(generatedSecret)
38
43
  }
39
- }, [value])
44
+ return { secret: generatedSecret, otpAuth: otpAuthUrl }
45
+ }, [username, appName, onSecretGenerated])
40
46
 
41
47
  // Calculate responsive size
42
48
  const responsiveSize = useMemo(() => {
43
49
  return Math.min(size, window.innerWidth - 32) // 32px for padding
44
50
  }, [size])
45
51
 
46
- if (!isValidValue) {
52
+ if (!otpAuth) {
47
53
  return (
48
54
  <Box sx={{ ...sx, p: 2 }} role="alert">
49
55
  <Typography color="error">
50
- Error: Invalid or empty QR code value
56
+ Error: Failed to generate QR code
51
57
  </Typography>
52
58
  </Box>
53
59
  )
@@ -88,13 +94,16 @@ const QRCodeComponent: React.FC<QRCodeProps> = React.memo(
88
94
  }
89
95
  >
90
96
  <QRCode
91
- value={value}
97
+ value={otpAuth}
92
98
  size={responsiveSize}
93
99
  style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
94
- aria-label={`QR Code for ${title || value}`}
100
+ aria-label={`QR Code for ${title || 'MFA Setup'}`}
95
101
  />
96
102
  </React.Suspense>
97
103
  </Box>
104
+ <Typography variant="body2" align="center" sx={{ mt: 2 }}>
105
+ Secret: {secret}
106
+ </Typography>
98
107
  </Paper>
99
108
  )
100
109
  }
@@ -103,3 +112,22 @@ const QRCodeComponent: React.FC<QRCodeProps> = React.memo(
103
112
  QRCodeComponent.displayName = 'QRCodeComponent'
104
113
 
105
114
  export default QRCodeComponent
115
+
116
+ /**
117
+ * Verifies a MFA token against a secret.
118
+ *
119
+ * @param token - The token to verify.
120
+ * @param secret - The secret key to verify against.
121
+ * @returns A boolean indicating whether the token is valid.
122
+ * @throws Error if inputs are invalid.
123
+ */
124
+ export function verifyMFAToken(token: string, secret: string): boolean {
125
+ if (!token || typeof token !== 'string') {
126
+ throw new Error('Invalid token')
127
+ }
128
+ if (!secret || typeof secret !== 'string') {
129
+ throw new Error('Invalid secret')
130
+ }
131
+
132
+ return authenticator.verify({ token, secret })
133
+ }