@carlonicora/nextjs-jsonapi 1.40.1 → 1.41.1

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 (165) hide show
  1. package/README.md +3 -3
  2. package/dist/AuthComponent-BuWc2C4g.d.ts +28 -0
  3. package/dist/AuthComponent-fLVGdvSr.d.mts +28 -0
  4. package/dist/{BlockNoteEditor-EKY4AHVK.mjs → BlockNoteEditor-B3RQ4VQ7.mjs} +5 -5
  5. package/dist/{BlockNoteEditor-4G3L3LSF.js → BlockNoteEditor-VUAWVZF4.js} +15 -15
  6. package/dist/{BlockNoteEditor-4G3L3LSF.js.map → BlockNoteEditor-VUAWVZF4.js.map} +1 -1
  7. package/dist/JsonApiRequest-MUPAO7DI.js +24 -0
  8. package/dist/{JsonApiRequest-GR3L56A5.js.map → JsonApiRequest-MUPAO7DI.js.map} +1 -1
  9. package/dist/{JsonApiRequest-K5BRU7RE.mjs → JsonApiRequest-XCQHVVYD.mjs} +2 -2
  10. package/dist/auth.interface-8XglqHir.d.mts +33 -0
  11. package/dist/auth.interface-BJGKQ0zr.d.ts +33 -0
  12. package/dist/billing/index.js +409 -415
  13. package/dist/billing/index.js.map +1 -1
  14. package/dist/billing/index.mjs +4 -10
  15. package/dist/billing/index.mjs.map +1 -1
  16. package/dist/{chunk-BAOP6PTD.mjs → chunk-BJNQZGMN.mjs} +1618 -666
  17. package/dist/chunk-BJNQZGMN.mjs.map +1 -0
  18. package/dist/{chunk-U4MTVHOC.mjs → chunk-GCQUTWZ2.mjs} +11 -4
  19. package/dist/{chunk-U4MTVHOC.mjs.map → chunk-GCQUTWZ2.mjs.map} +1 -1
  20. package/dist/{chunk-ZNGEVB5M.js → chunk-L5F5ZN5F.js} +960 -140
  21. package/dist/chunk-L5F5ZN5F.js.map +1 -0
  22. package/dist/{chunk-RRIYLEY6.mjs → chunk-LBIC4GJK.mjs} +2 -2
  23. package/dist/{chunk-T5YYOT4Z.js → chunk-OODZEX6P.js} +3 -3
  24. package/dist/{chunk-T5YYOT4Z.js.map → chunk-OODZEX6P.js.map} +1 -1
  25. package/dist/{chunk-GVN7XC3U.mjs → chunk-PHNL4QUF.mjs} +835 -15
  26. package/dist/chunk-PHNL4QUF.mjs.map +1 -0
  27. package/dist/{chunk-GKY5DAIH.js → chunk-QPWHMXE2.js} +1505 -553
  28. package/dist/chunk-QPWHMXE2.js.map +1 -0
  29. package/dist/{chunk-FM6WRAN5.js → chunk-WLS4D6VG.js} +12 -5
  30. package/dist/chunk-WLS4D6VG.js.map +1 -0
  31. package/dist/client/index.d.mts +4 -4
  32. package/dist/client/index.d.ts +4 -4
  33. package/dist/client/index.js +5 -5
  34. package/dist/client/index.mjs +4 -4
  35. package/dist/components/index.d.mts +69 -8
  36. package/dist/components/index.d.ts +69 -8
  37. package/dist/components/index.js +27 -5
  38. package/dist/components/index.js.map +1 -1
  39. package/dist/components/index.mjs +26 -4
  40. package/dist/{config-BxwhHdCD.d.mts → config-BW5u1e9P.d.mts} +1 -1
  41. package/dist/{config-BbaBV_yk.d.ts → config-BozK5PY0.d.ts} +1 -1
  42. package/dist/{content.interface-CgUu4771.d.ts → content.interface-CpCDB1Uk.d.ts} +1 -1
  43. package/dist/{content.interface-CWV0q4lZ.d.mts → content.interface-b-mzkL_q.d.mts} +1 -1
  44. package/dist/contexts/index.d.mts +2 -2
  45. package/dist/contexts/index.d.ts +2 -2
  46. package/dist/contexts/index.js +5 -5
  47. package/dist/contexts/index.mjs +4 -4
  48. package/dist/core/index.d.mts +407 -7
  49. package/dist/core/index.d.ts +407 -7
  50. package/dist/core/index.js +61 -3
  51. package/dist/core/index.js.map +1 -1
  52. package/dist/core/index.mjs +60 -2
  53. package/dist/index.d.mts +8 -6
  54. package/dist/index.d.ts +8 -6
  55. package/dist/index.js +62 -4
  56. package/dist/index.js.map +1 -1
  57. package/dist/index.mjs +61 -3
  58. package/dist/{notification.interface-XARGKJAq.d.ts → notification.interface-CR2PuV6Y.d.ts} +1 -0
  59. package/dist/{notification.interface-DIln2r7X.d.mts → notification.interface-D241WNUx.d.mts} +1 -0
  60. package/dist/{s3.service-BoOF5-ln.d.mts → s3.service-D0rbmLFp.d.mts} +10 -31
  61. package/dist/{s3.service-Mxo-7wQ6.d.ts → s3.service-DOwqcUDT.d.ts} +10 -31
  62. package/dist/scripts/generate-web-module/generator.js +26 -26
  63. package/dist/scripts/generate-web-module/generator.js.map +1 -1
  64. package/dist/scripts/generate-web-module/utils/file-writer.js +9 -9
  65. package/dist/scripts/generate-web-module/utils/file-writer.js.map +1 -1
  66. package/dist/server/index.d.mts +4 -3
  67. package/dist/server/index.d.ts +4 -3
  68. package/dist/server/index.js +12 -12
  69. package/dist/server/index.mjs +2 -2
  70. package/dist/{useSocket-awibcC9B.d.ts → useSocket-CC8SkXdm.d.ts} +1 -1
  71. package/dist/{useSocket-BILAdmZ0.d.mts → useSocket-CttIHn2P.d.mts} +1 -1
  72. package/package.json +4 -1
  73. package/scripts/generate-web-module/generator.ts +26 -26
  74. package/scripts/generate-web-module/utils/file-writer.ts +9 -9
  75. package/src/components/pages/PageContentContainer.tsx +22 -9
  76. package/src/core/abstracts/AbstractService.ts +2 -0
  77. package/src/core/factories/JsonApiDataFactory.ts +2 -1
  78. package/src/core/index.ts +14 -0
  79. package/src/core/registry/DataClassRegistry.ts +7 -1
  80. package/src/core/registry/ModuleRegistry.ts +15 -0
  81. package/src/features/auth/backup-code-verify.module.ts +9 -0
  82. package/src/features/auth/components/containers/SecurityContainer.tsx +11 -0
  83. package/src/features/auth/components/containers/index.ts +1 -0
  84. package/src/features/auth/components/forms/Login.tsx +15 -3
  85. package/src/features/auth/components/forms/Register.tsx +1 -9
  86. package/src/features/auth/components/forms/TwoFactorChallenge.tsx +202 -0
  87. package/src/features/auth/components/forms/index.ts +1 -0
  88. package/src/features/auth/components/index.ts +1 -0
  89. package/src/features/auth/components/two-factor/BackupCodesDialog.tsx +148 -0
  90. package/src/features/auth/components/two-factor/DisableTwoFactorDialog.tsx +74 -0
  91. package/src/features/auth/components/two-factor/PasskeyButton.tsx +59 -0
  92. package/src/features/auth/components/two-factor/PasskeyList.tsx +172 -0
  93. package/src/features/auth/components/two-factor/PasskeySetupDialog.tsx +105 -0
  94. package/src/features/auth/components/two-factor/TotpAuthenticatorList.tsx +104 -0
  95. package/src/features/auth/components/two-factor/TotpInput.tsx +90 -0
  96. package/src/features/auth/components/two-factor/TotpSetupDialog.tsx +161 -0
  97. package/src/features/auth/components/two-factor/TwoFactorSettings.tsx +175 -0
  98. package/src/features/auth/components/two-factor/index.ts +9 -0
  99. package/src/features/auth/contexts/AuthContext.tsx +9 -0
  100. package/src/features/auth/data/auth.service.ts +18 -1
  101. package/src/features/auth/data/backup-code-verify.ts +20 -0
  102. package/src/features/auth/data/index.ts +21 -0
  103. package/src/features/auth/data/passkey-authentication-options.interface.ts +7 -0
  104. package/src/features/auth/data/passkey-authentication-options.ts +37 -0
  105. package/src/features/auth/data/passkey-registration-options.ts +46 -0
  106. package/src/features/auth/data/passkey-registration-verify.ts +62 -0
  107. package/src/features/auth/data/passkey-rename.ts +20 -0
  108. package/src/features/auth/data/passkey-verify-login.ts +23 -0
  109. package/src/features/auth/data/passkey.interface.ts +9 -0
  110. package/src/features/auth/data/passkey.ts +40 -0
  111. package/src/features/auth/data/totp-authenticator.interface.ts +7 -0
  112. package/src/features/auth/data/totp-authenticator.ts +28 -0
  113. package/src/features/auth/data/totp-setup.interface.ts +5 -0
  114. package/src/features/auth/data/totp-setup.ts +48 -0
  115. package/src/features/auth/data/totp-verify-login.ts +20 -0
  116. package/src/features/auth/data/totp-verify.ts +22 -0
  117. package/src/features/auth/data/two-factor-challenge.interface.ts +7 -0
  118. package/src/features/auth/data/two-factor-challenge.ts +45 -0
  119. package/src/features/auth/data/two-factor-enable.ts +20 -0
  120. package/src/features/auth/data/two-factor-status.interface.ts +11 -0
  121. package/src/features/auth/data/two-factor-status.ts +40 -0
  122. package/src/features/auth/data/two-factor.service.ts +331 -0
  123. package/src/features/auth/enums/AuthComponent.ts +1 -0
  124. package/src/features/auth/index.ts +13 -0
  125. package/src/features/auth/passkey-authentication-options.module.ts +9 -0
  126. package/src/features/auth/passkey-registration-options.module.ts +9 -0
  127. package/src/features/auth/passkey-registration-verify.module.ts +9 -0
  128. package/src/features/auth/passkey-rename.module.ts +9 -0
  129. package/src/features/auth/passkey-verify-login.module.ts +9 -0
  130. package/src/features/auth/passkey.module.ts +9 -0
  131. package/src/features/auth/totp-authenticator.module.ts +9 -0
  132. package/src/features/auth/totp-setup.module.ts +9 -0
  133. package/src/features/auth/totp-verify-login.module.ts +9 -0
  134. package/src/features/auth/totp-verify.module.ts +9 -0
  135. package/src/features/auth/two-factor-challenge.module.ts +9 -0
  136. package/src/features/auth/two-factor-enable.module.ts +9 -0
  137. package/src/features/auth/two-factor-status.module.ts +9 -0
  138. package/src/features/billing/modules/billing.module.ts +1 -0
  139. package/src/features/billing/stripe-customer/stripe-customer.module.ts +1 -0
  140. package/src/features/billing/stripe-customer/stripe-payment-method.module.ts +1 -0
  141. package/src/features/billing/stripe-invoice/stripe-invoice.module.ts +1 -0
  142. package/src/features/billing/stripe-price/stripe-price.module.ts +1 -0
  143. package/src/features/billing/stripe-product/stripe-product.module.ts +1 -0
  144. package/src/features/billing/stripe-promotion-code/stripe-promotion-code.module.ts +1 -0
  145. package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +0 -5
  146. package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +0 -8
  147. package/src/features/billing/stripe-subscription/stripe-subscription.module.ts +1 -0
  148. package/src/features/billing/stripe-usage/stripe-usage.module.ts +1 -0
  149. package/src/features/user/data/user.interface.ts +1 -0
  150. package/src/features/user/data/user.ts +6 -0
  151. package/src/features/waitlist/data/WaitlistService.ts +1 -8
  152. package/src/features/waitlist/waitlist-stats.module.ts +1 -0
  153. package/src/shadcnui/ui/resizable.tsx +33 -11
  154. package/src/unified/JsonApiRequest.ts +2 -1
  155. package/dist/AuthComponent-hxOPs9o8.d.mts +0 -11
  156. package/dist/AuthComponent-hxOPs9o8.d.ts +0 -11
  157. package/dist/JsonApiRequest-GR3L56A5.js +0 -24
  158. package/dist/chunk-BAOP6PTD.mjs.map +0 -1
  159. package/dist/chunk-FM6WRAN5.js.map +0 -1
  160. package/dist/chunk-GKY5DAIH.js.map +0 -1
  161. package/dist/chunk-GVN7XC3U.mjs.map +0 -1
  162. package/dist/chunk-ZNGEVB5M.js.map +0 -1
  163. /package/dist/{BlockNoteEditor-EKY4AHVK.mjs.map → BlockNoteEditor-B3RQ4VQ7.mjs.map} +0 -0
  164. /package/dist/{JsonApiRequest-K5BRU7RE.mjs.map → JsonApiRequest-XCQHVVYD.mjs.map} +0 -0
  165. /package/dist/{chunk-RRIYLEY6.mjs.map → chunk-LBIC4GJK.mjs.map} +0 -0
