@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arch-cadre/two-factor-email",
3
- "version": "1.0.7",
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.47",
30
- "@arch-cadre/modules": "^0.0.73",
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.47",
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",
47
- "@arch-cadre/intl": "^0.0.47",
48
- "@arch-cadre/ui": "^0.0.47",
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
@@ -0,0 +1,9 @@
1
+ import type messages from "../locales/en/global.json";
2
+
3
+ type JsonDataType = typeof messages;
4
+
5
+ declare module "@arch-cadre/intl" {
6
+ export interface IntlMessages extends JsonDataType {}
7
+ }
8
+
9
+ export {};
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
+ }