diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-07-10 08:35:32 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-07-10 08:37:44 +0000 |
| commit | 93049e864ae6d281b60c23dee868bca3f585dd4a (patch) | |
| tree | d39c0b4221486dbc82461a505f205d162a9e4def /apps/web | |
| parent | aae3ef17eccf0752edb5ce5638a58444ccb6ce3a (diff) | |
| download | karakeep-93049e864ae6d281b60c23dee868bca3f585dd4a.tar.zst | |
feat: Add support for email verification
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/app/check-email/page.tsx | 128 | ||||
| -rw-r--r-- | apps/web/app/verify-email/page.tsx | 152 | ||||
| -rw-r--r-- | apps/web/components/signin/CredentialsForm.tsx | 28 | ||||
| -rw-r--r-- | apps/web/server/auth.ts | 36 |
4 files changed, 329 insertions, 15 deletions
diff --git a/apps/web/app/check-email/page.tsx b/apps/web/app/check-email/page.tsx new file mode 100644 index 00000000..96f0afb4 --- /dev/null +++ b/apps/web/app/check-email/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/lib/trpc"; +import { Loader2, Mail } from "lucide-react"; + +export default function CheckEmailPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [message, setMessage] = useState(""); + + const email = searchParams.get("email"); + + const resendEmailMutation = api.users.resendVerificationEmail.useMutation({ + onSuccess: () => { + setMessage( + "A new verification email has been sent to your email address.", + ); + }, + onError: (error) => { + setMessage(error.message || "Failed to resend verification email."); + }, + }); + + const handleResendEmail = () => { + if (email) { + resendEmailMutation.mutate({ email }); + } + }; + + const handleBackToSignIn = () => { + router.push("/signin"); + }; + + if (!email) { + return ( + <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + Invalid Request + </CardTitle> + <CardDescription> + No email address provided. Please try signing up again. + </CardDescription> + </CardHeader> + <CardContent> + <Button onClick={handleBackToSignIn} className="w-full"> + Back to Sign In + </Button> + </CardContent> + </Card> + </div> + ); + } + + return ( + <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold">Check Your Email</CardTitle> + <CardDescription> + We've sent a verification link to your email address + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-center"> + <Mail className="h-12 w-12 text-blue-600" /> + </div> + + <div className="space-y-2 text-center"> + <p className="text-sm text-gray-600"> + We've sent a verification email to: + </p> + <p className="font-medium text-gray-900">{email}</p> + <p className="text-sm text-gray-600"> + Click the link in the email to verify your account and complete + your registration. + </p> + </div> + + {message && ( + <Alert> + <AlertDescription className="text-center"> + {message} + </AlertDescription> + </Alert> + )} + + <div className="space-y-2"> + <Button + onClick={handleResendEmail} + variant="outline" + className="w-full" + disabled={resendEmailMutation.isPending} + > + {resendEmailMutation.isPending ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Sending... + </> + ) : ( + "Resend Verification Email" + )} + </Button> + <Button + onClick={handleBackToSignIn} + variant="ghost" + className="w-full" + > + Back to Sign In + </Button> + </div> + </CardContent> + </Card> + </div> + ); +} diff --git a/apps/web/app/verify-email/page.tsx b/apps/web/app/verify-email/page.tsx new file mode 100644 index 00000000..e8792465 --- /dev/null +++ b/apps/web/app/verify-email/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/lib/trpc"; +import { CheckCircle, Loader2, XCircle } from "lucide-react"; + +export default function VerifyEmailPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [status, setStatus] = useState<"loading" | "success" | "error">( + "loading", + ); + const [message, setMessage] = useState(""); + + const token = searchParams.get("token"); + const email = searchParams.get("email"); + + const verifyEmailMutation = api.users.verifyEmail.useMutation({ + onSuccess: () => { + setStatus("success"); + setMessage( + "Your email has been successfully verified! You can now sign in.", + ); + }, + onError: (error) => { + setStatus("error"); + setMessage( + error.message || + "Failed to verify email. The link may be invalid or expired.", + ); + }, + }); + + const resendEmailMutation = api.users.resendVerificationEmail.useMutation({ + onSuccess: () => { + setMessage( + "A new verification email has been sent to your email address.", + ); + }, + onError: (error) => { + setMessage(error.message || "Failed to resend verification email."); + }, + }); + + useEffect(() => { + if (token && email) { + verifyEmailMutation.mutate({ token, email }); + } else { + setStatus("error"); + setMessage("Invalid verification link. Missing token or email."); + } + }, [token, email]); + + const handleResendEmail = () => { + if (email) { + resendEmailMutation.mutate({ email }); + } + }; + + const handleSignIn = () => { + router.push("/signin"); + }; + + return ( + <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + Email Verification + </CardTitle> + <CardDescription> + {status === "loading" && "Verifying your email address..."} + {status === "success" && "Email verified successfully!"} + {status === "error" && "Verification failed"} + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {status === "loading" && ( + <div className="flex items-center justify-center"> + <Loader2 className="h-8 w-8 animate-spin text-blue-600" /> + </div> + )} + + {status === "success" && ( + <> + <div className="flex items-center justify-center"> + <CheckCircle className="h-12 w-12 text-green-600" /> + </div> + <Alert> + <AlertDescription className="text-center"> + {message} + </AlertDescription> + </Alert> + <Button onClick={handleSignIn} className="w-full"> + Sign In + </Button> + </> + )} + + {status === "error" && ( + <> + <div className="flex items-center justify-center"> + <XCircle className="h-12 w-12 text-red-600" /> + </div> + <Alert variant="destructive"> + <AlertDescription className="text-center"> + {message} + </AlertDescription> + </Alert> + {email && ( + <div className="space-y-2"> + <Button + onClick={handleResendEmail} + variant="outline" + className="w-full" + disabled={resendEmailMutation.isPending} + > + {resendEmailMutation.isPending ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Sending... + </> + ) : ( + "Resend Verification Email" + )} + </Button> + <Button + onClick={handleSignIn} + variant="ghost" + className="w-full" + > + Back to Sign In + </Button> + </div> + )} + </> + )} + </CardContent> + </Card> + </div> + ); +} diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx index 3772db09..05aa1cef 100644 --- a/apps/web/components/signin/CredentialsForm.tsx +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -28,9 +28,11 @@ const signInSchema = z.object({ password: z.string(), }); -const SIGNIN_FAILED = "Incorrect username or password"; +const SIGNIN_FAILED = "Incorrect email or password"; const OAUTH_FAILED = "OAuth login failed: "; +const VERIFY_EMAIL_ERROR = "Please verify your email address before signing in"; + function SignIn() { const [signinError, setSigninError] = useState(""); const router = useRouter(); @@ -68,8 +70,16 @@ function SignIn() { email: value.email.trim(), password: value.password, }); - if (!resp || !resp?.ok) { - setSigninError(SIGNIN_FAILED); + if (!resp || !resp?.ok || resp.error) { + if (resp?.error === "CredentialsSignin") { + setSigninError(SIGNIN_FAILED); + } else if (resp?.error === VERIFY_EMAIL_ERROR) { + router.replace( + `/check-email?email=${encodeURIComponent(value.email.trim())}`, + ); + } else { + setSigninError(resp?.error ?? SIGNIN_FAILED); + } return; } router.replace("/"); @@ -149,8 +159,16 @@ function SignUp() { email: value.email.trim(), password: value.password, }); - if (!resp || !resp.ok) { - setErrorMessage("Hit an unexpected error while signing in"); + if (!resp || !resp.ok || resp.error) { + if (resp?.error === VERIFY_EMAIL_ERROR) { + router.replace( + `/check-email?email=${encodeURIComponent(value.email.trim())}`, + ); + } else { + setErrorMessage( + resp?.error ?? "Hit an unexpected error while signing in", + ); + } return; } router.replace("/"); diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index 3d32f702..e7b5e1cb 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -1,6 +1,6 @@ import { Adapter, AdapterUser } from "@auth/core/adapters"; import { DrizzleAdapter } from "@auth/drizzle-adapter"; -import { and, count, eq } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; import NextAuth, { DefaultSession, getServerSession, @@ -169,22 +169,38 @@ export const authOptions: NextAuthOptions = { newUser: "/signin", }, callbacks: { - async signIn({ credentials, profile }) { + async signIn({ user: credUser, credentials, profile }) { + const email = credUser.email || profile?.email; + if (!email) { + throw new Error("Provider didn't provide an email during signin"); + } + const user = await db.query.users.findFirst({ + columns: { emailVerified: true }, + where: eq(users.email, email), + }); + if (credentials) { + if (!user) { + throw new Error("Invalid credentials"); + } + if ( + serverConfig.auth.emailVerificationRequired && + !user.emailVerified + ) { + throw new Error("Please verify your email address before signing in"); + } return true; } - if (!profile?.email) { - throw new Error("No profile"); - } - const [{ count: userCount }] = await db - .select({ count: count() }) - .from(users) - .where(and(eq(users.email, profile.email))); // If it's a new user and signups are disabled, fail the sign in - if (userCount === 0 && serverConfig.auth.disableSignups) { + if (!user && serverConfig.auth.disableSignups) { throw new Error("Signups are disabled in server config"); } + + // TODO: We're blindly trusting oauth providers to validate emails + // As such, oauth users can sign in even if email verification is enabled. + // We might want to change this in the future. + return true; }, async jwt({ token, user }) { |