package/src/core/index.ts CHANGED
@@ -30,6 +30,20 @@ export * from "../permissions";
30
30
 
31
31
  // Feature data classes, interfaces, and modules
32
32
  export * from "../features/auth/auth.module";
33
+ export * from "../features/auth/totp-authenticator.module";
34
+ export * from "../features/auth/totp-setup.module";
35
+ export * from "../features/auth/totp-verify.module";
36
+ export * from "../features/auth/totp-verify-login.module";
37
+ export * from "../features/auth/passkey.module";
38
+ export * from "../features/auth/passkey-registration-options.module";
39
+ export * from "../features/auth/passkey-registration-verify.module";
40
+ export * from "../features/auth/passkey-rename.module";
41
+ export * from "../features/auth/passkey-verify-login.module";
42
+ export * from "../features/auth/passkey-authentication-options.module";
43
+ export * from "../features/auth/two-factor-enable.module";
44
+ export * from "../features/auth/two-factor-challenge.module";
45
+ export * from "../features/auth/two-factor-status.module";
46
+ export * from "../features/auth/backup-code-verify.module";
33
47
  export * from "../features/auth/data";
34
48
  export * from "../features/auth/enums";
35
49
  export * from "../features/billing/data";
@@ -2,6 +2,10 @@ import { ApiDataInterface } from "../interfaces/ApiDataInterface";
2
2
  import { ApiRequestDataTypeInterface } from "../interfaces/ApiRequestDataTypeInterface";
