From ac7170a95dc724afa5f11b0be7df2d390bce6ee3 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 24 Jan 2026 19:48:15 +1100 Subject: [PATCH] Use OTP emails --- app/(auth)/accept-invite/page.tsx | 38 ++++++---- app/(auth)/sign-in/page.tsx | 52 ++++++------- bun.lock | 3 + components/auth/otp-verification-form.tsx | 92 +++++++++++++++++++++++ components/ui/input-otp.tsx | 71 +++++++++++++++++ lib/auth/client.ts | 7 +- lib/auth/index.ts | 31 ++++---- package.json | 5 +- 8 files changed, 232 insertions(+), 67 deletions(-) create mode 100644 components/auth/otp-verification-form.tsx create mode 100644 components/ui/input-otp.tsx diff --git a/app/(auth)/accept-invite/page.tsx b/app/(auth)/accept-invite/page.tsx index 2da07c8..be1d1de 100644 --- a/app/(auth)/accept-invite/page.tsx +++ b/app/(auth)/accept-invite/page.tsx @@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { authClient, useSession } from "@/lib/auth/client"; import { toast } from "sonner"; +import { OtpVerificationForm } from "@/components/auth/otp-verification-form"; export default function AcceptInvitePage() { const router = useRouter(); @@ -17,7 +18,7 @@ export default function AcceptInvitePage() { const [isLoading, setIsLoading] = useState(false); const [isAccepting, setIsAccepting] = useState(false); const [sent, setSent] = useState(false); - const { data: session, isPending } = useSession(); + const { data: session, isPending, refetch } = useSession(); async function handleAcceptInvitation() { setIsAccepting(true); @@ -33,14 +34,13 @@ export default function AcceptInvitePage() { router.push("/start"); } } catch (error) { - console.error("Accept invitation error:", error); toast.error("Failed to accept invitation"); } finally { setIsAccepting(false); } } - async function handleSignIn() { + async function handleSendOtp() { if (!email) { toast.error("Please enter your email"); return; @@ -48,25 +48,28 @@ export default function AcceptInvitePage() { setIsLoading(true); try { - const result = await authClient.signIn.magicLink({ + const result = await authClient.emailOtp.sendVerificationOtp({ email, - callbackURL: `/accept-invite?email=${encodeURIComponent(email)}${invitationId ? `&invitationId=${invitationId}` : ""}`, + type: "sign-in", }); if (result.error) { - toast.error(result.error.message || "Failed to send sign in link"); + toast.error(result.error.message || "Failed to send verification code"); } else { setSent(true); - toast.success("Check your email for the sign in link"); + toast.success("Check your email for the verification code"); } } catch (error) { - console.error("Sign in error:", error); - toast.error("Failed to send sign in link"); + toast.error("Failed to send verification code"); } finally { setIsLoading(false); } } + async function handleOtpSuccess() { + await refetch(); + } + if (isPending) { return (
@@ -105,14 +108,17 @@ export default function AcceptInvitePage() {
-

Check your email

+

Enter verification code

- We sent a sign in link to {email} -

-

- Click the link in the email to sign in and join the workspace. + We sent a 6-digit code to {email}

+ + setSent(false)} + />
); @@ -141,11 +147,11 @@ export default function AcceptInvitePage() {
diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index 7173cb1..25627e0 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -15,8 +15,10 @@ import { import { authClient } from "@/lib/auth/client"; import { toast } from "sonner"; import { Loader2, Mail } from "lucide-react"; +import { OtpVerificationForm } from "@/components/auth/otp-verification-form"; export default function SignInPage() { + const router = useRouter(); const searchParams = useSearchParams(); const redirect = searchParams.get("redirect") || "/start"; @@ -24,20 +26,24 @@ export default function SignInPage() { const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); - async function handleSubmit(e: React.FormEvent) { + async function handleSendOtp(e: React.FormEvent) { e.preventDefault(); setLoading(true); try { - await authClient.signIn.magicLink({ + const result = await authClient.emailOtp.sendVerificationOtp({ email, - callbackURL: redirect, + type: "sign-in", }); - setSent(true); - toast.success("Check your email for the sign-in link"); + + if (result.error) { + toast.error(result.error.message || "Failed to send verification code"); + } else { + setSent(true); + toast.success("Check your email for the verification code"); + } } catch (error) { - toast.error("Failed to send sign-in link. Please try again."); - console.error(error); + toast.error("Failed to send verification code. Please try again."); } finally { setLoading(false); } @@ -51,25 +57,17 @@ export default function SignInPage() {
- Check your email + Enter verification code - We sent a sign-in link to {email} + We sent a 6-digit code to {email} - -

- Click the link in the email to sign in. The link will expire in 15 - minutes. -

- + + router.push(redirect)} + onBack={() => setSent(false)} + /> @@ -82,11 +80,11 @@ export default function SignInPage() { Sign in to Manage - Enter your email to receive a sign-in link + Enter your email to receive a verification code -
+
diff --git a/bun.lock b/bun.lock index cd1606b..2137422 100644 --- a/bun.lock +++ b/bun.lock @@ -52,6 +52,7 @@ "drizzle-orm": "^0.44.6", "es-toolkit": "^1.39.8", "ical-generator": "^8.0.1", + "input-otp": "^1.4.2", "lucide-react": "^0.503.0", "mime-types": "^2.1.35", "neverthrow": "^8.2.0", @@ -1382,6 +1383,8 @@ "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], diff --git a/components/auth/otp-verification-form.tsx b/components/auth/otp-verification-form.tsx new file mode 100644 index 0000000..a5ce82f --- /dev/null +++ b/components/auth/otp-verification-form.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth/client"; +import { toast } from "sonner"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; + +interface OtpVerificationFormProps { + email: string; + onSuccess: () => void; + onBack: () => void; +} + +export function OtpVerificationForm({ + email, + onSuccess, + onBack, +}: OtpVerificationFormProps) { + const [otp, setOtp] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + async function handleVerifyOtp(e: React.FormEvent) { + e.preventDefault(); + if (otp.length !== 6) return; + + setIsLoading(true); + try { + const result = await authClient.signIn.emailOtp({ + email, + otp, + }); + + if (result.error) { + toast.error(result.error.message || "Invalid verification code"); + } else { + toast.success("Signed in successfully"); + onSuccess(); + } + } catch { + toast.error("Failed to verify code. Please try again."); + } finally { + setIsLoading(false); + } + } + + function handleBack() { + setOtp(""); + onBack(); + } + + return ( +
+
+ + + + + + + + + + +
+ + +
+ ); +} diff --git a/components/ui/input-otp.tsx b/components/ui/input-otp.tsx new file mode 100644 index 0000000..eb2a1ba --- /dev/null +++ b/components/ui/input-otp.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { Minus } from "lucide-react" + +import { cn } from "@/lib/utils" + +const InputOTP = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)) +InputOTP.displayName = "InputOTP" + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( +
+)) +InputOTPGroup.displayName = "InputOTPGroup" + +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +}) +InputOTPSlot.displayName = "InputOTPSlot" + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( +
+ +
+)) +InputOTPSeparator.displayName = "InputOTPSeparator" + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/lib/auth/client.ts b/lib/auth/client.ts index 898d32c..098a4f7 100644 --- a/lib/auth/client.ts +++ b/lib/auth/client.ts @@ -1,12 +1,9 @@ import { createAuthClient } from "better-auth/react"; -import { - magicLinkClient, - organizationClient, -} from "better-auth/client/plugins"; +import { emailOTPClient, organizationClient } from "better-auth/client/plugins"; export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_APP_URL, - plugins: [magicLinkClient(), organizationClient()], + plugins: [emailOTPClient(), organizationClient()], }); export const { diff --git a/lib/auth/index.ts b/lib/auth/index.ts index 8495993..a16a8f8 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -1,6 +1,6 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { magicLink, organization } from "better-auth/plugins"; +import { emailOTP, organization } from "better-auth/plugins"; import { Resend } from "resend"; import { database } from "@/lib/utils/useDatabase"; import { isSignupDisabled } from "@/lib/config"; @@ -28,33 +28,30 @@ export const auth = betterAuth({ }, }, plugins: [ - magicLink({ - sendMagicLink: async ({ email, url }) => { - console.log("[Magic Link] Sending to:", email); - const response = await resend.emails.send({ + emailOTP({ + otpLength: 6, + expiresIn: 600, + sendVerificationOTP: async ({ email, otp }) => { + await resend.emails.send({ from: getFromEmail(), to: email, - subject: "Sign in to Manage", + subject: "Your verification code for Manage", html: `
-

Sign in to Manage

-

Click the button below to sign in to your account:

- - Sign in - +

Your verification code

+

Enter this code to sign in to Manage:

+
+ ${otp} +

- If you didn't request this email, you can safely ignore it. + If you didn't request this code, you can safely ignore it.

- This link will expire in 15 minutes. + This code will expire in 10 minutes.

`, }); - console.log( - "[Magic Link] Resend response:", - JSON.stringify(response, null, 2), - ); }, }), organization({ diff --git a/package.json b/package.json index 9b80dae..9ea5e8a 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,14 @@ "private": true, "scripts": { "dev": "TZ=UTC next dev --turbopack", - "prebuild": "npm run db:push", + "prebuild": "npm run db:push", "build": "next build", "start": "next start", "lint": "biome lint", "format": "biome format --write", "lint:fix": "biome lint --write", "fix": "biome format --write && biome lint --write", - "db:push": "drizzle-kit push" + "db:push": "drizzle-kit push" }, "dependencies": { "@aws-sdk/client-s3": "^3.623.0", @@ -61,6 +61,7 @@ "drizzle-orm": "^0.44.6", "es-toolkit": "^1.39.8", "ical-generator": "^8.0.1", + "input-otp": "^1.4.2", "lucide-react": "^0.503.0", "mime-types": "^2.1.35", "neverthrow": "^8.2.0",