@arch-cadre/two-factor-email 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -7
- package/src/actions.ts +118 -0
- package/src/index.ts +80 -0
- package/src/intl.d.ts +9 -0
- package/src/routes.ts +12 -0
- package/src/schema.ts +49 -0
- package/src/ui/settings-toggle.tsx +72 -0
- package/src/ui/verify-page.tsx +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arch-cadre/two-factor-email",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Email-based two-factor authentication module for Kryo framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
+
"src",
|
|
16
17
|
"locales",
|
|
17
18
|
"manifest.json"
|
|
18
19
|
],
|
|
@@ -26,8 +27,8 @@
|
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
29
|
"@hookform/resolvers": "^3.10.0",
|
|
29
|
-
"@arch-cadre/ui": "^0.0.
|
|
30
|
-
"@arch-cadre/modules": "^0.0.
|
|
30
|
+
"@arch-cadre/ui": "^0.0.53",
|
|
31
|
+
"@arch-cadre/modules": "^0.0.79",
|
|
31
32
|
"lucide-react": "^0.475.0",
|
|
32
33
|
"react-hook-form": "^7.54.2",
|
|
33
34
|
"sonner": "^2.0.7",
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@types/pg": "^8.16.0",
|
|
38
|
-
"@arch-cadre/core": "^0.0.
|
|
39
|
+
"@arch-cadre/core": "^0.0.53",
|
|
39
40
|
"@types/react": "^19",
|
|
40
41
|
"next": "16.1.1",
|
|
41
42
|
"react": "^19.0.0",
|
|
@@ -43,9 +44,9 @@
|
|
|
43
44
|
"unbuild": "^3.6.1"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
|
-
"@arch-cadre/core": "^0.0.
|
|
47
|
-
"@arch-cadre/intl": "^0.0.
|
|
48
|
-
"@arch-cadre/ui": "^0.0.
|
|
47
|
+
"@arch-cadre/core": "^0.0.53",
|
|
48
|
+
"@arch-cadre/intl": "^0.0.53",
|
|
49
|
+
"@arch-cadre/ui": "^0.0.53",
|
|
49
50
|
"pg": "^8.16.3",
|
|
50
51
|
"drizzle-orm": "1.0.0-beta.6-4414a19",
|
|
51
52
|
"next": ">=13.0.0",
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
db,
|
|
5
|
+
eventBus,
|
|
6
|
+
finalizeLogin,
|
|
7
|
+
getCurrentSession,
|
|
8
|
+
getUserById,
|
|
9
|
+
send2FACode,
|
|
10
|
+
} from "@arch-cadre/core/server";
|
|
11
|
+
import { and, eq, gt } from "drizzle-orm";
|
|
12
|
+
import { twoFactorSettingsTable, twoFactorTokensTable } from "./schema";
|
|
13
|
+
|
|
14
|
+
export async function is2FAEnabled(userId: string) {
|
|
15
|
+
const [settings] = await db
|
|
16
|
+
.select()
|
|
17
|
+
.from(twoFactorSettingsTable)
|
|
18
|
+
.where(eq(twoFactorSettingsTable.userId, userId));
|
|
19
|
+
return settings?.enabled ?? false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function toggle2FA(enabled: boolean) {
|
|
23
|
+
const { user } = await getCurrentSession();
|
|
24
|
+
if (!user) throw new Error("Unauthorized");
|
|
25
|
+
|
|
26
|
+
await db
|
|
27
|
+
.insert(twoFactorSettingsTable)
|
|
28
|
+
.values({ userId: user.id, enabled })
|
|
29
|
+
.onConflictDoUpdate({
|
|
30
|
+
target: twoFactorSettingsTable.userId,
|
|
31
|
+
set: { enabled, updatedAt: new Date() },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await eventBus.publish(
|
|
35
|
+
"activity.create",
|
|
36
|
+
{
|
|
37
|
+
action: "2fa.toggled",
|
|
38
|
+
description: `User ${enabled ? "enabled" : "disabled"} 2FA`,
|
|
39
|
+
userId: user.id,
|
|
40
|
+
metadata: { enabled },
|
|
41
|
+
},
|
|
42
|
+
"auth:2fa",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return { success: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function generateAndSendCode(userId: string) {
|
|
49
|
+
const user = await getUserById(userId);
|
|
50
|
+
if (!user) throw new Error("User not found");
|
|
51
|
+
|
|
52
|
+
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
|
53
|
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
|
54
|
+
|
|
55
|
+
// Clear existing tokens
|
|
56
|
+
await db
|
|
57
|
+
.delete(twoFactorTokensTable)
|
|
58
|
+
.where(eq(twoFactorTokensTable.userId, userId));
|
|
59
|
+
|
|
60
|
+
await db.insert(twoFactorTokensTable).values({
|
|
61
|
+
userId,
|
|
62
|
+
code,
|
|
63
|
+
expiresAt,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await send2FACode(user.email, code);
|
|
67
|
+
|
|
68
|
+
await eventBus.publish(
|
|
69
|
+
"activity.create",
|
|
70
|
+
{
|
|
71
|
+
action: "2fa.sended",
|
|
72
|
+
description: `User sent 2FA code`,
|
|
73
|
+
userId: userId,
|
|
74
|
+
metadata: { code },
|
|
75
|
+
},
|
|
76
|
+
"auth:2fa",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return { success: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function verifyAndLogin(userId: string, code: string) {
|
|
83
|
+
const [token] = await db
|
|
84
|
+
.select()
|
|
85
|
+
.from(twoFactorTokensTable)
|
|
86
|
+
.where(
|
|
87
|
+
and(
|
|
88
|
+
eq(twoFactorTokensTable.userId, userId),
|
|
89
|
+
eq(twoFactorTokensTable.code, code),
|
|
90
|
+
gt(twoFactorTokensTable.expiresAt, new Date()),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (!token) {
|
|
95
|
+
return { error: "Invalid or expired code" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Cleanup
|
|
99
|
+
await db
|
|
100
|
+
.delete(twoFactorTokensTable)
|
|
101
|
+
.where(eq(twoFactorTokensTable.userId, userId));
|
|
102
|
+
|
|
103
|
+
// Finalize the login
|
|
104
|
+
const result = await finalizeLogin(userId, {});
|
|
105
|
+
|
|
106
|
+
await eventBus.publish(
|
|
107
|
+
"activity.create",
|
|
108
|
+
{
|
|
109
|
+
action: "2fa.verified",
|
|
110
|
+
description: `User verified 2FA`,
|
|
111
|
+
userId: userId,
|
|
112
|
+
metadata: { userId },
|
|
113
|
+
},
|
|
114
|
+
"auth:2fa",
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return { success: true, ...result };
|
|
118
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { db, registerAuthValidator } from "@arch-cadre/core/server";
|
|
2
|
+
import type { IModule } from "@arch-cadre/modules";
|
|
3
|
+
import { sql } from "drizzle-orm";
|
|
4
|
+
import manifest from "../manifest.json";
|
|
5
|
+
import { generateAndSendCode, is2FAEnabled } from "./actions";
|
|
6
|
+
|
|
7
|
+
import { publicRoutes } from "./routes";
|
|
8
|
+
import { TwoFactorSettings } from "./ui/settings-toggle";
|
|
9
|
+
|
|
10
|
+
const twoFactorEmailModule: IModule = {
|
|
11
|
+
manifest,
|
|
12
|
+
|
|
13
|
+
routes: {
|
|
14
|
+
public: publicRoutes,
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
init: async () => {
|
|
18
|
+
console.log("[Module:2FA-Email] Initializing...");
|
|
19
|
+
|
|
20
|
+
// Register Auth Interceptor
|
|
21
|
+
registerAuthValidator(async (userId) => {
|
|
22
|
+
console.log(`[Module:2FA-Email] Validating login for user: ${userId}`);
|
|
23
|
+
try {
|
|
24
|
+
const enabled = await is2FAEnabled(userId);
|
|
25
|
+
console.log(
|
|
26
|
+
`[Module:2FA-Email] 2FA enabled for user ${userId}: ${enabled}`,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (enabled) {
|
|
30
|
+
// Generate and send code before redirecting
|
|
31
|
+
console.log(`[Module:2FA-Email] Generating code for user ${userId}`);
|
|
32
|
+
await generateAndSendCode(userId);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
status: "CHALLENGE_REQUIRED",
|
|
36
|
+
type: "email_2fa",
|
|
37
|
+
userId,
|
|
38
|
+
redirect: `/auth/2fa?userId=${userId}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(
|
|
43
|
+
"[Module:2FA-Email] Error during auth validation:",
|
|
44
|
+
error,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null; // Continue standard login
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
console.log("[Module:2FA-Email] Validator registered.");
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
onDisable: async () => {
|
|
55
|
+
console.log(
|
|
56
|
+
"[Module:2FA-Email] onDisable: Dropping all tables physically...",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const tables = ["two_factor_settings", "two_factor_tokens"];
|
|
61
|
+
for (const table of tables) {
|
|
62
|
+
await db.execute(sql.raw(`DROP TABLE IF EXISTS ${table} CASCADE`));
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error("[Module:2FA-Email] onDisable Error:", e);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
extensions: [
|
|
70
|
+
{
|
|
71
|
+
id: "2fa-settings-section",
|
|
72
|
+
targetModule: "user-profile",
|
|
73
|
+
point: "settings:extra-sections",
|
|
74
|
+
component: TwoFactorSettings,
|
|
75
|
+
priority: 50,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export default twoFactorEmailModule;
|
package/src/intl.d.ts
ADDED
package/src/routes.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PublicRouteDefinition } from "@arch-cadre/modules";
|
|
2
|
+
import dynamic from "next/dynamic";
|
|
3
|
+
|
|
4
|
+
const TwoFactorVerifyPage = dynamic(() => import("./ui/verify-page"));
|
|
5
|
+
|
|
6
|
+
export const publicRoutes: PublicRouteDefinition[] = [
|
|
7
|
+
{
|
|
8
|
+
path: "/auth/2fa",
|
|
9
|
+
component: TwoFactorVerifyPage,
|
|
10
|
+
auth: false,
|
|
11
|
+
},
|
|
12
|
+
];
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { userTable } from "@arch-cadre/core";
|
|
2
|
+
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
3
|
+
// import { defineRelations } from "drizzle-orm";
|
|
4
|
+
|
|
5
|
+
export const twoFactorSettingsTable = pgTable("two_factor_settings", {
|
|
6
|
+
userId: text("user_id")
|
|
7
|
+
.primaryKey()
|
|
8
|
+
.references(() => userTable.id, { onDelete: "cascade" }),
|
|
9
|
+
enabled: boolean("enabled").notNull().default(false),
|
|
10
|
+
updatedAt: timestamp("updated_at", { precision: 3 }).defaultNow(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const twoFactorTokensTable = pgTable("two_factor_tokens", {
|
|
14
|
+
id: text("id")
|
|
15
|
+
.$defaultFn(() => crypto.randomUUID())
|
|
16
|
+
.notNull()
|
|
17
|
+
.primaryKey(),
|
|
18
|
+
userId: text("user_id")
|
|
19
|
+
.notNull()
|
|
20
|
+
.references(() => userTable.id, { onDelete: "cascade" }),
|
|
21
|
+
code: text("code").notNull(),
|
|
22
|
+
expiresAt: timestamp("expires_at", { precision: 3 }).notNull(),
|
|
23
|
+
createdAt: timestamp("created_at", { precision: 3 }).notNull().defaultNow(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const twoFactorSchema = {
|
|
27
|
+
twoFactorSettingsTable,
|
|
28
|
+
twoFactorTokensTable,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// export const relations = defineRelations(
|
|
32
|
+
// {
|
|
33
|
+
// user: userTable,
|
|
34
|
+
// twoFactorSettings: twoFactorSettingsTable,
|
|
35
|
+
// twoFactorTokens: twoFactorTokensTable,
|
|
36
|
+
// },
|
|
37
|
+
// (r) => ({
|
|
38
|
+
// user: {
|
|
39
|
+
// twoFactorTokens: r.many.twoFactorTokens({
|
|
40
|
+
// from: r.user.id,
|
|
41
|
+
// to: r.twoFactorTokens.userId,
|
|
42
|
+
// }),
|
|
43
|
+
// twoFactorSettings: r.one.twoFactorSettings({
|
|
44
|
+
// from: r.user.id,
|
|
45
|
+
// to: r.twoFactorSettings.userId,
|
|
46
|
+
// }),
|
|
47
|
+
// },
|
|
48
|
+
// }),
|
|
49
|
+
// );
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslation } from "@arch-cadre/intl";
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardDescription,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
} from "@arch-cadre/ui/components/card";
|
|
11
|
+
import { Label } from "@arch-cadre/ui/components/label";
|
|
12
|
+
import { Switch } from "@arch-cadre/ui/components/switch";
|
|
13
|
+
import { useUser } from "@arch-cadre/ui/hooks/use-user";
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
import { useEffect, useState } from "react";
|
|
16
|
+
import { toast } from "sonner";
|
|
17
|
+
import { is2FAEnabled, toggle2FA } from "../actions";
|
|
18
|
+
|
|
19
|
+
export function TwoFactorSettings() {
|
|
20
|
+
const { user } = useUser();
|
|
21
|
+
const { t } = useTranslation();
|
|
22
|
+
const [enabled, setEnabled] = useState(false);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (user) {
|
|
27
|
+
is2FAEnabled(user.id).then((val) => {
|
|
28
|
+
setEnabled(val);
|
|
29
|
+
setLoading(false);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}, [user]);
|
|
33
|
+
|
|
34
|
+
const handleToggle = async (val: boolean) => {
|
|
35
|
+
try {
|
|
36
|
+
await toggle2FA(val);
|
|
37
|
+
setEnabled(val);
|
|
38
|
+
toast.success(val ? t("2FA enabled") : t("2FA disabled"));
|
|
39
|
+
} catch {
|
|
40
|
+
toast.error(t("Action failed"));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (loading) return null;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Card>
|
|
48
|
+
<CardHeader>
|
|
49
|
+
<CardTitle>{t("Two Factor Authentication")}</CardTitle>
|
|
50
|
+
<CardDescription>
|
|
51
|
+
{t(
|
|
52
|
+
"Add an extra layer of security to your account by requiring a code sent to your email.",
|
|
53
|
+
)}
|
|
54
|
+
</CardDescription>
|
|
55
|
+
</CardHeader>
|
|
56
|
+
<CardContent className="flex items-center justify-between">
|
|
57
|
+
<Label htmlFor="2fa-toggle" className="flex flex-col ">
|
|
58
|
+
<span>{t("Email Authentication")}</span>
|
|
59
|
+
<span className="font-normal text-xs text-muted-foreground">
|
|
60
|
+
{enabled ? t("Currently enabled") : t("Currently disabled")}
|
|
61
|
+
</span>
|
|
62
|
+
</Label>
|
|
63
|
+
|
|
64
|
+
<Switch
|
|
65
|
+
id="2fa-toggle"
|
|
66
|
+
checked={enabled}
|
|
67
|
+
onCheckedChange={handleToggle}
|
|
68
|
+
/>
|
|
69
|
+
</CardContent>
|
|
70
|
+
</Card>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslation } from "@arch-cadre/intl";
|
|
4
|
+
import { Button } from "@arch-cadre/ui/components/button";
|
|
5
|
+
import {
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
} from "@arch-cadre/ui/components/card";
|
|
12
|
+
import { Input } from "@arch-cadre/ui/components/input";
|
|
13
|
+
import { Loader } from "@arch-cadre/ui/shared/loader";
|
|
14
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { toast } from "sonner";
|
|
18
|
+
import { generateAndSendCode, verifyAndLogin } from "../actions";
|
|
19
|
+
|
|
20
|
+
export default function TwoFactorVerifyPage() {
|
|
21
|
+
const { t } = useTranslation();
|
|
22
|
+
const router = useRouter();
|
|
23
|
+
const searchParams = useSearchParams();
|
|
24
|
+
const userId = searchParams.get("userId");
|
|
25
|
+
const [code, setCode] = useState("");
|
|
26
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
27
|
+
|
|
28
|
+
if (!userId) {
|
|
29
|
+
router.push("/signin");
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
setIsSubmitting(true);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await verifyAndLogin(userId, code);
|
|
39
|
+
if (result.error === null) {
|
|
40
|
+
toast.success(t("Logged in successfully"));
|
|
41
|
+
window.location.href = "/"; // Force full reload to update session state
|
|
42
|
+
} else {
|
|
43
|
+
toast.error(result.error || t("Verification failed"));
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
toast.error(t("An error occurred"));
|
|
47
|
+
} finally {
|
|
48
|
+
setIsSubmitting(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleResend = async () => {
|
|
53
|
+
try {
|
|
54
|
+
await generateAndSendCode(userId);
|
|
55
|
+
toast.success(t("New code sent to your email"));
|
|
56
|
+
} catch {
|
|
57
|
+
toast.error(t("Failed to send code"));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex min-h-screen items-center justify-center bg-muted/50 p-4">
|
|
63
|
+
<Card className="w-full max-w-md">
|
|
64
|
+
<CardHeader className="text-center">
|
|
65
|
+
<CardTitle className="text-2xl">
|
|
66
|
+
{t("Two Factor Verification")}
|
|
67
|
+
</CardTitle>
|
|
68
|
+
<CardDescription>
|
|
69
|
+
{t(
|
|
70
|
+
"Enter the 6-digit code sent to your email address to continue.",
|
|
71
|
+
)}
|
|
72
|
+
</CardDescription>
|
|
73
|
+
</CardHeader>
|
|
74
|
+
<CardContent>
|
|
75
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
76
|
+
<div className="space-y-2">
|
|
77
|
+
<Input
|
|
78
|
+
type="text"
|
|
79
|
+
maxLength={6}
|
|
80
|
+
value={code}
|
|
81
|
+
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
|
82
|
+
placeholder="000000"
|
|
83
|
+
className="text-center text-2xl tracking-[0.5em] font-mono h-14"
|
|
84
|
+
autoFocus
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<Button
|
|
88
|
+
type="submit"
|
|
89
|
+
className="w-full h-11"
|
|
90
|
+
disabled={isSubmitting || code.length !== 6}
|
|
91
|
+
>
|
|
92
|
+
{isSubmitting ? <Loader variant="dark" /> : t("Verify & Signin")}
|
|
93
|
+
</Button>
|
|
94
|
+
<Button
|
|
95
|
+
type="button"
|
|
96
|
+
variant="ghost"
|
|
97
|
+
className="w-full"
|
|
98
|
+
onClick={handleResend}
|
|
99
|
+
>
|
|
100
|
+
{t("Didn't receive a code? Resend")}
|
|
101
|
+
</Button>
|
|
102
|
+
</form>
|
|
103
|
+
</CardContent>
|
|
104
|
+
</Card>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|