feat: add email/password auth backend + forgot/reset password routes
- Configure Better Auth emailAndPassword plugin with Resend - Add email verification and password reset email sending - Create forgot-password and reset-password frontend routes - Add auth schemas to @lila/shared
This commit is contained in:
parent
35e54014b3
commit
6297dff399
10 changed files with 317 additions and 0 deletions
|
|
@ -17,6 +17,7 @@
|
|||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.0",
|
||||
"helmet": "^8.1.0",
|
||||
"resend": "^6.12.2",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { Resend } from "resend";
|
||||
import { db } from "@lila/db";
|
||||
import * as schema from "@lila/db/schema";
|
||||
|
||||
const resend = new Resend(process.env["RESEND_API_KEY"]);
|
||||
const emailFrom = process.env["EMAIL_FROM"] ?? "noreply@lilastudy.com";
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
|
||||
advanced: {
|
||||
|
|
@ -16,6 +20,30 @@ export const auth = betterAuth({
|
|||
},
|
||||
},
|
||||
database: drizzleAdapter(db, { provider: "pg", schema }),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await resend.emails.send({
|
||||
from: emailFrom,
|
||||
to: user.email,
|
||||
subject: "Reset your lila password",
|
||||
html: `<p>Click <a href="${url}">here</a> to reset your password. This link expires in 1 hour.</p>`,
|
||||
});
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
await resend.emails.send({
|
||||
from: emailFrom,
|
||||
to: user.email,
|
||||
subject: "Verify your lila account",
|
||||
html: `<p>Click <a href="${url}">here</a> to verify your email address.</p>`,
|
||||
});
|
||||
},
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
},
|
||||
trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
|
||||
socialProviders: {
|
||||
google: {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"better-auth": "^1.6.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -9,15 +9,22 @@
|
|||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||
import { Route as PlayRouteImport } from './routes/play'
|
||||
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
|
||||
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
|
||||
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code'
|
||||
|
||||
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
||||
id: '/reset-password',
|
||||
path: '/reset-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlayRoute = PlayRouteImport.update({
|
||||
id: '/play',
|
||||
path: '/play',
|
||||
|
|
@ -33,6 +40,11 @@ const LoginRoute = LoginRouteImport.update({
|
|||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
||||
id: '/forgot-password',
|
||||
path: '/forgot-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
|
|
@ -62,9 +74,11 @@ const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({
|
|||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||
'/play': typeof PlayRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||
|
|
@ -72,8 +86,10 @@ export interface FileRoutesByFullPath {
|
|||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/play': typeof PlayRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/multiplayer': typeof MultiplayerIndexRoute
|
||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||
|
|
@ -82,9 +98,11 @@ export interface FileRoutesById {
|
|||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||
'/play': typeof PlayRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||
|
|
@ -94,9 +112,11 @@ export interface FileRouteTypes {
|
|||
fullPaths:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/forgot-password'
|
||||
| '/login'
|
||||
| '/multiplayer'
|
||||
| '/play'
|
||||
| '/reset-password'
|
||||
| '/multiplayer/'
|
||||
| '/multiplayer/game/$code'
|
||||
| '/multiplayer/lobby/$code'
|
||||
|
|
@ -104,8 +124,10 @@ export interface FileRouteTypes {
|
|||
to:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/forgot-password'
|
||||
| '/login'
|
||||
| '/play'
|
||||
| '/reset-password'
|
||||
| '/multiplayer'
|
||||
| '/multiplayer/game/$code'
|
||||
| '/multiplayer/lobby/$code'
|
||||
|
|
@ -113,9 +135,11 @@ export interface FileRouteTypes {
|
|||
| '__root__'
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/forgot-password'
|
||||
| '/login'
|
||||
| '/multiplayer'
|
||||
| '/play'
|
||||
| '/reset-password'
|
||||
| '/multiplayer/'
|
||||
| '/multiplayer/game/$code'
|
||||
| '/multiplayer/lobby/$code'
|
||||
|
|
@ -124,13 +148,22 @@ export interface FileRouteTypes {
|
|||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
MultiplayerRoute: typeof MultiplayerRouteWithChildren
|
||||
PlayRoute: typeof PlayRoute
|
||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/reset-password': {
|
||||
id: '/reset-password'
|
||||
path: '/reset-password'
|
||||
fullPath: '/reset-password'
|
||||
preLoaderRoute: typeof ResetPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/play': {
|
||||
id: '/play'
|
||||
path: '/play'
|
||||
|
|
@ -152,6 +185,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/forgot-password': {
|
||||
id: '/forgot-password'
|
||||
path: '/forgot-password'
|
||||
fullPath: '/forgot-password'
|
||||
preLoaderRoute: typeof ForgotPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
|
|
@ -209,9 +249,11 @@ const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren(
|
|||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
MultiplayerRoute: MultiplayerRouteWithChildren,
|
||||
PlayRoute: PlayRoute,
|
||||
ResetPasswordRoute: ResetPasswordRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
74
apps/web/src/routes/forgot-password.tsx
Normal file
74
apps/web/src/routes/forgot-password.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useState } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { authClient } from "../lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsPending(true);
|
||||
await authClient.requestPasswordReset(
|
||||
{ email, redirectTo: `${window.location.origin}/reset-password` },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Check your email for a reset link.");
|
||||
setIsPending(false);
|
||||
},
|
||||
onError: (ctx) => {
|
||||
toast.error(ctx.error.message ?? "Something went wrong.");
|
||||
setIsPending(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-6 p-8 max-w-sm mx-auto">
|
||||
<div className="w-full text-center">
|
||||
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
|
||||
Forgot password
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
||||
Enter your email and we'll send you a reset link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className="w-full flex flex-col gap-3"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Sending..." : "Send reset link"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
||||
>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/forgot-password")({
|
||||
component: ForgotPasswordPage,
|
||||
});
|
||||
91
apps/web/src/routes/reset-password.tsx
Normal file
91
apps/web/src/routes/reset-password.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { useState } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { authClient } from "../lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { ResetPasswordSearchSchema } from "@lila/shared";
|
||||
|
||||
function ResetPasswordPage() {
|
||||
const token = String(Route.useSearch().token);
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-8 max-w-sm mx-auto text-center">
|
||||
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
|
||||
Invalid link
|
||||
</h1>
|
||||
<p className="text-sm text-(--color-text-muted)">
|
||||
This reset link is invalid or has expired.
|
||||
</p>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-(--color-primary) hover:underline"
|
||||
>
|
||||
Request a new one
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsPending(true);
|
||||
await authClient.resetPassword(
|
||||
{ newPassword: password, token },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Password updated. You can now sign in.");
|
||||
void navigate({ to: "/" });
|
||||
},
|
||||
onError: (ctx) => {
|
||||
toast.error(ctx.error.message ?? "Something went wrong.");
|
||||
setIsPending(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-6 p-8 max-w-sm mx-auto">
|
||||
<div className="w-full text-center">
|
||||
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
|
||||
Reset password
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className="w-full flex flex-col gap-3"
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Updating..." : "Update password"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/reset-password")({
|
||||
component: ResetPasswordPage,
|
||||
validateSearch: ResetPasswordSearchSchema,
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue