From 67527c2f52e76725ad78719d4b0307e702bd0da1 Mon Sep 17 00:00:00 2001
From: schererleander <leander@schererleander.de>
Date: Fri, 26 Dec 2025 16:24:36 +0100
Subject: feat(2fa): implement google authenticator 2fa

- add otplib and qrcode dependencies
- update user model with 2fa fields
- add twoFactorCode to validation schema
- implement api routes for setup, enable, disable
- add 2fa verification in auth flow
- add 2fa management ui in settings
- implement 2fa challenge in login page
---
 src/app/login/page.tsx | 176 ++++++++++++++++++++++++++++++++-----------------
 1 file changed, 114 insertions(+), 62 deletions(-)

(limited to 'src/app/login/page.tsx')

diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index ae89e63..c8a80f1 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -22,11 +22,14 @@ export default function SignInPage() {
   const [error, setError] = useState("")
   const router = useRouter()
 
+  const [showTwoFactor, setShowTwoFactor] = useState(false)
+
   const form = useForm<LoginInput>({
     resolver: zodResolver(loginSchema),
     defaultValues: {
       email: "",
       password: "",
+      twoFactorCode: "",
     },
   })
 
@@ -38,11 +41,17 @@ export default function SignInPage() {
       const result = await signIn("credentials", {
         email: data.email,
         password: data.password,
+        twoFactorCode: data.twoFactorCode,
         redirect: false,
       })
 
       if (result?.error) {
-        setError("Invalid email or password")
+        if (result.error === "2FA_REQUIRED") {
+          setShowTwoFactor(true)
+          // Don't clear password here so user can just enter code
+        } else {
+          setError(result.error)
+        }
       } else if (result?.ok) {
         router.push("/")
         router.refresh()
@@ -62,7 +71,9 @@ export default function SignInPage() {
         <CardHeader className="space-y-1">
           <CardTitle className="text-2xl text-center">Sign In</CardTitle>
           <CardDescription className="text-center">
-            Enter your email and password to access your account
+            {showTwoFactor 
+              ? "Enter the code from your authenticator app"
+              : "Enter your email and password to access your account"}
           </CardDescription>
         </CardHeader>
         <CardContent className="space-y-4">
@@ -74,76 +85,117 @@ export default function SignInPage() {
 
           <Form {...form}>
             <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-              <FormField
-                control={form.control}
-                name="email"
-                render={({ field }) => (
-                  <FormItem>
-                    <FormLabel>Email</FormLabel>
-                    <FormControl>
-                      <Input
-                        type="email"
-                        placeholder="john@example.com"
-                        autoComplete="email"
-                        {...field}
-                      />
-                    </FormControl>
-                    <FormMessage />
-                  </FormItem>
-                )}
-              />
-              <FormField
-                control={form.control}
-                name="password"
-                render={({ field }) => (
-                  <FormItem>
-                    <FormLabel>Password</FormLabel>
-                    <FormControl>
-                      <div className="relative">
+              {!showTwoFactor ? (
+                <>
+                  <FormField
+                    control={form.control}
+                    name="email"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>Email</FormLabel>
+                        <FormControl>
+                          <Input
+                            type="email"
+                            placeholder="john@example.com"
+                            autoComplete="email"
+                            {...field}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                  <FormField
+                    control={form.control}
+                    name="password"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>Password</FormLabel>
+                        <FormControl>
+                          <div className="relative">
+                            <Input
+                              type={showPassword ? "text" : "password"}
+                              placeholder="Enter your password"
+                              autoComplete="current-password"
+                              {...field}
+                            />
+                            <Button
+                              type="button"
+                              variant="ghost"
+                              size="sm"
+                              className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
+                              onClick={() => setShowPassword(!showPassword)}
+                            >
+                              {showPassword ? (
+                                <EyeOff className="h-4 w-4" />
+                              ) : (
+                                <Eye className="h-4 w-4" />
+                              )}
+                            </Button>
+                          </div>
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </>
+              ) : (
+                <FormField
+                  control={form.control}
+                  name="twoFactorCode"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>Two-Factor Code</FormLabel>
+                      <FormControl>
                         <Input
-                          type={showPassword ? "text" : "password"}
-                          placeholder="Enter your password"
-                          autoComplete="current-password"
+                          placeholder="000000"
+                          autoComplete="one-time-code"
+                          maxLength={6}
                           {...field}
+                          autoFocus
                         />
-                        <Button
-                          type="button"
-                          variant="ghost"
-                          size="sm"
-                          className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
-                          onClick={() => setShowPassword(!showPassword)}
-                        >
-                          {showPassword ? (
-                            <EyeOff className="h-4 w-4" />
-                          ) : (
-                            <Eye className="h-4 w-4" />
-                          )}
-                        </Button>
-                      </div>
-                    </FormControl>
-                    <FormMessage />
-                  </FormItem>
-                )}
-              />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              )}
+              
               <Button type="submit" className="w-full" disabled={isLoading}>
                 {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                Sign In
+                {showTwoFactor ? "Verify" : "Sign In"}
               </Button>
             </form>
           </Form>
 
-          <div className="text-center text-sm">
-            <span className="text-muted-foreground">Don&apos;t have an account? </span>
-            <Link href="/signup" className="text-primary hover:underline font-medium">
-              Sign up
-            </Link>
-          </div>
+          {!showTwoFactor && (
+            <>
+              <div className="text-center text-sm">
+                <span className="text-muted-foreground">Don&apos;t have an account? </span>
+                <Link href="/signup" className="text-primary hover:underline font-medium">
+                  Sign up
+                </Link>
+              </div>
 
-          <div className="text-center">
-            <Button variant="link" className="text-sm text-muted-foreground">
-              Forgot your password?
-            </Button>
-          </div>
+              <div className="text-center">
+                <Button variant="link" className="text-sm text-muted-foreground">
+                  Forgot your password?
+                </Button>
+              </div>
+            </>
+          )}
+          
+          {showTwoFactor && (
+             <div className="text-center">
+                <Button 
+                  variant="link" 
+                  className="text-sm text-muted-foreground"
+                  onClick={() => setShowTwoFactor(false)}
+                >
+                  Back to login
+                </Button>
+              </div>
+          )}
         </CardContent>
       </Card>
     </div>
-- 
cgit v1.3.1

