@digitaldefiance/express-suite-react-components 5.0.0 → 5.0.2

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.
Files changed (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -0
  3. package/package.json +3 -4
  4. package/src/auth/AdminRoute.tsx +54 -0
  5. package/src/auth/Private.tsx +17 -0
  6. package/src/auth/PrivateRoute.tsx +28 -0
  7. package/src/auth/UnAuth.tsx +16 -0
  8. package/src/auth/UnAuthRoute.tsx +30 -0
  9. package/src/auth/{index.d.ts → index.ts} +2 -3
  10. package/src/components/ApiAccess.tsx +175 -0
  11. package/src/components/BackupCodeLoginForm.tsx +489 -0
  12. package/src/components/BackupCodesForm.tsx +287 -0
  13. package/src/components/CategoryMenu.tsx +155 -0
  14. package/src/components/ChangePasswordForm.tsx +273 -0
  15. package/src/components/ConfirmationDialog.tsx +48 -0
  16. package/src/components/CurrencyCodeSelector.tsx +60 -0
  17. package/src/components/CurrencyInput.tsx +80 -0
  18. package/src/components/DashboardPage.tsx +24 -0
  19. package/src/components/DropdownMenu.tsx +105 -0
  20. package/src/components/ExpirationSecondsSelector.tsx +60 -0
  21. package/src/components/FakeEmailAdminPanel.tsx +374 -0
  22. package/src/components/Flag.tsx +52 -0
  23. package/src/components/ForgotPasswordForm.tsx +174 -0
  24. package/src/components/LoginForm.tsx +468 -0
  25. package/src/components/LogoutPage.tsx +21 -0
  26. package/src/components/RegisterForm.tsx +1169 -0
  27. package/src/components/ResetPasswordForm.tsx +247 -0
  28. package/src/components/SideMenu.tsx +46 -0
  29. package/src/components/SideMenuListItem.tsx +75 -0
  30. package/src/components/T.tsx +24 -0
  31. package/src/components/TDiv.tsx +26 -0
  32. package/src/components/TImgAlt.tsx +28 -0
  33. package/src/components/TopMenu.tsx +252 -0
  34. package/src/components/TotpSetupForm.tsx +188 -0
  35. package/src/components/TotpVerificationForm.tsx +148 -0
  36. package/src/components/TranslatedTitle.tsx +31 -0
  37. package/src/components/UserLanguageSelector.tsx +45 -0
  38. package/src/components/UserMenu.tsx +15 -0
  39. package/src/components/UserSettingsForm.tsx +904 -0
  40. package/src/components/VerifyEmailPage.tsx +222 -0
  41. package/src/components/__tests__/RegisterForm.totp.spec.tsx +377 -0
  42. package/src/components/__tests__/TotpSetupForm.spec.tsx +258 -0
  43. package/src/components/__tests__/TotpVerificationForm.spec.tsx +259 -0
  44. package/src/components/__tests__/UserSettingsForm.totp.spec.tsx +396 -0
  45. package/src/components/{index.d.ts → index.ts} +1 -1
  46. package/src/contexts/AuthProvider.spec.tsx +1284 -0
  47. package/src/contexts/AuthProvider.tsx +977 -0
  48. package/src/contexts/I18nProvider.tsx +164 -0
  49. package/src/contexts/MenuContext.tsx +426 -0
  50. package/src/contexts/SuiteConfigProvider.tsx +93 -0
  51. package/src/contexts/ThemeProvider.tsx +67 -0
  52. package/src/contexts/{index.d.ts → index.ts} +0 -1
  53. package/src/hooks/{index.d.ts → index.ts} +0 -1
  54. package/src/hooks/useAuthenticatedApi.tsx +67 -0
  55. package/src/hooks/useBackupCodes.ts +105 -0
  56. package/src/hooks/useEmailVerification.ts +54 -0
  57. package/src/hooks/useExpiringValue.ts +78 -0
  58. package/src/hooks/useLocalStorage.ts +18 -0
  59. package/src/hooks/useUserSettings.ts +271 -0
  60. package/src/{index.d.ts → index.ts} +1 -1
  61. package/src/interfaces/IAppConfig.ts +7 -0
  62. package/src/interfaces/ICategoryConfig.ts +41 -0
  63. package/src/interfaces/IMenuConfig.ts +28 -0
  64. package/src/interfaces/IMenuOption.ts +79 -0
  65. package/src/interfaces/{index.d.ts → index.ts} +0 -1
  66. package/src/services/__mocks__/authService.ts +14 -0
  67. package/src/services/__tests__/authService.totp.property.spec.ts +360 -0
  68. package/src/services/__tests__/authService.totp.spec.ts +439 -0
  69. package/src/services/api.ts +13 -0
  70. package/src/services/authService.ts +692 -0
  71. package/src/services/authenticatedApi.ts +17 -0
  72. package/src/services/index.ts +3 -0
  73. package/src/types/MenuType.ts +15 -0
  74. package/src/types/expirationSeconds.ts +18 -0
  75. package/src/types/index.ts +1 -0
  76. package/src/types/translation.ts +20 -0
  77. package/src/wrappers/BackupCodeLoginWrapper.tsx +34 -0
  78. package/src/wrappers/BackupCodesWrapper.tsx +28 -0
  79. package/src/wrappers/ChangePasswordFormWrapper.tsx +34 -0
  80. package/src/wrappers/ForgotPasswordFormWrapper.tsx +45 -0
  81. package/src/wrappers/LoginFormWrapper.tsx +108 -0
  82. package/src/wrappers/LogoutPageWrapper.tsx +30 -0
  83. package/src/wrappers/RegisterFormWrapper.tsx +109 -0
  84. package/src/wrappers/ResetPasswordFormWrapper.tsx +57 -0
  85. package/src/wrappers/UserSettingsFormWrapper.tsx +140 -0
  86. package/src/wrappers/VerifyEmailPageWrapper.tsx +27 -0
  87. package/src/wrappers/__tests__/LoginFormWrapper.totp.spec.tsx +339 -0
  88. package/src/wrappers/{index.d.ts → index.tsx} +10 -1
  89. package/src/auth/AdminRoute.d.ts +0 -8
  90. package/src/auth/AdminRoute.d.ts.map +0 -1
  91. package/src/auth/AdminRoute.js +0 -28
  92. package/src/auth/Private.d.ts +0 -6
  93. package/src/auth/Private.d.ts.map +0 -1
  94. package/src/auth/Private.js +0 -14
  95. package/src/auth/PrivateRoute.d.ts +0 -8
  96. package/src/auth/PrivateRoute.d.ts.map +0 -1
  97. package/src/auth/PrivateRoute.js +0 -23
  98. package/src/auth/UnAuth.d.ts +0 -6
  99. package/src/auth/UnAuth.d.ts.map +0 -1
  100. package/src/auth/UnAuth.js +0 -14
  101. package/src/auth/UnAuthRoute.d.ts +0 -8
  102. package/src/auth/UnAuthRoute.d.ts.map +0 -1
  103. package/src/auth/UnAuthRoute.js +0 -22
  104. package/src/auth/index.d.ts.map +0 -1
  105. package/src/auth/index.js +0 -12
  106. package/src/components/ApiAccess.d.ts +0 -16
  107. package/src/components/ApiAccess.d.ts.map +0 -1
  108. package/src/components/ApiAccess.js +0 -77
  109. package/src/components/BackupCodeLoginForm.d.ts +0 -43
  110. package/src/components/BackupCodeLoginForm.d.ts.map +0 -1
  111. package/src/components/BackupCodeLoginForm.js +0 -139
  112. package/src/components/BackupCodesForm.d.ts +0 -26
  113. package/src/components/BackupCodesForm.d.ts.map +0 -1
  114. package/src/components/BackupCodesForm.js +0 -120
  115. package/src/components/CategoryMenu.d.ts +0 -17
  116. package/src/components/CategoryMenu.d.ts.map +0 -1
  117. package/src/components/CategoryMenu.js +0 -65
  118. package/src/components/ChangePasswordForm.d.ts +0 -26
  119. package/src/components/ChangePasswordForm.d.ts.map +0 -1
  120. package/src/components/ChangePasswordForm.js +0 -78
  121. package/src/components/ConfirmationDialog.d.ts +0 -13
  122. package/src/components/ConfirmationDialog.d.ts.map +0 -1
  123. package/src/components/ConfirmationDialog.js +0 -10
  124. package/src/components/CurrencyCodeSelector.d.ts +0 -9
  125. package/src/components/CurrencyCodeSelector.d.ts.map +0 -1
  126. package/src/components/CurrencyCodeSelector.js +0 -31
  127. package/src/components/CurrencyInput.d.ts +0 -13
  128. package/src/components/CurrencyInput.d.ts.map +0 -1
  129. package/src/components/CurrencyInput.js +0 -22
  130. package/src/components/DashboardPage.d.ts +0 -8
  131. package/src/components/DashboardPage.d.ts.map +0 -1
  132. package/src/components/DashboardPage.js +0 -10
  133. package/src/components/DropdownMenu.d.ts +0 -13
  134. package/src/components/DropdownMenu.d.ts.map +0 -1
  135. package/src/components/DropdownMenu.js +0 -63
  136. package/src/components/ExpirationSecondsSelector.d.ts +0 -13
  137. package/src/components/ExpirationSecondsSelector.d.ts.map +0 -1
  138. package/src/components/ExpirationSecondsSelector.js +0 -32
  139. package/src/components/FakeEmailAdminPanel.d.ts +0 -23
  140. package/src/components/FakeEmailAdminPanel.d.ts.map +0 -1
  141. package/src/components/FakeEmailAdminPanel.js +0 -147
  142. package/src/components/Flag.d.ts +0 -20
  143. package/src/components/Flag.d.ts.map +0 -1
  144. package/src/components/Flag.js +0 -43
  145. package/src/components/ForgotPasswordForm.d.ts +0 -18
  146. package/src/components/ForgotPasswordForm.d.ts.map +0 -1
  147. package/src/components/ForgotPasswordForm.js +0 -61
  148. package/src/components/LoginForm.d.ts +0 -44
  149. package/src/components/LoginForm.d.ts.map +0 -1
  150. package/src/components/LoginForm.js +0 -123
  151. package/src/components/LogoutPage.d.ts +0 -8
  152. package/src/components/LogoutPage.d.ts.map +0 -1
  153. package/src/components/LogoutPage.js +0 -16
  154. package/src/components/RegisterForm.d.ts +0 -100
  155. package/src/components/RegisterForm.d.ts.map +0 -1
  156. package/src/components/RegisterForm.js +0 -306
  157. package/src/components/ResetPasswordForm.d.ts +0 -23
  158. package/src/components/ResetPasswordForm.d.ts.map +0 -1
  159. package/src/components/ResetPasswordForm.js +0 -78
  160. package/src/components/SideMenu.d.ts +0 -8
  161. package/src/components/SideMenu.d.ts.map +0 -1
  162. package/src/components/SideMenu.js +0 -25
  163. package/src/components/SideMenuListItem.d.ts +0 -13
  164. package/src/components/SideMenuListItem.d.ts.map +0 -1
  165. package/src/components/SideMenuListItem.js +0 -44
  166. package/src/components/T.d.ts +0 -7
  167. package/src/components/T.d.ts.map +0 -1
  168. package/src/components/T.js +0 -13
  169. package/src/components/TDiv.d.ts +0 -9
  170. package/src/components/TDiv.d.ts.map +0 -1
  171. package/src/components/TDiv.js +0 -14
  172. package/src/components/TImgAlt.d.ts +0 -10
  173. package/src/components/TImgAlt.d.ts.map +0 -1
  174. package/src/components/TImgAlt.js +0 -14
  175. package/src/components/TopMenu.d.ts +0 -50
  176. package/src/components/TopMenu.d.ts.map +0 -1
  177. package/src/components/TopMenu.js +0 -79
  178. package/src/components/TotpSetupForm.d.ts +0 -24
  179. package/src/components/TotpSetupForm.d.ts.map +0 -1
  180. package/src/components/TotpSetupForm.js +0 -82
  181. package/src/components/TotpVerificationForm.d.ts +0 -23
  182. package/src/components/TotpVerificationForm.d.ts.map +0 -1
  183. package/src/components/TotpVerificationForm.js +0 -66
  184. package/src/components/TranslatedTitle.d.ts +0 -8
  185. package/src/components/TranslatedTitle.d.ts.map +0 -1
  186. package/src/components/TranslatedTitle.js +0 -15
  187. package/src/components/UserLanguageSelector.d.ts +0 -4
  188. package/src/components/UserLanguageSelector.d.ts.map +0 -1
  189. package/src/components/UserLanguageSelector.js +0 -31
  190. package/src/components/UserMenu.d.ts +0 -4
  191. package/src/components/UserMenu.d.ts.map +0 -1
  192. package/src/components/UserMenu.js +0 -12
  193. package/src/components/UserSettingsForm.d.ts +0 -113
  194. package/src/components/UserSettingsForm.d.ts.map +0 -1
  195. package/src/components/UserSettingsForm.js +0 -269
  196. package/src/components/VerifyEmailPage.d.ts +0 -24
  197. package/src/components/VerifyEmailPage.d.ts.map +0 -1
  198. package/src/components/VerifyEmailPage.js +0 -81
  199. package/src/components/index.d.ts.map +0 -1
  200. package/src/components/index.js +0 -35
  201. package/src/contexts/AuthProvider.d.ts +0 -186
  202. package/src/contexts/AuthProvider.d.ts.map +0 -1
  203. package/src/contexts/AuthProvider.js +0 -515
  204. package/src/contexts/I18nProvider.d.ts +0 -27
  205. package/src/contexts/I18nProvider.d.ts.map +0 -1
  206. package/src/contexts/I18nProvider.js +0 -60
  207. package/src/contexts/MenuContext.d.ts +0 -20
  208. package/src/contexts/MenuContext.d.ts.map +0 -1
  209. package/src/contexts/MenuContext.js +0 -275
  210. package/src/contexts/SuiteConfigProvider.d.ts +0 -44
  211. package/src/contexts/SuiteConfigProvider.d.ts.map +0 -1
  212. package/src/contexts/SuiteConfigProvider.js +0 -43
  213. package/src/contexts/ThemeProvider.d.ts +0 -15
  214. package/src/contexts/ThemeProvider.d.ts.map +0 -1
  215. package/src/contexts/ThemeProvider.js +0 -36
  216. package/src/contexts/index.d.ts.map +0 -1
  217. package/src/contexts/index.js +0 -8
  218. package/src/hooks/index.d.ts.map +0 -1
  219. package/src/hooks/index.js +0 -9
  220. package/src/hooks/useAuthenticatedApi.d.ts +0 -34
  221. package/src/hooks/useAuthenticatedApi.d.ts.map +0 -1
  222. package/src/hooks/useAuthenticatedApi.js +0 -46
  223. package/src/hooks/useBackupCodes.d.ts +0 -15
  224. package/src/hooks/useBackupCodes.d.ts.map +0 -1
  225. package/src/hooks/useBackupCodes.js +0 -74
  226. package/src/hooks/useEmailVerification.d.ts +0 -10
  227. package/src/hooks/useEmailVerification.d.ts.map +0 -1
  228. package/src/hooks/useEmailVerification.js +0 -40
  229. package/src/hooks/useExpiringValue.d.ts +0 -14
  230. package/src/hooks/useExpiringValue.d.ts.map +0 -1
  231. package/src/hooks/useExpiringValue.js +0 -53
  232. package/src/hooks/useLocalStorage.d.ts +0 -2
  233. package/src/hooks/useLocalStorage.d.ts.map +0 -1
  234. package/src/hooks/useLocalStorage.js +0 -15
  235. package/src/hooks/useUserSettings.d.ts +0 -48
  236. package/src/hooks/useUserSettings.d.ts.map +0 -1
  237. package/src/hooks/useUserSettings.js +0 -171
  238. package/src/index.d.ts.map +0 -1
  239. package/src/index.js +0 -12
  240. package/src/interfaces/IAppConfig.d.ts +0 -8
  241. package/src/interfaces/IAppConfig.d.ts.map +0 -1
  242. package/src/interfaces/IAppConfig.js +0 -2
  243. package/src/interfaces/ICategoryConfig.d.ts +0 -41
  244. package/src/interfaces/ICategoryConfig.d.ts.map +0 -1
  245. package/src/interfaces/ICategoryConfig.js +0 -2
  246. package/src/interfaces/IMenuConfig.d.ts +0 -28
  247. package/src/interfaces/IMenuConfig.d.ts.map +0 -1
  248. package/src/interfaces/IMenuConfig.js +0 -2
  249. package/src/interfaces/IMenuOption.d.ts +0 -82
  250. package/src/interfaces/IMenuOption.d.ts.map +0 -1
  251. package/src/interfaces/IMenuOption.js +0 -2
  252. package/src/interfaces/index.d.ts.map +0 -1
  253. package/src/interfaces/index.js +0 -7
  254. package/src/services/__mocks__/authService.d.ts +0 -21
  255. package/src/services/__mocks__/authService.d.ts.map +0 -1
  256. package/src/services/__mocks__/authService.js +0 -15
  257. package/src/services/api.d.ts +0 -3
  258. package/src/services/api.d.ts.map +0 -1
  259. package/src/services/api.js +0 -14
  260. package/src/services/authService.d.ts +0 -126
  261. package/src/services/authService.d.ts.map +0 -1
  262. package/src/services/authService.js +0 -471
  263. package/src/services/authenticatedApi.d.ts +0 -3
  264. package/src/services/authenticatedApi.d.ts.map +0 -1
  265. package/src/services/authenticatedApi.js +0 -18
  266. package/src/services/index.d.ts +0 -4
  267. package/src/services/index.d.ts.map +0 -1
  268. package/src/services/index.js +0 -6
  269. package/src/types/MenuType.d.ts +0 -11
  270. package/src/types/MenuType.d.ts.map +0 -1
  271. package/src/types/MenuType.js +0 -12
  272. package/src/types/expirationSeconds.d.ts +0 -3
  273. package/src/types/expirationSeconds.d.ts.map +0 -1
  274. package/src/types/expirationSeconds.js +0 -17
  275. package/src/types/index.d.ts +0 -2
  276. package/src/types/index.d.ts.map +0 -1
  277. package/src/types/index.js +0 -4
  278. package/src/types/translation.d.ts +0 -10
  279. package/src/types/translation.d.ts.map +0 -1
  280. package/src/types/translation.js +0 -9
  281. package/src/wrappers/BackupCodeLoginWrapper.d.ts +0 -8
  282. package/src/wrappers/BackupCodeLoginWrapper.d.ts.map +0 -1
  283. package/src/wrappers/BackupCodeLoginWrapper.js +0 -20
  284. package/src/wrappers/BackupCodesWrapper.d.ts +0 -7
  285. package/src/wrappers/BackupCodesWrapper.d.ts.map +0 -1
  286. package/src/wrappers/BackupCodesWrapper.js +0 -17
  287. package/src/wrappers/ChangePasswordFormWrapper.d.ts +0 -8
  288. package/src/wrappers/ChangePasswordFormWrapper.d.ts.map +0 -1
  289. package/src/wrappers/ChangePasswordFormWrapper.js +0 -21
  290. package/src/wrappers/ForgotPasswordFormWrapper.d.ts +0 -8
  291. package/src/wrappers/ForgotPasswordFormWrapper.d.ts.map +0 -1
  292. package/src/wrappers/ForgotPasswordFormWrapper.js +0 -29
  293. package/src/wrappers/LoginFormWrapper.d.ts +0 -21
  294. package/src/wrappers/LoginFormWrapper.d.ts.map +0 -1
  295. package/src/wrappers/LoginFormWrapper.js +0 -72
  296. package/src/wrappers/LogoutPageWrapper.d.ts +0 -9
  297. package/src/wrappers/LogoutPageWrapper.d.ts.map +0 -1
  298. package/src/wrappers/LogoutPageWrapper.js +0 -21
  299. package/src/wrappers/RegisterFormWrapper.d.ts +0 -20
  300. package/src/wrappers/RegisterFormWrapper.d.ts.map +0 -1
  301. package/src/wrappers/RegisterFormWrapper.js +0 -51
  302. package/src/wrappers/ResetPasswordFormWrapper.d.ts +0 -9
  303. package/src/wrappers/ResetPasswordFormWrapper.d.ts.map +0 -1
  304. package/src/wrappers/ResetPasswordFormWrapper.js +0 -34
  305. package/src/wrappers/UserSettingsFormWrapper.d.ts +0 -29
  306. package/src/wrappers/UserSettingsFormWrapper.d.ts.map +0 -1
  307. package/src/wrappers/UserSettingsFormWrapper.js +0 -95
  308. package/src/wrappers/VerifyEmailPageWrapper.d.ts +0 -8
  309. package/src/wrappers/VerifyEmailPageWrapper.d.ts.map +0 -1
  310. package/src/wrappers/VerifyEmailPageWrapper.js +0 -21
  311. package/src/wrappers/index.d.ts.map +0 -1
  312. package/src/wrappers/index.js +0 -24
@@ -0,0 +1,904 @@
1
+ import { CurrencyCode } from '@digitaldefiance/i18n-lib';
2
+ import {
3
+ SuiteCoreComponentId,
4
+ SuiteCoreStringKey,
5
+ SuiteCoreStringKeyValue,
6
+ } from '@digitaldefiance/suite-core-lib';
7
+ import {
8
+ Alert,
9
+ Box,
10
+ Button,
11
+ Container,
12
+ Divider,
13
+ FormControl,
14
+ FormControlLabel,
15
+ InputLabel,
16
+ MenuItem,
17
+ Select,
18
+ Switch,
19
+ TextField,
20
+ Typography,
21
+ } from '@mui/material';
22
+ import { useFormik } from 'formik';
23
+ import moment from 'moment-timezone';
24
+ import { FC, useCallback, useEffect, useMemo, useState } from 'react';
25
+ import * as Yup from 'yup';
26
+ import { useI18n } from '../contexts';
27
+ import { Constants } from '@digitaldefiance/suite-core-lib';
28
+ import { TotpSetupForm } from './TotpSetupForm';
29
+
30
+ export interface UserSettingsFormValues {
31
+ email: string;
32
+ timezone: string;
33
+ siteLanguage: string;
34
+ currency: string;
35
+ darkMode: boolean;
36
+ directChallenge: boolean;
37
+ displayName?: string;
38
+ [key: string]: string | boolean | undefined;
39
+ }
40
+
41
+ export interface UserSettingsFormProps {
42
+ initialValues: UserSettingsFormValues;
43
+ onSubmit: (values: UserSettingsFormValues) => Promise<
44
+ | { success: boolean; message: string }
45
+ | {
46
+ error: string;
47
+ errorType?: string;
48
+ field?: string;
49
+ errors?: Array<{ path: string; msg: string }>;
50
+ }
51
+ >;
52
+ languages: Array<{ code: string; label: string }>;
53
+ emailValidation?: Yup.StringSchema;
54
+ timezoneValidation?: Yup.StringSchema;
55
+ siteLanguageValidation?: Yup.StringSchema;
56
+ currencyValidation?: Yup.StringSchema;
57
+ darkModeValidation?: Yup.BooleanSchema;
58
+ directChallengeValidation?: Yup.BooleanSchema;
59
+ displayNameValidation?: Yup.StringSchema;
60
+ additionalFields?: (
61
+ formik: ReturnType<typeof useFormik<UserSettingsFormValues>>
62
+ ) => React.ReactNode;
63
+ additionalInitialValues?: Record<string, string | boolean>;
64
+ additionalValidation?: Record<string, Yup.Schema>;
65
+ /**
66
+ * Current TOTP 2FA status. When undefined (not passed), no TOTP controls are rendered.
67
+ * When false, an "Enable 2FA" button is shown. When true, "Disable 2FA" and "Reset 2FA" buttons are shown.
68
+ */
69
+ totpEnabled?: boolean;
70
+ /**
71
+ * Called to initiate TOTP setup (e.g. AuthService.setupTotp()).
72
+ * Must return provisioning URI and secret on success, or an error.
73
+ */
74
+ onTotpSetup?: () => Promise<
75
+ | { provisioningUri: string; secret: string }
76
+ | { error: string }
77
+ >;
78
+ /**
79
+ * Called to confirm TOTP setup with a 6-digit code (e.g. AuthService.confirmTotp()).
80
+ */
81
+ onTotpConfirm?: (
82
+ code: string
83
+ ) => Promise<{ success: boolean } | { error: string }>;
84
+ /**
85
+ * Called to disable TOTP with a 6-digit code (e.g. AuthService.disableTotp()).
86
+ */
87
+ onTotpDisable?: (
88
+ code: string
89
+ ) => Promise<{ success: boolean } | { error: string }>;
90
+ /**
91
+ * Called to reset TOTP with a 6-digit code (e.g. AuthService.resetTotp()).
92
+ * Returns new provisioning URI and secret on success.
93
+ */
94
+ onTotpReset?: (
95
+ code: string
96
+ ) => Promise<
97
+ | { provisioningUri: string; secret: string }
98
+ | { error: string }
99
+ >;
100
+ labels?: {
101
+ title?: string;
102
+ email?: string;
103
+ emailHelper?: string;
104
+ timezone?: string;
105
+ siteLanguage?: string;
106
+ currency?: string;
107
+ darkMode?: string;
108
+ directChallenge?: string;
109
+ directChallengeHelper?: string;
110
+ displayName?: string;
111
+ saving?: string;
112
+ save?: string;
113
+ successMessage?: string;
114
+ totpSectionTitle?: string;
115
+ totpStatusEnabled?: string;
116
+ totpStatusDisabled?: string;
117
+ totpEnableButton?: string;
118
+ totpDisableButton?: string;
119
+ totpResetButton?: string;
120
+ totpDisableCodeLabel?: string;
121
+ totpDisableSubmitButton?: string;
122
+ totpResetCodeLabel?: string;
123
+ totpResetSubmitButton?: string;
124
+ totpCancelButton?: string;
125
+ totpError?: string;
126
+ };
127
+ }
128
+
129
+ export const UserSettingsForm: FC<UserSettingsFormProps> = ({
130
+ initialValues,
131
+ onSubmit,
132
+ languages,
133
+ emailValidation,
134
+ timezoneValidation,
135
+ siteLanguageValidation,
136
+ currencyValidation,
137
+ darkModeValidation,
138
+ directChallengeValidation,
139
+ displayNameValidation,
140
+ additionalFields,
141
+ additionalInitialValues = {},
142
+ additionalValidation = {},
143
+ totpEnabled,
144
+ onTotpSetup,
145
+ onTotpConfirm,
146
+ onTotpDisable,
147
+ onTotpReset,
148
+ labels = {},
149
+ }) => {
150
+ const { tComponent } = useI18n();
151
+ const [apiErrors, setApiErrors] = useState<Record<string, string>>({});
152
+ const [saving, setSaving] = useState(false);
153
+ const [successMessage, setSuccessMessage] = useState<string | null>(null);
154
+
155
+ // TOTP management state
156
+ const [totpStatus, setTotpStatus] = useState<boolean | undefined>(totpEnabled);
157
+
158
+ // Sync totpStatus when the totpEnabled prop transitions from undefined to a
159
+ // concrete value (i.e. after the wrapper fetches it asynchronously).
160
+ useEffect(() => {
161
+ if (totpEnabled !== undefined) {
162
+ setTotpStatus(totpEnabled);
163
+ }
164
+ }, [totpEnabled]);
165
+ const [showTotpSetup, setShowTotpSetup] = useState(false);
166
+ const [totpProvisioningUri, setTotpProvisioningUri] = useState<string | null>(null);
167
+ const [totpSecret, setTotpSecret] = useState<string | null>(null);
168
+ const [totpSetupLoading, setTotpSetupLoading] = useState(false);
169
+ const [totpError, setTotpError] = useState<string | null>(null);
170
+ const [showDisablePrompt, setShowDisablePrompt] = useState(false);
171
+ const [disableCode, setDisableCode] = useState('');
172
+ const [disableSubmitting, setDisableSubmitting] = useState(false);
173
+ const [showResetPrompt, setShowResetPrompt] = useState(false);
174
+ const [resetCode, setResetCode] = useState('');
175
+ const [resetSubmitting, setResetSubmitting] = useState(false);
176
+
177
+ const handleEnableTotp = useCallback(async () => {
178
+ if (!onTotpSetup) return;
179
+ setTotpSetupLoading(true);
180
+ setTotpError(null);
181
+ try {
182
+ const result = await onTotpSetup();
183
+ if ('error' in result) {
184
+ setTotpError(result.error);
185
+ } else {
186
+ setTotpProvisioningUri(result.provisioningUri);
187
+ setTotpSecret(result.secret);
188
+ setShowTotpSetup(true);
189
+ }
190
+ } catch {
191
+ setTotpError(labels.totpError || 'Failed to set up two-factor authentication');
192
+ } finally {
193
+ setTotpSetupLoading(false);
194
+ }
195
+ }, [onTotpSetup, labels.totpError]);
196
+
197
+ const handleTotpConfirm = useCallback(
198
+ async (code: string): Promise<{ success: boolean } | { error: string }> => {
199
+ if (!onTotpConfirm) {
200
+ return { error: 'TOTP confirmation is not available' };
201
+ }
202
+ const result = await onTotpConfirm(code);
203
+ if ('success' in result && result.success) {
204
+ setTotpStatus(true);
205
+ setShowTotpSetup(false);
206
+ setTotpProvisioningUri(null);
207
+ setTotpSecret(null);
208
+ }
209
+ return result;
210
+ },
211
+ [onTotpConfirm]
212
+ );
213
+
214
+ const handleDisableTotp = useCallback(async () => {
215
+ if (!onTotpDisable || !disableCode) return;
216
+ setDisableSubmitting(true);
217
+ setTotpError(null);
218
+ try {
219
+ const result = await onTotpDisable(disableCode);
220
+ if ('error' in result) {
221
+ setTotpError(result.error);
222
+ } else {
223
+ setTotpStatus(false);
224
+ setShowDisablePrompt(false);
225
+ setDisableCode('');
226
+ }
227
+ } catch {
228
+ setTotpError(labels.totpError || 'Failed to disable two-factor authentication');
229
+ } finally {
230
+ setDisableSubmitting(false);
231
+ }
232
+ }, [onTotpDisable, disableCode, labels.totpError]);
233
+
234
+ const handleResetTotp = useCallback(async () => {
235
+ if (!onTotpReset || !resetCode) return;
236
+ setResetSubmitting(true);
237
+ setTotpError(null);
238
+ try {
239
+ const result = await onTotpReset(resetCode);
240
+ if ('error' in result) {
241
+ setTotpError(result.error);
242
+ } else {
243
+ setTotpProvisioningUri(result.provisioningUri);
244
+ setTotpSecret(result.secret);
245
+ setShowResetPrompt(false);
246
+ setResetCode('');
247
+ setShowTotpSetup(true);
248
+ }
249
+ } catch {
250
+ setTotpError(labels.totpError || 'Failed to reset two-factor authentication');
251
+ } finally {
252
+ setResetSubmitting(false);
253
+ }
254
+ }, [onTotpReset, resetCode, labels.totpError]);
255
+
256
+ const handleCancelTotpAction = useCallback(() => {
257
+ setShowTotpSetup(false);
258
+ setShowDisablePrompt(false);
259
+ setShowResetPrompt(false);
260
+ setTotpProvisioningUri(null);
261
+ setTotpSecret(null);
262
+ setDisableCode('');
263
+ setResetCode('');
264
+ setTotpError(null);
265
+ }, []);
266
+
267
+ const timezones = useMemo(() => moment.tz.names(), []);
268
+ const currencies = useMemo(
269
+ () =>
270
+ CurrencyCode.getAllData().map((c) => ({
271
+ code: c.code,
272
+ label: `${c.code} - ${c.currency}`,
273
+ })),
274
+ []
275
+ );
276
+
277
+ const validation = {
278
+ email:
279
+ emailValidation ||
280
+ Yup.string()
281
+ .email(
282
+ tComponent<SuiteCoreStringKeyValue>(
283
+ SuiteCoreComponentId,
284
+ SuiteCoreStringKey.Validation_InvalidEmail
285
+ )
286
+ )
287
+ .required(
288
+ tComponent<SuiteCoreStringKeyValue>(
289
+ SuiteCoreComponentId,
290
+ SuiteCoreStringKey.Validation_Required
291
+ )
292
+ ),
293
+ timezone:
294
+ timezoneValidation ||
295
+ Yup.string()
296
+ .required(
297
+ tComponent<SuiteCoreStringKeyValue>(
298
+ SuiteCoreComponentId,
299
+ SuiteCoreStringKey.Validation_TimezoneRequired
300
+ )
301
+ )
302
+ .test(
303
+ 'valid-timezone',
304
+ tComponent<SuiteCoreStringKeyValue>(
305
+ SuiteCoreComponentId,
306
+ SuiteCoreStringKey.Validation_TimezoneInvalid
307
+ ),
308
+ (value) => !value || moment.tz.zone(value) !== null
309
+ ),
310
+ siteLanguage:
311
+ siteLanguageValidation ||
312
+ Yup.string().required(
313
+ tComponent<SuiteCoreStringKeyValue>(
314
+ SuiteCoreComponentId,
315
+ SuiteCoreStringKey.Validation_Required
316
+ )
317
+ ),
318
+ currency:
319
+ currencyValidation ||
320
+ Yup.string()
321
+ .required(
322
+ tComponent<SuiteCoreStringKeyValue>(
323
+ SuiteCoreComponentId,
324
+ SuiteCoreStringKey.Validation_Required
325
+ )
326
+ )
327
+ .test(
328
+ 'valid-currency',
329
+ tComponent<SuiteCoreStringKeyValue>(
330
+ SuiteCoreComponentId,
331
+ SuiteCoreStringKey.Validation_Required
332
+ ),
333
+ (value) => !value || CurrencyCode.isValid(value)
334
+ ),
335
+ darkMode:
336
+ darkModeValidation ||
337
+ Yup.boolean().required(
338
+ tComponent<SuiteCoreStringKeyValue>(
339
+ SuiteCoreComponentId,
340
+ SuiteCoreStringKey.Validation_Required
341
+ )
342
+ ),
343
+ directChallenge:
344
+ directChallengeValidation ||
345
+ Yup.boolean().required(
346
+ tComponent<SuiteCoreStringKeyValue>(
347
+ SuiteCoreComponentId,
348
+ SuiteCoreStringKey.Validation_Required
349
+ )
350
+ ),
351
+ ...(Constants.EnableDisplayName ? {
352
+ displayName: displayNameValidation ||
353
+ Yup.string()
354
+ .min(
355
+ Constants.DisplayNameMinLength,
356
+ tComponent<SuiteCoreStringKeyValue>(
357
+ SuiteCoreComponentId,
358
+ SuiteCoreStringKey.Validation_DisplayNameMinLengthTemplate
359
+ )
360
+ )
361
+ .max(
362
+ Constants.DisplayNameMaxLength,
363
+ tComponent<SuiteCoreStringKeyValue>(
364
+ SuiteCoreComponentId,
365
+ SuiteCoreStringKey.Validation_DisplayNameMaxLengthTemplate
366
+ )
367
+ )
368
+ .matches(
369
+ Constants.DisplayNameRegex,
370
+ tComponent<SuiteCoreStringKeyValue>(
371
+ SuiteCoreComponentId,
372
+ SuiteCoreStringKey.Validation_DisplayNameRegexErrorTemplate
373
+ )
374
+ ),
375
+ } : {}),
376
+ };
377
+
378
+ const formik = useFormik<UserSettingsFormValues>({
379
+ initialValues: {
380
+ ...initialValues,
381
+ ...additionalInitialValues,
382
+ },
383
+ enableReinitialize: true,
384
+ validationSchema: Yup.object({
385
+ email: validation.email,
386
+ timezone: validation.timezone,
387
+ siteLanguage: validation.siteLanguage,
388
+ currency: validation.currency,
389
+ darkMode: validation.darkMode,
390
+ directChallenge: validation.directChallenge,
391
+ ...(Constants.EnableDisplayName && validation.displayName ? { displayName: validation.displayName } : {}),
392
+ ...additionalValidation,
393
+ }),
394
+ onSubmit: async (values, { setSubmitting, setFieldError, setTouched }) => {
395
+ setSaving(true);
396
+ setSuccessMessage(null);
397
+ const result = await onSubmit(values);
398
+
399
+ if ('success' in result && result.success) {
400
+ setSuccessMessage(
401
+ result.message ||
402
+ labels.successMessage ||
403
+ tComponent<SuiteCoreStringKeyValue>(
404
+ SuiteCoreComponentId,
405
+ SuiteCoreStringKey.Settings_SaveSuccess
406
+ )
407
+ );
408
+ setApiErrors({});
409
+ } else {
410
+ const newApiErrors: Record<string, string> = {};
411
+ const fieldsToTouch: Record<string, boolean> = {};
412
+
413
+ if ('field' in result && result.field) {
414
+ setFieldError(result.field, result.error);
415
+ fieldsToTouch[result.field] = true;
416
+ }
417
+
418
+ if ('errors' in result && result.errors) {
419
+ result.errors.forEach((err) => {
420
+ if (err.path && err.msg) {
421
+ setFieldError(err.path, err.msg);
422
+ fieldsToTouch[err.path] = true;
423
+ }
424
+ });
425
+ }
426
+
427
+ if (
428
+ 'error' in result &&
429
+ result.error &&
430
+ !Object.keys(newApiErrors).length
431
+ ) {
432
+ newApiErrors.general = result.error;
433
+ }
434
+
435
+ setApiErrors(newApiErrors);
436
+ setTouched(fieldsToTouch, false);
437
+ }
438
+ setSubmitting(false);
439
+ setSaving(false);
440
+ },
441
+ });
442
+
443
+ return (
444
+ <Container maxWidth="sm">
445
+ <Box
446
+ sx={{
447
+ mt: 4,
448
+ display: 'flex',
449
+ flexDirection: 'column',
450
+ alignItems: 'center',
451
+ }}
452
+ >
453
+ <Typography variant="h4" component="h1" gutterBottom>
454
+ {labels.title ||
455
+ tComponent<SuiteCoreStringKeyValue>(
456
+ SuiteCoreComponentId,
457
+ SuiteCoreStringKey.Settings_Title
458
+ )}
459
+ </Typography>
460
+
461
+ <Box
462
+ component="form"
463
+ onSubmit={formik.handleSubmit}
464
+ sx={{ mt: 1, width: '100%' }}
465
+ >
466
+ <TextField
467
+ fullWidth
468
+ id="email"
469
+ name="email"
470
+ label={
471
+ labels.email ||
472
+ tComponent<SuiteCoreStringKeyValue>(
473
+ SuiteCoreComponentId,
474
+ SuiteCoreStringKey.Common_Email
475
+ )
476
+ }
477
+ value={formik.values.email}
478
+ onChange={formik.handleChange}
479
+ onBlur={formik.handleBlur}
480
+ error={Boolean(
481
+ formik.touched.email && (formik.errors.email || apiErrors.email)
482
+ )}
483
+ helperText={
484
+ (formik.touched.email &&
485
+ (formik.errors.email || apiErrors.email)) ||
486
+ labels.emailHelper ||
487
+ tComponent<SuiteCoreStringKeyValue>(
488
+ SuiteCoreComponentId,
489
+ SuiteCoreStringKey.Settings_EmailHelper
490
+ )
491
+ }
492
+ margin="normal"
493
+ />
494
+
495
+ {Constants.EnableDisplayName && (
496
+ <TextField
497
+ fullWidth
498
+ id="displayName"
499
+ name="displayName"
500
+ label={
501
+ labels.displayName ||
502
+ tComponent<SuiteCoreStringKeyValue>(
503
+ SuiteCoreComponentId,
504
+ SuiteCoreStringKey.Common_DisplayName
505
+ )
506
+ }
507
+ value={formik.values.displayName ?? ''}
508
+ onChange={formik.handleChange}
509
+ onBlur={formik.handleBlur}
510
+ error={Boolean(
511
+ formik.touched.displayName && (formik.errors.displayName || apiErrors.displayName)
512
+ )}
513
+ helperText={
514
+ formik.touched.displayName && (formik.errors.displayName || apiErrors.displayName)
515
+ }
516
+ margin="normal"
517
+ />
518
+ )}
519
+
520
+ <FormControl fullWidth margin="normal">
521
+ <InputLabel id="timezone-label">
522
+ {labels.timezone ||
523
+ tComponent<SuiteCoreStringKeyValue>(
524
+ SuiteCoreComponentId,
525
+ SuiteCoreStringKey.Common_Timezone
526
+ )}
527
+ </InputLabel>
528
+ <Select
529
+ labelId="timezone-label"
530
+ id="timezone"
531
+ name="timezone"
532
+ value={formik.values.timezone}
533
+ onChange={formik.handleChange}
534
+ onBlur={formik.handleBlur}
535
+ error={formik.touched.timezone && Boolean(formik.errors.timezone)}
536
+ label={
537
+ labels.timezone ||
538
+ tComponent<SuiteCoreStringKeyValue>(
539
+ SuiteCoreComponentId,
540
+ SuiteCoreStringKey.Common_Timezone
541
+ )
542
+ }
543
+ >
544
+ {timezones.map((tz) => (
545
+ <MenuItem key={tz} value={tz}>
546
+ {tz}
547
+ </MenuItem>
548
+ ))}
549
+ </Select>
550
+ {formik.touched.timezone &&
551
+ (formik.errors.timezone || apiErrors.timezone) && (
552
+ <Typography color="error" variant="caption">
553
+ {formik.errors.timezone || apiErrors.timezone}
554
+ </Typography>
555
+ )}
556
+ </FormControl>
557
+
558
+ <FormControl fullWidth margin="normal">
559
+ <InputLabel id="language-label">
560
+ {labels.siteLanguage ||
561
+ tComponent<SuiteCoreStringKeyValue>(
562
+ SuiteCoreComponentId,
563
+ SuiteCoreStringKey.Settings_SiteLanguage
564
+ )}
565
+ </InputLabel>
566
+ <Select
567
+ labelId="language-label"
568
+ id="siteLanguage"
569
+ name="siteLanguage"
570
+ value={formik.values.siteLanguage}
571
+ onChange={formik.handleChange}
572
+ onBlur={formik.handleBlur}
573
+ error={
574
+ formik.touched.siteLanguage &&
575
+ Boolean(formik.errors.siteLanguage)
576
+ }
577
+ label={
578
+ labels.siteLanguage ||
579
+ tComponent<SuiteCoreStringKeyValue>(
580
+ SuiteCoreComponentId,
581
+ SuiteCoreStringKey.Settings_SiteLanguage
582
+ )
583
+ }
584
+ >
585
+ {languages.map((lang) => (
586
+ <MenuItem key={lang.code} value={lang.code}>
587
+ {lang.label}
588
+ </MenuItem>
589
+ ))}
590
+ </Select>
591
+ {formik.touched.siteLanguage &&
592
+ (formik.errors.siteLanguage || apiErrors.siteLanguage) && (
593
+ <Typography color="error" variant="caption">
594
+ {formik.errors.siteLanguage || apiErrors.siteLanguage}
595
+ </Typography>
596
+ )}
597
+ </FormControl>
598
+
599
+ <FormControl fullWidth margin="normal">
600
+ <InputLabel id="currency-label">
601
+ {labels.currency ||
602
+ tComponent<SuiteCoreStringKeyValue>(
603
+ SuiteCoreComponentId,
604
+ SuiteCoreStringKey.Settings_Currency
605
+ )}
606
+ </InputLabel>
607
+ <Select
608
+ labelId="currency-label"
609
+ id="currency"
610
+ name="currency"
611
+ value={formik.values.currency}
612
+ onChange={formik.handleChange}
613
+ onBlur={formik.handleBlur}
614
+ error={formik.touched.currency && Boolean(formik.errors.currency)}
615
+ label={
616
+ labels.currency ||
617
+ tComponent<SuiteCoreStringKeyValue>(
618
+ SuiteCoreComponentId,
619
+ SuiteCoreStringKey.Settings_Currency
620
+ )
621
+ }
622
+ >
623
+ {currencies.map((curr) => (
624
+ <MenuItem key={curr.code} value={curr.code}>
625
+ {curr.label}
626
+ </MenuItem>
627
+ ))}
628
+ </Select>
629
+ {formik.touched.currency &&
630
+ (formik.errors.currency || apiErrors.currency) && (
631
+ <Typography color="error" variant="caption">
632
+ {formik.errors.currency || apiErrors.currency}
633
+ </Typography>
634
+ )}
635
+ </FormControl>
636
+
637
+ <FormControl fullWidth margin="normal">
638
+ <FormControlLabel
639
+ control={
640
+ <Switch
641
+ id="darkMode"
642
+ name="darkMode"
643
+ checked={formik.values.darkMode}
644
+ onChange={formik.handleChange}
645
+ />
646
+ }
647
+ label={
648
+ labels.darkMode ||
649
+ tComponent<SuiteCoreStringKeyValue>(
650
+ SuiteCoreComponentId,
651
+ SuiteCoreStringKey.Settings_DarkMode
652
+ )
653
+ }
654
+ />
655
+ </FormControl>
656
+
657
+ <FormControl fullWidth margin="normal">
658
+ <FormControlLabel
659
+ control={
660
+ <Switch
661
+ id="directChallenge"
662
+ name="directChallenge"
663
+ checked={formik.values.directChallenge}
664
+ onChange={formik.handleChange}
665
+ />
666
+ }
667
+ label={
668
+ labels.directChallenge ||
669
+ tComponent<SuiteCoreStringKeyValue>(
670
+ SuiteCoreComponentId,
671
+ SuiteCoreStringKey.Registration_DirectChallengeLabel
672
+ )
673
+ }
674
+ />
675
+ <Typography
676
+ variant="caption"
677
+ color="text.secondary"
678
+ sx={{ ml: 4, mt: -1 }}
679
+ >
680
+ {labels.directChallengeHelper ||
681
+ tComponent<SuiteCoreStringKeyValue>(
682
+ SuiteCoreComponentId,
683
+ SuiteCoreStringKey.Registration_DirectChallengeHelper
684
+ )}
685
+ </Typography>
686
+ </FormControl>
687
+
688
+ {additionalFields && additionalFields(formik)}
689
+
690
+ {apiErrors.general && (
691
+ <Alert severity="error" sx={{ mt: 2, mb: 2 }}>
692
+ {apiErrors.general}
693
+ </Alert>
694
+ )}
695
+
696
+ {successMessage && (
697
+ <Alert severity="success" sx={{ mt: 2, mb: 2 }}>
698
+ {successMessage}
699
+ </Alert>
700
+ )}
701
+
702
+ <Button
703
+ type="submit"
704
+ fullWidth
705
+ variant="contained"
706
+ color="primary"
707
+ sx={{ mt: 3, mb: 2 }}
708
+ disabled={formik.isSubmitting}
709
+ >
710
+ {saving
711
+ ? labels.saving ||
712
+ tComponent<SuiteCoreStringKeyValue>(
713
+ SuiteCoreComponentId,
714
+ SuiteCoreStringKey.Settings_Saving
715
+ )
716
+ : labels.save ||
717
+ tComponent<SuiteCoreStringKeyValue>(
718
+ SuiteCoreComponentId,
719
+ SuiteCoreStringKey.Settings_Save
720
+ )}
721
+ </Button>
722
+ </Box>
723
+
724
+ {/* TOTP Two-Factor Authentication Management Section — outside the form
725
+ to prevent TotpSetupForm's nested <form> from being swallowed by the
726
+ outer form (nested <form> elements are invalid HTML). */}
727
+ {totpStatus !== undefined && (
728
+ <>
729
+ <Divider sx={{ mt: 3, mb: 2 }} />
730
+ <Typography variant="h6" component="h2" gutterBottom>
731
+ {labels.totpSectionTitle || 'Two-Factor Authentication'}
732
+ </Typography>
733
+
734
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
735
+ {totpStatus
736
+ ? (labels.totpStatusEnabled || '2FA is currently enabled')
737
+ : (labels.totpStatusDisabled || '2FA is currently disabled')}
738
+ </Typography>
739
+
740
+ {totpError && (
741
+ <Alert severity="error" sx={{ mb: 2 }} role="alert">
742
+ {totpError}
743
+ </Alert>
744
+ )}
745
+
746
+ {/* Enable 2FA flow */}
747
+ {!totpStatus && !showTotpSetup && (
748
+ <Button
749
+ type="button"
750
+ variant="outlined"
751
+ onClick={handleEnableTotp}
752
+ disabled={totpSetupLoading}
753
+ data-testid="enable-totp-button"
754
+ >
755
+ {totpSetupLoading
756
+ ? 'Setting up...'
757
+ : (labels.totpEnableButton || 'Enable 2FA')}
758
+ </Button>
759
+ )}
760
+
761
+ {/* TotpSetupForm inline (for enable or reset flows) */}
762
+ {showTotpSetup && totpProvisioningUri && totpSecret && (
763
+ <Box sx={{ mt: 2 }}>
764
+ <TotpSetupForm
765
+ provisioningUri={totpProvisioningUri}
766
+ secret={totpSecret}
767
+ onConfirm={handleTotpConfirm}
768
+ />
769
+ <Box sx={{ textAlign: 'center', mt: 1 }}>
770
+ <Button
771
+ type="button"
772
+ variant="text"
773
+ onClick={handleCancelTotpAction}
774
+ data-testid="cancel-totp-setup-button"
775
+ >
776
+ {labels.totpCancelButton || 'Cancel'}
777
+ </Button>
778
+ </Box>
779
+ </Box>
780
+ )}
781
+
782
+ {/* Disable 2FA and Reset 2FA buttons */}
783
+ {totpStatus && !showTotpSetup && !showDisablePrompt && !showResetPrompt && (
784
+ <Box sx={{ display: 'flex', gap: 2 }}>
785
+ <Button
786
+ type="button"
787
+ variant="outlined"
788
+ color="error"
789
+ onClick={() => { setShowDisablePrompt(true); setTotpError(null); }}
790
+ data-testid="disable-totp-button"
791
+ >
792
+ {labels.totpDisableButton || 'Disable 2FA'}
793
+ </Button>
794
+ <Button
795
+ type="button"
796
+ variant="outlined"
797
+ onClick={() => { setShowResetPrompt(true); setTotpError(null); }}
798
+ data-testid="reset-totp-button"
799
+ >
800
+ {labels.totpResetButton || 'Reset 2FA'}
801
+ </Button>
802
+ </Box>
803
+ )}
804
+
805
+ {/* Disable 2FA code prompt */}
806
+ {showDisablePrompt && (
807
+ <Box sx={{ mt: 2 }}>
808
+ <Typography variant="body2" sx={{ mb: 1 }}>
809
+ Enter your current 2FA code to disable two-factor authentication:
810
+ </Typography>
811
+ <TextField
812
+ fullWidth
813
+ id="totp-disable-code"
814
+ name="totp-disable-code"
815
+ label={labels.totpDisableCodeLabel || 'TOTP Code'}
816
+ value={disableCode}
817
+ onChange={(e) => setDisableCode(e.target.value)}
818
+ inputProps={{
819
+ 'aria-label': labels.totpDisableCodeLabel || 'TOTP Code',
820
+ inputMode: 'numeric',
821
+ maxLength: 6,
822
+ pattern: '\\d{6}',
823
+ }}
824
+ autoComplete="one-time-code"
825
+ margin="normal"
826
+ />
827
+ <Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
828
+ <Button
829
+ type="button"
830
+ variant="contained"
831
+ color="error"
832
+ onClick={handleDisableTotp}
833
+ disabled={disableSubmitting || !/^\d{6}$/.test(disableCode)}
834
+ data-testid="disable-totp-submit-button"
835
+ >
836
+ {disableSubmitting
837
+ ? 'Disabling...'
838
+ : (labels.totpDisableSubmitButton || 'Confirm Disable')}
839
+ </Button>
840
+ <Button
841
+ type="button"
842
+ variant="text"
843
+ onClick={handleCancelTotpAction}
844
+ data-testid="cancel-disable-totp-button"
845
+ >
846
+ {labels.totpCancelButton || 'Cancel'}
847
+ </Button>
848
+ </Box>
849
+ </Box>
850
+ )}
851
+
852
+ {/* Reset 2FA code prompt */}
853
+ {showResetPrompt && (
854
+ <Box sx={{ mt: 2 }}>
855
+ <Typography variant="body2" sx={{ mb: 1 }}>
856
+ Enter your current 2FA code to reset two-factor authentication:
857
+ </Typography>
858
+ <TextField
859
+ fullWidth
860
+ id="totp-reset-code"
861
+ name="totp-reset-code"
862
+ label={labels.totpResetCodeLabel || 'TOTP Code'}
863
+ value={resetCode}
864
+ onChange={(e) => setResetCode(e.target.value)}
865
+ inputProps={{
866
+ 'aria-label': labels.totpResetCodeLabel || 'TOTP Code',
867
+ inputMode: 'numeric',
868
+ maxLength: 6,
869
+ pattern: '\\d{6}',
870
+ }}
871
+ autoComplete="one-time-code"
872
+ margin="normal"
873
+ />
874
+ <Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
875
+ <Button
876
+ type="button"
877
+ variant="contained"
878
+ onClick={handleResetTotp}
879
+ disabled={resetSubmitting || !/^\d{6}$/.test(resetCode)}
880
+ data-testid="reset-totp-submit-button"
881
+ >
882
+ {resetSubmitting
883
+ ? 'Resetting...'
884
+ : (labels.totpResetSubmitButton || 'Confirm Reset')}
885
+ </Button>
886
+ <Button
887
+ type="button"
888
+ variant="text"
889
+ onClick={handleCancelTotpAction}
890
+ data-testid="cancel-reset-totp-button"
891
+ >
892
+ {labels.totpCancelButton || 'Cancel'}
893
+ </Button>
894
+ </Box>
895
+ </Box>
896
+ )}
897
+ </>
898
+ )}
899
+ </Box>
900
+ </Container>
901
+ );
902
+ };
903
+
904
+ export default UserSettingsForm;