@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.
- package/README.md +3 -3
- package/dist/AuthComponent-BuWc2C4g.d.ts +28 -0
- package/dist/AuthComponent-fLVGdvSr.d.mts +28 -0
- package/dist/{BlockNoteEditor-EKY4AHVK.mjs → BlockNoteEditor-B3RQ4VQ7.mjs} +5 -5
- package/dist/{BlockNoteEditor-4G3L3LSF.js → BlockNoteEditor-VUAWVZF4.js} +15 -15
- package/dist/{BlockNoteEditor-4G3L3LSF.js.map → BlockNoteEditor-VUAWVZF4.js.map} +1 -1
- package/dist/JsonApiRequest-MUPAO7DI.js +24 -0
- package/dist/{JsonApiRequest-GR3L56A5.js.map → JsonApiRequest-MUPAO7DI.js.map} +1 -1
- package/dist/{JsonApiRequest-K5BRU7RE.mjs → JsonApiRequest-XCQHVVYD.mjs} +2 -2
- package/dist/auth.interface-8XglqHir.d.mts +33 -0
- package/dist/auth.interface-BJGKQ0zr.d.ts +33 -0
- package/dist/billing/index.js +409 -415
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +4 -10
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-BAOP6PTD.mjs → chunk-BJNQZGMN.mjs} +1618 -666
- package/dist/chunk-BJNQZGMN.mjs.map +1 -0
- package/dist/{chunk-U4MTVHOC.mjs → chunk-GCQUTWZ2.mjs} +11 -4
- package/dist/{chunk-U4MTVHOC.mjs.map → chunk-GCQUTWZ2.mjs.map} +1 -1
- package/dist/{chunk-ZNGEVB5M.js → chunk-L5F5ZN5F.js} +960 -140
- package/dist/chunk-L5F5ZN5F.js.map +1 -0
- package/dist/{chunk-RRIYLEY6.mjs → chunk-LBIC4GJK.mjs} +2 -2
- package/dist/{chunk-T5YYOT4Z.js → chunk-OODZEX6P.js} +3 -3
- package/dist/{chunk-T5YYOT4Z.js.map → chunk-OODZEX6P.js.map} +1 -1
- package/dist/{chunk-GVN7XC3U.mjs → chunk-PHNL4QUF.mjs} +835 -15
- package/dist/chunk-PHNL4QUF.mjs.map +1 -0
- package/dist/{chunk-GKY5DAIH.js → chunk-QPWHMXE2.js} +1505 -553
- package/dist/chunk-QPWHMXE2.js.map +1 -0
- package/dist/{chunk-FM6WRAN5.js → chunk-WLS4D6VG.js} +12 -5
- package/dist/chunk-WLS4D6VG.js.map +1 -0
- package/dist/client/index.d.mts +4 -4
- package/dist/client/index.d.ts +4 -4
- package/dist/client/index.js +5 -5
- package/dist/client/index.mjs +4 -4
- package/dist/components/index.d.mts +69 -8
- package/dist/components/index.d.ts +69 -8
- package/dist/components/index.js +27 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +26 -4
- package/dist/{config-BxwhHdCD.d.mts → config-BW5u1e9P.d.mts} +1 -1
- package/dist/{config-BbaBV_yk.d.ts → config-BozK5PY0.d.ts} +1 -1
- package/dist/{content.interface-CgUu4771.d.ts → content.interface-CpCDB1Uk.d.ts} +1 -1
- package/dist/{content.interface-CWV0q4lZ.d.mts → content.interface-b-mzkL_q.d.mts} +1 -1
- package/dist/contexts/index.d.mts +2 -2
- package/dist/contexts/index.d.ts +2 -2
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +407 -7
- package/dist/core/index.d.ts +407 -7
- package/dist/core/index.js +61 -3
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +60 -2
- package/dist/index.d.mts +8 -6
- package/dist/index.d.ts +8 -6
- package/dist/index.js +62 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +61 -3
- package/dist/{notification.interface-XARGKJAq.d.ts → notification.interface-CR2PuV6Y.d.ts} +1 -0
- package/dist/{notification.interface-DIln2r7X.d.mts → notification.interface-D241WNUx.d.mts} +1 -0
- package/dist/{s3.service-BoOF5-ln.d.mts → s3.service-D0rbmLFp.d.mts} +10 -31
- package/dist/{s3.service-Mxo-7wQ6.d.ts → s3.service-DOwqcUDT.d.ts} +10 -31
- package/dist/scripts/generate-web-module/generator.js +26 -26
- package/dist/scripts/generate-web-module/generator.js.map +1 -1
- package/dist/scripts/generate-web-module/utils/file-writer.js +9 -9
- package/dist/scripts/generate-web-module/utils/file-writer.js.map +1 -1
- package/dist/server/index.d.mts +4 -3
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.js +12 -12
- package/dist/server/index.mjs +2 -2
- package/dist/{useSocket-awibcC9B.d.ts → useSocket-CC8SkXdm.d.ts} +1 -1
- package/dist/{useSocket-BILAdmZ0.d.mts → useSocket-CttIHn2P.d.mts} +1 -1
- package/package.json +4 -1
- package/scripts/generate-web-module/generator.ts +26 -26
- package/scripts/generate-web-module/utils/file-writer.ts +9 -9
- package/src/components/pages/PageContentContainer.tsx +22 -9
- package/src/core/abstracts/AbstractService.ts +2 -0
- package/src/core/factories/JsonApiDataFactory.ts +2 -1
- package/src/core/index.ts +14 -0
- package/src/core/registry/DataClassRegistry.ts +7 -1
- package/src/core/registry/ModuleRegistry.ts +15 -0
- package/src/features/auth/backup-code-verify.module.ts +9 -0
- package/src/features/auth/components/containers/SecurityContainer.tsx +11 -0
- package/src/features/auth/components/containers/index.ts +1 -0
- package/src/features/auth/components/forms/Login.tsx +15 -3
- package/src/features/auth/components/forms/Register.tsx +1 -9
- package/src/features/auth/components/forms/TwoFactorChallenge.tsx +202 -0
- package/src/features/auth/components/forms/index.ts +1 -0
- package/src/features/auth/components/index.ts +1 -0
- package/src/features/auth/components/two-factor/BackupCodesDialog.tsx +148 -0
- package/src/features/auth/components/two-factor/DisableTwoFactorDialog.tsx +74 -0
- package/src/features/auth/components/two-factor/PasskeyButton.tsx +59 -0
- package/src/features/auth/components/two-factor/PasskeyList.tsx +172 -0
- package/src/features/auth/components/two-factor/PasskeySetupDialog.tsx +105 -0
- package/src/features/auth/components/two-factor/TotpAuthenticatorList.tsx +104 -0
- package/src/features/auth/components/two-factor/TotpInput.tsx +90 -0
- package/src/features/auth/components/two-factor/TotpSetupDialog.tsx +161 -0
- package/src/features/auth/components/two-factor/TwoFactorSettings.tsx +175 -0
- package/src/features/auth/components/two-factor/index.ts +9 -0
- package/src/features/auth/contexts/AuthContext.tsx +9 -0
- package/src/features/auth/data/auth.service.ts +18 -1
- package/src/features/auth/data/backup-code-verify.ts +20 -0
- package/src/features/auth/data/index.ts +21 -0
- package/src/features/auth/data/passkey-authentication-options.interface.ts +7 -0
- package/src/features/auth/data/passkey-authentication-options.ts +37 -0
- package/src/features/auth/data/passkey-registration-options.ts +46 -0
- package/src/features/auth/data/passkey-registration-verify.ts +62 -0
- package/src/features/auth/data/passkey-rename.ts +20 -0
- package/src/features/auth/data/passkey-verify-login.ts +23 -0
- package/src/features/auth/data/passkey.interface.ts +9 -0
- package/src/features/auth/data/passkey.ts +40 -0
- package/src/features/auth/data/totp-authenticator.interface.ts +7 -0
- package/src/features/auth/data/totp-authenticator.ts +28 -0
- package/src/features/auth/data/totp-setup.interface.ts +5 -0
- package/src/features/auth/data/totp-setup.ts +48 -0
- package/src/features/auth/data/totp-verify-login.ts +20 -0
- package/src/features/auth/data/totp-verify.ts +22 -0
- package/src/features/auth/data/two-factor-challenge.interface.ts +7 -0
- package/src/features/auth/data/two-factor-challenge.ts +45 -0
- package/src/features/auth/data/two-factor-enable.ts +20 -0
- package/src/features/auth/data/two-factor-status.interface.ts +11 -0
- package/src/features/auth/data/two-factor-status.ts +40 -0
- package/src/features/auth/data/two-factor.service.ts +331 -0
- package/src/features/auth/enums/AuthComponent.ts +1 -0
- package/src/features/auth/index.ts +13 -0
- package/src/features/auth/passkey-authentication-options.module.ts +9 -0
- package/src/features/auth/passkey-registration-options.module.ts +9 -0
- package/src/features/auth/passkey-registration-verify.module.ts +9 -0
- package/src/features/auth/passkey-rename.module.ts +9 -0
- package/src/features/auth/passkey-verify-login.module.ts +9 -0
- package/src/features/auth/passkey.module.ts +9 -0
- package/src/features/auth/totp-authenticator.module.ts +9 -0
- package/src/features/auth/totp-setup.module.ts +9 -0
- package/src/features/auth/totp-verify-login.module.ts +9 -0
- package/src/features/auth/totp-verify.module.ts +9 -0
- package/src/features/auth/two-factor-challenge.module.ts +9 -0
- package/src/features/auth/two-factor-enable.module.ts +9 -0
- package/src/features/auth/two-factor-status.module.ts +9 -0
- package/src/features/billing/modules/billing.module.ts +1 -0
- package/src/features/billing/stripe-customer/stripe-customer.module.ts +1 -0
- package/src/features/billing/stripe-customer/stripe-payment-method.module.ts +1 -0
- package/src/features/billing/stripe-invoice/stripe-invoice.module.ts +1 -0
- package/src/features/billing/stripe-price/stripe-price.module.ts +1 -0
- package/src/features/billing/stripe-product/stripe-product.module.ts +1 -0
- package/src/features/billing/stripe-promotion-code/stripe-promotion-code.module.ts +1 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +0 -5
- package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +0 -8
- package/src/features/billing/stripe-subscription/stripe-subscription.module.ts +1 -0
- package/src/features/billing/stripe-usage/stripe-usage.module.ts +1 -0
- package/src/features/user/data/user.interface.ts +1 -0
- package/src/features/user/data/user.ts +6 -0
- package/src/features/waitlist/data/WaitlistService.ts +1 -8
- package/src/features/waitlist/waitlist-stats.module.ts +1 -0
- package/src/shadcnui/ui/resizable.tsx +33 -11
- package/src/unified/JsonApiRequest.ts +2 -1
- package/dist/AuthComponent-hxOPs9o8.d.mts +0 -11
- package/dist/AuthComponent-hxOPs9o8.d.ts +0 -11
- package/dist/JsonApiRequest-GR3L56A5.js +0 -24
- package/dist/chunk-BAOP6PTD.mjs.map +0 -1
- package/dist/chunk-FM6WRAN5.js.map +0 -1
- package/dist/chunk-GKY5DAIH.js.map +0 -1
- package/dist/chunk-GVN7XC3U.mjs.map +0 -1
- package/dist/chunk-ZNGEVB5M.js.map +0 -1
- /package/dist/{BlockNoteEditor-EKY4AHVK.mjs.map → BlockNoteEditor-B3RQ4VQ7.mjs.map} +0 -0
- /package/dist/{JsonApiRequest-K5BRU7RE.mjs.map → JsonApiRequest-XCQHVVYD.mjs.map} +0 -0
- /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(
|
|
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
|
+
});
|
|
@@ -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
|
|
58
|
+
const response = await AuthService.login({
|
|
58
59
|
email: values.email,
|
|
59
60
|
password: values.password,
|
|
60
|
-
})
|
|
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 (
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|