3
3
 
4
4
  export class DataClassRegistry {
5
+ // Use Map with string key (module.name) for reliable lookup
6
+ // String keys are stable across HMR and module re-evaluations
7
+ // Note: WeakMap with object identity was tried but fails during Next.js navigation
8
+ // because module re-evaluation creates new object instances with different identity
5
9
  private static _map = new Map<string, { new (): ApiDataInterface }>();
6
10
 
7
11
  public static registerObjectClass(
@@ -19,7 +23,9 @@ export class DataClassRegistry {
19
23
  } {
20
24
  const response = this._map.get(classKey.name);
21
25
  if (!response) {
22
- throw new Error(`Class not registered for key: ${typeof classKey === "string" ? classKey : classKey.name}`);
26
+ throw new Error(
27
+ `Class not registered for key: ${typeof classKey === "string" ? classKey : classKey.name}. Ensure bootstrap() was called.`,
28
+ );
23
29
  }
24
30
 
25
31
  return response;
@@ -32,6 +32,21 @@ export interface FoundationModuleDefinitions {
32
32
  // Waitlist modules
33
33
  Waitlist: ModuleWithPermissions;
34
34
  WaitlistStats: ModuleWithPermissions;
35
+ // Two-factor authentication modules
36
+ TotpAuthenticator: ModuleWithPermissions;
37
+ TotpSetup: ModuleWithPermissions;
38
+ TotpVerify: ModuleWithPermissions;
39
+ TotpVerifyLogin: ModuleWithPermissions;
40
+ Passkey: ModuleWithPermissions;
41
+ PasskeyRegistrationOptions: ModuleWithPermissions;
42
+ PasskeyRegistrationVerify: ModuleWithPermissions;
43
+ PasskeyRename: ModuleWithPermissions;
44
+ PasskeyVerifyLogin: ModuleWithPermissions;
45
+ PasskeyAuthenticationOptions: ModuleWithPermissions;
46
+ TwoFactorEnable: ModuleWithPermissions;
47
+ TwoFactorChallenge: ModuleWithPermissions;
48
+ TwoFactorStatus: ModuleWithPermissions;
49
+ BackupCodeVerify: ModuleWithPermissions;
35
50
  }
36
51
 
37
52
  // App-specific modules - apps will augment this interface ONLY
@@ -0,0 +1,9 @@
1
+ import { ModuleFactory } from "../../permissions";
2
+ import { BackupCodeVerify } from "./data/backup-code-verify";
3
+
4
+ export const BackupCodeVerifyModule = (factory: ModuleFactory) =>
5
+ factory({
6
+ name: "backup-codes",
7
+ pageUrl: "/backup-codes",
8
+ model: BackupCodeVerify,
9
+ });
@@ -0,0 +1,11 @@
1
+ "use client";
2
+
3
+ import { TwoFactorSettings } from "../two-factor/TwoFactorSettings";
4
+
5
+ export function SecurityContainer() {
6
+ return (
7
+ <div className="space-y-6">
8
+ <TwoFactorSettings />
9
+ </div>
10
+ );
11
+ }
@@ -1 +1,2 @@
1
1
  export * from "./AuthContainer";
2
+ export * from "./SecurityContainer";
@@ -24,13 +24,14 @@ import { UserInterface } from "../../../user";
24
24
  import { useCurrentUserContext } from "../../../user/contexts";
25
25
  import { useAuthContext } from "../../contexts";
26
26
  import { AuthService } from "../../data/auth.service";
27
+ import { TwoFactorChallengeInterface } from "../../data/two-factor-challenge.interface";
27
28
  import { AuthComponent } from "../../enums";
28
29
  import { GoogleSignInButton } from "../buttons/GoogleSignInButton";
29
30
 
30
31
  export function Login() {
31
32
  const t = useTranslations();
32
33
  const { setUser } = useCurrentUserContext<UserInterface>();
33
- const { setComponentType } = useAuthContext();
34
+ const { setComponentType, setPendingTwoFactor } = useAuthContext();
34
35
  const generateUrl = usePageUrlGenerator();
35
36
  const i18nRouter = useI18nRouter();
36
37
  const nativeRouter = useRouter(); // For URLs that already include locale
@@ -54,11 +55,21 @@ export function Login() {
54
55
 
55
56
  const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = async (values: z.infer<typeof formSchema>) => {
56
57
  try {
57
- const user: UserInterface = (await AuthService.login({
58
+ const response = await AuthService.login({
58
59
  email: values.email,
59
60
  password: values.password,
60
- })) as UserInterface;
61
+ });
62
+
63
+ // Check if 2FA is required (response is TwoFactorChallengeInterface)
64
+ if ("pendingToken" in response) {
65
+ const challenge = response as TwoFactorChallengeInterface;
66
+ setPendingTwoFactor(challenge);
67
+ setComponentType(AuthComponent.TwoFactorChallenge);
68
+ return;
69
+ }
61
70
 
71
+ // Normal login flow
72
+ const user = response as UserInterface;
62
73
  setUser(user);
63
74
 
64
75
  // Redirect to callback URL if present, otherwise go to home
@@ -70,6 +81,7 @@ export function Login() {
70
81
  i18nRouter.replace(generateUrl({ page: `/` }));
71
82
  }
72
83
  } catch (e) {
84
+ console.error("[Login] Login error:", e);
73
85
  errorToast({
74
86
  title: t(`common.errors.error`),
75
87
  error: e,
@@ -79,30 +79,22 @@ export default function Register() {
79
79
 
80
80
  useEffect(() => {
81
81
  async function validateInvite() {
82
- console.log("[Register] validateInvite called. registrationMode:", registrationMode, "inviteCode:", inviteCode);
83
-
84
82
  if (registrationMode !== "waitlist" || !inviteCode) {
85
- console.log("[Register] Skipping validation - not in waitlist mode or no invite code");
86
83
  return;
87
84
  }
88
85
 
89
86
  setIsValidatingInvite(true);
90
87
  try {
91
- console.log("[Register] Calling WaitlistService.validateInvite...");
92
88
  const result = await WaitlistService.validateInvite(inviteCode);
93
- console.log("[Register] Validation result:", JSON.stringify(result));
94
89
 
95
90
  if (result && result.valid) {
96
- console.log("[Register] Invite valid! Email:", result.email);
97
91
  setInviteValidated(true);
98
92
  form.setValue("email", result.email);
99
93
  } else {
100
94
  const errorMsg = result ? t("waitlist.invite.error_expired") : t("waitlist.invite.error_invalid");
101
- console.log("[Register] Invite invalid. result:", result, "errorMsg:", errorMsg);
102
95
  setInviteError(errorMsg);
103
96
  }
104
- } catch (error) {
105
- console.error("[Register] Validation exception:", error);
97
+ } catch (_error) {
106
98
  setInviteError(t("waitlist.invite.error_validation_failed"));
107
99
  } finally {
108
100
  setIsValidatingInvite(false);
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { useState } from "react";
6
+ import { v4 } from "uuid";
7
+ import { errorToast } from "../../../../components";
8
+ import { useI18nRouter, usePageUrlGenerator } from "../../../../hooks";
9
+ import {
10
+ Button,
11
+ CardContent,
12
+ CardDescription,
13
+ CardHeader,
14
+ CardTitle,
15
+ Input,
16
+ Tabs,
17
+ TabsContent,
18
+ TabsList,
19
+ TabsTrigger,
20
+ } from "../../../../shadcnui";
21
+ import { UserInterface } from "../../../user";
22
+ import { useCurrentUserContext } from "../../../user/contexts";
23
+ import { useAuthContext } from "../../contexts";
24
+ import { AuthInterface } from "../../data/auth.interface";
25
+ import { TwoFactorService } from "../../data/two-factor.service";
26
+ import { PasskeyButton } from "../two-factor/PasskeyButton";
27
+ import { TotpInput } from "../two-factor/TotpInput";
28
+
29
+ export function TwoFactorChallenge() {
30
+ const t = useTranslations();
31
+ const { setUser } = useCurrentUserContext<UserInterface>();
32
+ const { pendingTwoFactor, setPendingTwoFactor } = useAuthContext();
33
+ const generateUrl = usePageUrlGenerator();
34
+ const i18nRouter = useI18nRouter();
35
+ const nativeRouter = useRouter();
36
+ const searchParams = useSearchParams();
37
+ const callbackUrl = searchParams.get("callbackUrl");
38
+
39
+ const [isVerifying, setIsVerifying] = useState(false);
40
+ const [totpError, setTotpError] = useState<string | undefined>();
41
+ const [backupCode, setBackupCode] = useState("");
42
+ const [backupError, setBackupError] = useState<string | undefined>();
43
+
44
+ if (!pendingTwoFactor) {
45
+ return null;
46
+ }
47
+
48
+ const handleSuccess = (auth: AuthInterface) => {
49
+ setPendingTwoFactor(undefined);
50
+ setUser(auth.user);
51
+
52
+ if (callbackUrl) {
53
+ nativeRouter.replace(callbackUrl);
54
+ } else {
55
+ i18nRouter.replace(generateUrl({ page: "/" }));
56
+ }
57
+ };
58
+
59
+ const handleTotpComplete = async (code: string) => {
60
+ setIsVerifying(true);
61
+ setTotpError(undefined);
62
+
63
+ try {
64
+ const auth = await TwoFactorService.verifyTotp({
65
+ id: v4(),
66
+ pendingToken: pendingTwoFactor.pendingToken,
67
+ code,
68
+ });
69
+ handleSuccess(auth);
70
+ } catch (error) {
71
+ setTotpError(t("auth.two_factor.invalid_code"));
72
+ errorToast({
73
+ title: t("common.errors.error"),
74
+ error,
75
+ });
76
+ } finally {
77
+ setIsVerifying(false);
78
+ }
79
+ };
80
+
81
+ const handlePasskeyError = (error: Error) => {
82
+ errorToast({
83
+ title: t("common.errors.error"),
84
+ error,
85
+ });
86
+ };
87
+
88
+ const handleBackupSubmit = async () => {
89
+ if (backupCode.length < 8) {
90
+ setBackupError(t("auth.two_factor.invalid_backup_code"));
91
+ return;
92
+ }
93
+
94
+ setIsVerifying(true);
95
+ setBackupError(undefined);
96
+
97
+ try {
98
+ const auth = await TwoFactorService.verifyBackupCode({
99
+ id: v4(),
100
+ pendingToken: pendingTwoFactor.pendingToken,
101
+ code: backupCode,
102
+ });
103
+ handleSuccess(auth);
104
+ } catch (error) {
105
+ setBackupError(t("auth.two_factor.invalid_backup_code"));
106
+ errorToast({
107
+ title: t("common.errors.error"),
108
+ error,
109
+ });
110
+ } finally {
111
+ setIsVerifying(false);
112
+ }
113
+ };
114
+
115
+ const availableMethods = pendingTwoFactor.availableMethods;
116
+ const hasTotp = availableMethods.includes("totp");
117
+ const hasPasskey = availableMethods.includes("passkey");
118
+ const hasBackup = availableMethods.includes("backup");
119
+
120
+ return (
121
+ <>
122
+ <CardHeader data-testid="page-2fa-challenge">
123
+ <CardTitle className="text-primary text-2xl">{t("auth.two_factor.verification_required")}</CardTitle>
124
+ <CardDescription>{t("auth.two_factor.enter_verification_code")}</CardDescription>
125
+ </CardHeader>
126
+ <CardContent>
127
+ <Tabs defaultValue={hasTotp ? "totp" : hasPasskey ? "passkey" : "backup"}>
128
+ <TabsList className="grid w-full grid-cols-3">
129
+ {hasTotp && (
130
+ <TabsTrigger value="totp" data-testid="tab-totp">
131
+ {t("auth.two_factor.authenticator")}
132
+ </TabsTrigger>
133
+ )}
134
+ {hasPasskey && (
135
+ <TabsTrigger value="passkey" data-testid="tab-passkey">
136
+ {t("auth.two_factor.passkey")}
137
+ </TabsTrigger>
138
+ )}
139
+ {hasBackup && (
140
+ <TabsTrigger value="backup" data-testid="tab-backup">
141
+ {t("auth.two_factor.backup_code")}
142
+ </TabsTrigger>
143
+ )}
144
+ </TabsList>
145
+
146
+ {hasTotp && (
147
+ <TabsContent value="totp" className="mt-6">
148
+ <div className="flex flex-col items-center gap-4">
149
+ <p className="text-sm text-muted-foreground text-center">
150
+ {t("auth.two_factor.enter_authenticator_code")}
151
+ </p>
152
+ <TotpInput onComplete={handleTotpComplete} disabled={isVerifying} error={totpError} />
153
+ </div>
154
+ </TabsContent>
155
+ )}
156
+
157
+ {hasPasskey && (
158
+ <TabsContent value="passkey" className="mt-6">
159
+ <div className="flex flex-col items-center gap-4">
160
+ <p className="text-sm text-muted-foreground text-center">
161
+ {t("auth.two_factor.use_passkey_description")}
162
+ </p>
163
+ <PasskeyButton
164
+ pendingToken={pendingTwoFactor.pendingToken}
165
+ onSuccess={handleSuccess}
166
+ onError={handlePasskeyError}
167
+ disabled={isVerifying}
168
+ />
169
+ </div>
170
+ </TabsContent>
171
+ )}
172
+
173
+ {hasBackup && (
174
+ <TabsContent value="backup" className="mt-6">
175
+ <div className="flex flex-col items-center gap-4">
176
+ <p className="text-sm text-muted-foreground text-center">{t("auth.two_factor.enter_backup_code")}</p>
177
+ <Input
178
+ type="text"
179
+ value={backupCode}
180
+ onChange={(e) => setBackupCode(e.target.value.toUpperCase())}
181
+ placeholder="XXXXXXXX"
182
+ maxLength={8}
183
+ className={`w-48 text-center font-mono uppercase ${backupError ? "border-destructive" : ""}`}
184
+ disabled={isVerifying}
185
+ data-testid="backup-code-input"
186
+ />
187
+ {backupError && <p className="text-sm text-destructive">{backupError}</p>}
188
+ <Button
189
+ onClick={handleBackupSubmit}
190
+ disabled={isVerifying || backupCode.length < 8}
191
+ data-testid="backup-code-submit"
192
+ >
193
+ {t("auth.two_factor.verify")}
194
+ </Button>
195
+ </div>
196
+ </TabsContent>
197
+ )}
198
+ </Tabs>
199
+ </CardContent>
200
+ </>
201
+ );
202
+ }
@@ -7,3 +7,4 @@ export * from "./Logout";
7
7
  export * from "./RefreshUser";
8
8
  export * from "./Register";
9
9
  export * from "./ResetPassword";
10
+ export * from "./TwoFactorChallenge";
@@ -2,3 +2,4 @@ export * from "./containers";
2
2
  export * from "./details";
3
3
  export * from "./forms";
4
4
  export * from "./GdprConsentSection";
5
+ export * from "./two-factor";
@@ -0,0 +1,148 @@
1
+ "use client";
2
+
3
+ import { Copy, Download, RefreshCw } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useState } from "react";
6
+ import { errorToast } from "../../../../components/errors/errorToast";
7
+ import {
8
+ AlertDialog,
9
+ AlertDialogAction,
10
+ AlertDialogCancel,
11
+ AlertDialogContent,
12
+ AlertDialogDescription,
13
+ AlertDialogFooter,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
16
+ AlertDialogTrigger,
17
+ Button,
18
+ Dialog,
19
+ DialogContent,
20
+ DialogDescription,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ DialogTrigger,
24
+ } from "../../../../shadcnui";
25
+ import { showToast } from "../../../../utils/toast";
26
+ import { TwoFactorService } from "../../data/two-factor.service";
27
+
28
+ interface BackupCodesDialogProps {
29
+ remainingCodes: number;
30
+ onRegenerate: () => void;
31
+ trigger?: React.ReactElement;
32
+ }
33
+
34
+ export function BackupCodesDialog({ remainingCodes, onRegenerate, trigger }: BackupCodesDialogProps) {
35
+ const t = useTranslations();
36
+ const [open, setOpen] = useState(false);
37
+ const [codes, setCodes] = useState<string[]>([]);
38
+ const [isLoading, setIsLoading] = useState(false);
39
+ const [showCodes, setShowCodes] = useState(false);
40
+
41
+ const handleGenerate = async () => {
42
+ setIsLoading(true);
43
+ try {
44
+ const newCodes = await TwoFactorService.generateBackupCodes();
45
+ setCodes(newCodes);
46
+ setShowCodes(true);
47
+ onRegenerate();
48
+ } catch (error) {
49
+ errorToast({ title: t("common.errors.error"), error });
50
+ } finally {
51
+ setIsLoading(false);
52
+ }
53
+ };
54
+
55
+ const handleCopyAll = async () => {
56
+ const text = codes.join("\n");
57
+ await navigator.clipboard.writeText(text);
58
+ showToast(t("auth.two_factor.codes_copied"));
59
+ };
60
+
61
+ const handleDownload = () => {
62
+ const text = `Backup Codes\n\n${codes.join("\n")}\n\nKeep these codes safe. Each code can only be used once.`;
63
+ const blob = new Blob([text], { type: "text/plain" });
64
+ const url = URL.createObjectURL(blob);
65
+ const a = document.createElement("a");
66
+ a.href = url;
67
+ a.download = "backup-codes.txt";
68
+ a.click();
69
+ URL.revokeObjectURL(url);
70
+ };
71
+
72
+ const handleOpenChange = (newOpen: boolean) => {
73
+ setOpen(newOpen);
74
+ if (!newOpen) {
75
+ setCodes([]);
76
+ setShowCodes(false);
77
+ }
78
+ };
79
+
80
+ return (
81
+ <Dialog open={open} onOpenChange={handleOpenChange}>
82
+ {trigger ? (
83
+ <DialogTrigger render={trigger} />
84
+ ) : (
85
+ <DialogTrigger render={<Button variant="outline">{t("auth.two_factor.manage_backup_codes")}</Button>} />
86
+ )}
87
+ <DialogContent className="sm:max-w-md">
88
+ <DialogHeader>
89
+ <DialogTitle>{t("auth.two_factor.backup_codes")}</DialogTitle>
90
+ <DialogDescription>
91
+ {showCodes ? t("auth.two_factor.save_backup_codes") : t("auth.two_factor.backup_codes_description")}
92
+ </DialogDescription>
93
+ </DialogHeader>
94
+
95
+ {!showCodes ? (
96
+ <div className="flex flex-col gap-4">
97
+ <p className="text-sm">
98
+ {t("auth.two_factor.remaining_codes")}: <strong>{remainingCodes}</strong>
99
+ </p>
100
+ <AlertDialog>
101
+ <AlertDialogTrigger
102
+ render={
103
+ <Button variant="outline" className="w-full">
104
+ <RefreshCw className="h-4 w-4 mr-2" />
105
+ {t("auth.two_factor.generate_new_codes")}
106
+ </Button>
107
+ }
108
+ />
109
+ <AlertDialogContent>
110
+ <AlertDialogHeader>
111
+ <AlertDialogTitle>{t("auth.two_factor.regenerate_codes")}</AlertDialogTitle>
112
+ <AlertDialogDescription>{t("auth.two_factor.regenerate_codes_warning")}</AlertDialogDescription>
113
+ </AlertDialogHeader>
114
+ <AlertDialogFooter>
115
+ <AlertDialogCancel>{t("common.buttons.cancel")}</AlertDialogCancel>
116
+ <AlertDialogAction onClick={handleGenerate} disabled={isLoading}>
117
+ {t("auth.two_factor.generate")}
118
+ </AlertDialogAction>
119
+ </AlertDialogFooter>
120
+ </AlertDialogContent>
121
+ </AlertDialog>
122
+ </div>
123
+ ) : (
124
+ <div className="flex flex-col gap-4">
125
+ <div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
126
+ {codes.map((code, index) => (
127
+ <div key={index} className="text-center" data-testid={`backup-code-${index}`}>
128
+ {code}
129
+ </div>
130
+ ))}
131
+ </div>
132
+ <div className="flex gap-2">
133
+ <Button variant="outline" className="flex-1" onClick={handleCopyAll}>
134
+ <Copy className="h-4 w-4 mr-2" />
135
+ {t("auth.two_factor.copy_all")}
136
+ </Button>
137
+ <Button variant="outline" className="flex-1" onClick={handleDownload}>
138
+ <Download className="h-4 w-4 mr-2" />
139
+ {t("auth.two_factor.download")}
140
+ </Button>
141
+ </div>
142
+ <p className="text-xs text-destructive text-center">{t("auth.two_factor.codes_shown_once")}</p>
143
+ </div>
144
+ )}
145
+ </DialogContent>
146
+ </Dialog>
147
+ );
148
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useState } from "react";
5
+ import { errorToast } from "../../../../components/errors/errorToast";
6
+ import {
7
+ Button,
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ } from "../../../../shadcnui";
15
+ import { showToast } from "../../../../utils/toast";
16
+ import { TwoFactorService } from "../../data/two-factor.service";
17
+ import { TotpInput } from "./TotpInput";
18
+
19
+ interface DisableTwoFactorDialogProps {
20
+ onSuccess: () => void;
21
+ trigger?: React.ReactElement;
22
+ }
23
+
24
+ export function DisableTwoFactorDialog({ onSuccess, trigger }: DisableTwoFactorDialogProps) {
25
+ const t = useTranslations();
26
+ const [open, setOpen] = useState(false);
27
+ const [isLoading, setIsLoading] = useState(false);
28
+ const [error, setError] = useState<string | undefined>();
29
+
30
+ const handleVerify = async (code: string) => {
31
+ setIsLoading(true);
32
+ setError(undefined);
33
+
34
+ try {
35
+ await TwoFactorService.disable({ code });
36
+ showToast(t("auth.two_factor.disabled_success"));
37
+ setOpen(false);
38
+ onSuccess();
39
+ } catch (err) {
40
+ setError(t("auth.two_factor.invalid_code"));
41
+ errorToast({ title: t("common.errors.error"), error: err });
42
+ } finally {
43
+ setIsLoading(false);
44
+ }
45
+ };
46
+
47
+ const handleOpenChange = (newOpen: boolean) => {
48
+ setOpen(newOpen);
49
+ if (!newOpen) {
50
+ setError(undefined);
51
+ }
52
+ };
53
+
54
+ return (
55
+ <Dialog open={open} onOpenChange={handleOpenChange}>
56
+ {trigger ? (
57
+ <DialogTrigger render={trigger} />
58
+ ) : (
59
+ <DialogTrigger render={<Button variant="destructive">{t("auth.two_factor.disable")}</Button>} />
60
+ )}
61
+ <DialogContent className="sm:max-w-md">
62
+ <DialogHeader>
63
+ <DialogTitle>{t("auth.two_factor.disable_2fa")}</DialogTitle>
64
+ <DialogDescription>{t("auth.two_factor.disable_warning")}</DialogDescription>
65
+ </DialogHeader>
66
+
67
+ <div className="flex flex-col items-center gap-4">
68
+ <p className="text-sm text-muted-foreground text-center">{t("auth.two_factor.enter_code_to_disable")}</p>
69
+ <TotpInput onComplete={handleVerify} disabled={isLoading} error={error} />
70
+ </div>
71
+ </DialogContent>
72
+ </Dialog>
73
+ );
74
+ }
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { startAuthentication } from "@simplewebauthn/browser";
4
+ import { useTranslations } from "next-intl";
5
+ import { useState } from "react";
6
+ import { v4 } from "uuid";
7
+ import { Button } from "../../../../shadcnui";
8
+ import { TwoFactorService } from "../../data/two-factor.service";
9
+ import { AuthInterface } from "../../data/auth.interface";
10
+
11
+ interface PasskeyButtonProps {
12
+ pendingToken: string;
13
+ onSuccess: (auth: AuthInterface) => void;
14
+ onError: (error: Error) => void;
15
+ disabled?: boolean;
16
+ }
17
+
18
+ export function PasskeyButton({ pendingToken, onSuccess, onError, disabled = false }: PasskeyButtonProps) {
19
+ const t = useTranslations();
20
+ const [isLoading, setIsLoading] = useState(false);
21
+
22
+ const handleClick = async () => {
23
+ setIsLoading(true);
24
+ try {
25
+ // 1. Get authentication options from backend
26
+ const { pendingId, options } = await TwoFactorService.getPasskeyAuthOptions({ pendingToken });
27
+
28
+ // 2. Trigger browser WebAuthn dialog
29
+ const credential = await startAuthentication({ optionsJSON: options });
30
+
31
+ // 3. Verify with backend
32
+ const auth = await TwoFactorService.verifyPasskey({
33
+ id: v4(),
34
+ pendingToken,
35
+ pendingId,
36
+ credential,
37
+ });
38
+
39
+ onSuccess(auth);
40
+ } catch (error) {
41
+ onError(error instanceof Error ? error : new Error("Passkey authentication failed"));
42
+ } finally {
43
+ setIsLoading(false);
44
+ }
45
+ };
46
+
47
+ return (
48
+ <Button
49
+ type="button"
50
+ variant="outline"
51
+ onClick={handleClick}
52
+ disabled={disabled || isLoading}
53
+ className="w-full"
54
+ data-testid="passkey-auth-button"
55
+ >
56
+ {isLoading ? t("auth.two_factor.verifying") : t("auth.two_factor.use_passkey")}
57
+ </Button>
58
+ );
59
+ }