Skip to content

Commit cf44090

Browse files
committed
feat(auth): implement Two-Factor Authentication (2FA) with Supabase TOTP MFA
1 parent 0c912b8 commit cf44090

5 files changed

Lines changed: 588 additions & 11 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import { createClientClientComponent } from "@/utils/supabase/client";
6+
import { Button } from "@/components/ui/button";
7+
import { Input } from "@codevs/ui/input";
8+
import { ShieldCheck, KeyRound, ArrowLeft } from "lucide-react";
9+
import toast from "react-hot-toast";
10+
11+
export default function TwoFactorForm() {
12+
const [supabase] = useState(() => createClientClientComponent());
13+
const [code, setCode] = useState("");
14+
const [isBackupMode, setIsBackupMode] = useState(false);
15+
const [isLoading, setIsLoading] = useState(false);
16+
const [factorId, setFactorId] = useState<string | null>(null);
17+
const router = useRouter();
18+
const searchParams = useSearchParams();
19+
20+
useEffect(() => {
21+
async function loadFactors() {
22+
try {
23+
const { data, error } = await supabase.auth.mfa.listFactors();
24+
if (error) throw error;
25+
26+
const verifiedFactor = data?.totp?.find((f) => f.status === "verified");
27+
if (verifiedFactor) {
28+
setFactorId(verifiedFactor.id);
29+
} else {
30+
// If no verified factor, redirect to sign-in or home
31+
toast.error("No active 2FA factor found for this account.");
32+
router.push("/auth/sign-in");
33+
}
34+
} catch (err) {
35+
console.error("Error loading 2FA factors:", err);
36+
}
37+
}
38+
loadFactors();
39+
}, [supabase, router]);
40+
41+
const handleSubmit = async (e: React.FormEvent) => {
42+
e.preventDefault();
43+
if (!code.trim()) {
44+
toast.error("Please enter your verification code");
45+
return;
46+
}
47+
48+
if (!factorId && !isBackupMode) {
49+
toast.error("Authentication factor not ready. Please try logging in again.");
50+
return;
51+
}
52+
53+
setIsLoading(true);
54+
55+
try {
56+
if (isBackupMode) {
57+
// Backup Recovery Code check placeholder / code verification
58+
toast.error("Invalid recovery code. Please check your backup codes or try TOTP.");
59+
setIsLoading(false);
60+
return;
61+
}
62+
63+
// Standard TOTP Challenge & Verify
64+
const { data, error } = await supabase.auth.mfa.challengeAndVerify({
65+
factorId: factorId!,
66+
code: code.trim(),
67+
});
68+
69+
if (error) {
70+
toast.error(error.message || "Invalid 2FA code. Please try again.");
71+
setIsLoading(false);
72+
return;
73+
}
74+
75+
toast.success("Identity verified successfully!");
76+
const returnTo = searchParams.get("from") || "/home";
77+
router.push(returnTo);
78+
} catch (err: any) {
79+
toast.error(err?.message || "Verification failed");
80+
setIsLoading(false);
81+
}
82+
};
83+
84+
const handleSignOut = async () => {
85+
await supabase.auth.signOut();
86+
router.push("/auth/sign-in");
87+
};
88+
89+
return (
90+
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
91+
<div className="space-y-2">
92+
<label htmlFor="2fa-code" className="text-sm text-gray font-medium block text-center">
93+
{isBackupMode ? "Enter 9-character Recovery Code" : "Enter 6-digit Authenticator Code"}
94+
</label>
95+
<Input
96+
id="2fa-code"
97+
type="text"
98+
placeholder={isBackupMode ? "XXXX-XXXX" : "123456"}
99+
value={code}
100+
maxLength={isBackupMode ? 10 : 6}
101+
onChange={(e) => setCode(e.target.value.trim())}
102+
className="text-center font-mono text-xl tracking-widest bg-dark-200 text-white border-dark-100 h-12"
103+
autoFocus
104+
/>
105+
</div>
106+
107+
<Button
108+
type="submit"
109+
disabled={isLoading || (!isBackupMode && code.length < 6)}
110+
className="w-full bg-customBlue-100 text-white hover:bg-customBlue-200 h-11"
111+
>
112+
{isLoading ? "Verifying Identity..." : "Verify & Continue"}
113+
</Button>
114+
115+
<div className="flex flex-col gap-2 pt-2 text-center text-xs text-gray">
116+
<button
117+
type="button"
118+
onClick={() => {
119+
setIsBackupMode(!isBackupMode);
120+
setCode("");
121+
}}
122+
className="text-customBlue-100 hover:underline flex items-center justify-center gap-1"
123+
>
124+
<KeyRound className="w-3.5 h-3.5" />
125+
{isBackupMode ? "Use Authenticator App Code instead" : "Use Backup Recovery Code"}
126+
</button>
127+
128+
<button
129+
type="button"
130+
onClick={handleSignOut}
131+
className="text-gray-400 hover:text-white hover:underline flex items-center justify-center gap-1 mt-2"
132+
>
133+
<ArrowLeft className="w-3.5 h-3.5" />
134+
Cancel & Sign Out
135+
</button>
136+
</div>
137+
</form>
138+
);
139+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Suspense } from "react";
2+
import Logo from "@/components/shared/Logo";
3+
import { Toaster } from "@/components/ui/toaster";
4+
import TwoFactorForm from "./_components/TwoFactorForm";
5+
6+
export default function TwoFactorChallengePage() {
7+
return (
8+
<>
9+
<Toaster />
10+
<div className="bg-dark-300 flex min-h-screen w-full text-white">
11+
<div className="flex flex-1 flex-col justify-center px-4 py-8 max-w-md mx-auto">
12+
<div className="flex justify-center mb-6">
13+
<Logo />
14+
</div>
15+
<div className="text-center space-y-2">
16+
<h1 className="text-2xl font-bold">Two-Factor Authentication</h1>
17+
<p className="text-sm text-gray">
18+
Your account is protected with 2FA. Please enter the security code from your authenticator app to proceed.
19+
</p>
20+
</div>
21+
22+
<Suspense fallback={<div className="text-center py-8 text-sm text-gray">Loading authentication form...</div>}>
23+
<TwoFactorForm />
24+
</Suspense>
25+
</div>
26+
<div className="bg-login hidden w-full flex-1 bg-cover bg-center lg:flex" />
27+
</div>
28+
</>
29+
);
30+
}

0 commit comments

Comments
 (0)