feat: enhance authentication flow with improved email verification and password reset

This commit is contained in:
javayhu 2025-02-23 11:53:53 +08:00
parent ecaf761b61
commit 940b8e9dfc
11 changed files with 180 additions and 202 deletions

View File

@ -1,5 +1,5 @@
{
"name": "mksaas-demo",
"name": "mksaas-template",
"version": "0.1.0",
"private": true,
"scripts": {

View File

@ -1,15 +0,0 @@
import { NewVerificationForm } from "@/components/auth/new-verification-form";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
export const metadata = constructMetadata({
title: "New Verification",
description: "New Verification",
canonicalUrl: `${siteConfig.url}/auth/new-verification`,
});
const NewVerificationPage = () => {
return <NewVerificationForm />;
};
export default NewVerificationPage;

View File

@ -1,4 +1,4 @@
import { ResetForm } from "@/components/auth/reset-form";
import { ResetPasswordForm } from "@/components/auth/reset-form";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
@ -9,7 +9,7 @@ export const metadata = constructMetadata({
});
const ResetPage = () => {
return <ResetForm />;
return <ResetPasswordForm />;
};
export default ResetPage;

View File

@ -1,6 +1,5 @@
"use client";
// import { login } from "@/actions/login";
import { AuthCard } from "@/components/auth/auth-card";
import { Icons } from "@/components/icons/icons";
import { FormError } from "@/components/shared/form-error";
@ -15,11 +14,12 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { LoginSchema } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import type * as z from "zod";
@ -32,9 +32,11 @@ export const LoginForm = ({ className }: { className?: string }) => {
? "Email already in use with different provider!"
: "";
const router = useRouter();
const [error, setError] = useState<string | undefined>("");
const [success, setSuccess] = useState<string | undefined>("");
const [isPending, startTransition] = useTransition();
const [isPending, setIsPending] = useState(false);
const form = useForm<z.infer<typeof LoginSchema>>({
resolver: zodResolver(LoginSchema),
@ -44,36 +46,35 @@ export const LoginForm = ({ className }: { className?: string }) => {
},
});
const onSubmit = (values: z.infer<typeof LoginSchema>) => {
setError("");
setSuccess("");
// startTransition(() => {
// login(values, callbackUrl)
// .then((data) => {
// // console.log('login, data:', data);
// if (data?.status === "error") {
// console.log("login, error:", data.message);
// form.reset();
// setError(data.message);
// }
// if (data?.status === "success") {
// console.log("login, success:", data.message);
// form.reset();
// setSuccess(data.message);
// // if success without redirect url, means sent confirmation email
// if (data.redirectUrl) {
// window.location.href = data.redirectUrl;
// }
// }
// })
// .catch((error) => {
// console.log("login, error:", error);
// setError("Something went wrong");
// });
// });
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
// 1. if callbackUrl is provided, user will be redirected to the callbackURL after login successfully.
// if user email is not verified, a new verification email will be sent to the user with the callbackURL.
// 2. if callbackUrl is not provided, we should redirect manually in the onSuccess callback.
const { data, error } = await authClient.signIn.email({
email: values.email,
password: values.password,
callbackURL: callbackUrl || "/dashboard",
}, {
onRequest: (ctx) => {
// console.log("login, request:", ctx.url);
setIsPending(true);
setError("");
setSuccess("");
},
onResponse: (ctx) => {
// console.log("login, response:", ctx.response);
setIsPending(false);
},
onSuccess: (ctx) => {
// console.log("login, success:", ctx.data);
// setSuccess("Login successful");
// router.push(callbackUrl || "/dashboard");
},
onError: (ctx) => {
console.log("login, error:", ctx.error);
setError(ctx.error.message);
},
});
};
return (
@ -118,7 +119,7 @@ export const LoginForm = ({ className }: { className?: string }) => {
asChild
className="px-0 font-normal text-muted-foreground"
>
<Link href="/auth/reset" className="text-xs underline">
<Link href="/auth/reset-password" className="text-xs underline">
Forgot password?
</Link>
</Button>

View File

@ -1,6 +1,5 @@
"use client";
// import { newPassword } from "@/actions/new-password";
import { AuthCard } from "@/components/auth/auth-card";
import { Icons } from "@/components/icons/icons";
import { FormError } from "@/components/shared/form-error";
@ -15,9 +14,10 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { NewPasswordSchema } from "@/lib/schemas";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import type * as z from "zod";
@ -25,10 +25,16 @@ import type * as z from "zod";
export const NewPasswordForm = () => {
const searchParams = useSearchParams();
const token = searchParams.get("token");
if (!token) {
// TODO: Handle the error
return <div>Invalid token</div>;
}
const router = useRouter();
const [error, setError] = useState<string | undefined>("");
const [success, setSuccess] = useState<string | undefined>("");
const [isPending, startTransition] = useTransition();
const [isPending, setIsPending] = useState(false);
const form = useForm<z.infer<typeof NewPasswordSchema>>({
resolver: zodResolver(NewPasswordSchema),
@ -37,28 +43,31 @@ export const NewPasswordForm = () => {
},
});
const onSubmit = (values: z.infer<typeof NewPasswordSchema>) => {
setError("");
setSuccess("");
// startTransition(() => {
// newPassword(values, token)
// .then((data) => {
// if (data?.status === "error") {
// console.log("newPassword, error:", data.message);
// setError(data.message);
// }
// if (data?.status === "success") {
// console.log("newPassword, success:", data.message);
// setSuccess(data.message);
// }
// })
// .catch(() => {
// console.log("newPassword, error:", error);
// setError("Something went wrong");
// });
// });
const onSubmit = async (values: z.infer<typeof NewPasswordSchema>) => {
const { data, error } = await authClient.resetPassword({
newPassword: values.password,
token,
}, {
onRequest: (ctx) => {
// console.log("resetPassword, request:", ctx.url);
setIsPending(true);
setError("");
setSuccess("");
},
onResponse: (ctx) => {
// console.log("resetPassword, response:", ctx.response);
setIsPending(false);
},
onSuccess: (ctx) => {
// console.log("resetPassword, success:", ctx.data);
// setSuccess("Password reset successfully");
router.push("/auth/login");
},
onError: (ctx) => {
console.log("resetPassword, error:", ctx.error);
setError(ctx.error.message);
},
});
};
return (

View File

@ -1,62 +0,0 @@
"use client";
// import { newVerification } from "@/actions/new-verification";
import { AuthCard } from "@/components/auth/auth-card";
import { Icons } from "@/components/icons/icons";
import { FormError } from "@/components/shared/form-error";
import { FormSuccess } from "@/components/shared/form-success";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
export const NewVerificationForm = () => {
const [error, setError] = useState<string | undefined>();
const [success, setSuccess] = useState<string | undefined>();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const onSubmit = useCallback(() => {
if (success || error) return;
if (!token) {
setError("Missing token!");
return;
}
// newVerification(token)
// .then((data) => {
// if (data.status === "success") {
// setSuccess(data.message);
// console.log("newVerification, success:", data.message);
// }
// if (data.status === "error") {
// setError(data.message);
// console.log("newVerification, error:", data.message);
// }
// })
// .catch(() => {
// setError("Something went wrong");
// });
}, [token, success, error]);
useEffect(() => {
onSubmit();
}, [onSubmit]);
return (
<AuthCard
headerLabel="Confirming your verification"
bottomButtonLabel="Back to login"
bottomButtonHref="/auth/login"
className="border-none"
>
<div className="flex items-center w-full justify-center">
{!success && !error && (
<Icons.spinner className="w-4 h-4 animate-spin" />
)}
<FormSuccess message={success} />
{!success && <FormError message={error} />}
</div>
</AuthCard>
);
};

View File

@ -1,6 +1,5 @@
"use client";
// import { register } from "@/actions/register";
import { AuthCard } from "@/components/auth/auth-card";
import { Icons } from "@/components/icons/icons";
import { FormError } from "@/components/shared/form-error";
@ -15,8 +14,10 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { RegisterSchema } from "@/lib/schemas";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import type * as z from "zod";
@ -24,7 +25,8 @@ import type * as z from "zod";
export const RegisterForm = () => {
const [error, setError] = useState<string | undefined>("");
const [success, setSuccess] = useState<string | undefined>("");
const [isPending, startTransition] = useTransition();
const [isPending, setIsPending] = useState(false);
const router = useRouter();
const form = useForm<z.infer<typeof RegisterSchema>>({
resolver: zodResolver(RegisterSchema),
@ -35,27 +37,38 @@ export const RegisterForm = () => {
},
});
const onSubmit = (values: z.infer<typeof RegisterSchema>) => {
setError("");
setSuccess("");
// startTransition(() => {
// register(values)
// .then((data) => {
// if (data.status === "error") {
// console.log("register, error:", data.message);
// setError(data.message);
// }
// if (data.status === "success") {
// console.log("register, success:", data.message);
// setSuccess(data.message);
// }
// })
// .catch((error) => {
// console.log("register, error:", error);
// setError("Something went wrong");
// });
// });
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
// 1. if requireEmailVerification is true, callbackURL will be used in the verification email,
// the user will be redirected to the callbackURL after the email is verified.
// 2. if requireEmailVerification is false, the user will not be redirected to the callbackURL,
// we should redirect to the callbackURL manually in the onSuccess callback.
const { data, error } = await authClient.signUp.email({
email: values.email,
password: values.password,
name: values.name,
callbackURL: "/dashboard",
}, {
onRequest: (ctx) => {
console.log("register, request:", ctx.url);
setIsPending(true);
setError("");
setSuccess("");
},
onResponse: (ctx) => {
console.log("register, response:", ctx.response);
setIsPending(false);
},
onSuccess: (ctx) => {
// sign up success, user information stored in ctx.data
// console.log("register, success:", ctx.data);
setSuccess('Please check your email for verification');
},
onError: (ctx) => {
// sign up fail, display the error message
console.log("register, error:", ctx.error.message);
setError(ctx.error.message);
},
});
};
return (

View File

@ -1,6 +1,5 @@
"use client";
// import { reset } from "@/actions/reset";
import { AuthCard } from "@/components/auth/auth-card";
import { FormError } from "@/components/shared/form-error";
import { FormSuccess } from "@/components/shared/form-success";
@ -14,46 +13,50 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ResetSchema } from "@/lib/schemas";
import { ResetPasswordSchema } from "@/lib/schemas";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import type * as z from "zod";
import { Icons } from "../icons/icons";
import { Icons } from "@/components/icons/icons";
import { authClient } from "@/lib/auth-client";
export const ResetForm = () => {
export const ResetPasswordForm = () => {
const [error, setError] = useState<string | undefined>("");
const [success, setSuccess] = useState<string | undefined>("");
const [isPending, startTransition] = useTransition();
const [isPending, setIsPending] = useState(false);
const form = useForm<z.infer<typeof ResetSchema>>({
resolver: zodResolver(ResetSchema),
const form = useForm<z.infer<typeof ResetPasswordSchema>>({
resolver: zodResolver(ResetPasswordSchema),
defaultValues: {
email: "",
},
});
const onSubmit = (values: z.infer<typeof ResetSchema>) => {
setError("");
setSuccess("");
// startTransition(() => {
// reset(values)
// .then((data) => {
// if (data.status === "error") {
// console.log("reset, error:", data.message);
// setError(data.message);
// }
// if (data.status === "success") {
// console.log("reset, success:", data.message);
// setSuccess(data.message);
// }
// })
// .catch((error) => {
// console.log("reset, error:", error);
// setError("Something went wrong");
// });
// });
const onSubmit = async (values: z.infer<typeof ResetPasswordSchema>) => {
const { data, error } = await authClient.forgetPassword({
email: values.email,
redirectTo: "/auth/new-password",
}, {
onRequest: (ctx) => {
// console.log("reset, request:", ctx.url);
setIsPending(true);
setError("");
setSuccess("");
},
onResponse: (ctx) => {
// console.log("reset, response:", ctx.response);
setIsPending(false);
},
onSuccess: (ctx) => {
// console.log("reset, success:", ctx.data);
setSuccess("Please check your email for the reset password link");
},
onError: (ctx) => {
console.log("reset, error:", ctx.error);
setError(ctx.error.message);
},
});
};
return (

View File

@ -2,8 +2,13 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import db from "@/db/index";
import { user, session, account, verification } from "@/db/schema";
import { siteConfig } from "@/config/site";
import { resend } from "@/lib/email/resend";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
export const auth = betterAuth({
appName: siteConfig.name,
database: drizzleAdapter(db, {
provider: "pg", // or "mysql", "sqlite"
// The schema object that defines the tables and fields
@ -17,7 +22,28 @@ export const auth = betterAuth({
},
}),
emailAndPassword: {
enabled: true
enabled: true,
requireEmailVerification: true,
// https://www.better-auth.com/docs/authentication/email-password#forget-password
async sendResetPassword({ user, url }) {
await resend.emails.send({
from,
to: user.email,
subject: "Reset your password",
react: `Click the link to reset your password: ${url}`,
});
},
},
emailVerification: {
// https://www.better-auth.com/docs/authentication/email-password#require-email-verification
sendVerificationEmail: async ( { user, url, token }, request) => {
await resend.emails.send({
from,
to: user.email,
subject: "Verify your email address",
text: `Click the link to verify your email: ${url}`,
});
},
},
socialProviders: {
github: {

3
src/lib/email/resend.ts Normal file
View File

@ -0,0 +1,3 @@
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);

View File

@ -103,14 +103,14 @@ export type UserLinkData = z.infer<typeof UserLinkSchema>;
export const UserPasswordSchema = z
.object({
password: z.string().min(6, {
message: "Minimum of 6 characters required",
password: z.string().min(8, {
message: "Minimum 8 characters required",
}),
newPassword: z.string().min(6, {
message: "Minimum of 6 characters required",
newPassword: z.string().min(8, {
message: "Minimum 8 characters required",
}),
confirmPassword: z.string().min(6, {
message: "Minimum of 6 characters required",
confirmPassword: z.string().min(8, {
message: "Minimum 8 characters required",
}),
})
.refine(
@ -159,12 +159,12 @@ export type UserPasswordData = z.infer<typeof UserPasswordSchema>;
* auth related schemas
*/
export const NewPasswordSchema = z.object({
password: z.string().min(6, {
message: "Minimum of 6 characters required",
password: z.string().min(8, {
message: "Minimum 8 characters required",
}),
});
export const ResetSchema = z.object({
export const ResetPasswordSchema = z.object({
email: z.string().email({
message: "Email is required",
}),
@ -183,8 +183,8 @@ export const RegisterSchema = z.object({
email: z.string().email({
message: "Email is required",
}),
password: z.string().min(6, {
message: "Minimum 6 characters required",
password: z.string().min(8, {
message: "Minimum 8 characters required",
}),
name: z.string().min(1, {
message: "Name is required",