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:
lila 2026-04-30 18:30:20 +02:00
parent 35e54014b3
commit 6297dff399
10 changed files with 317 additions and 0 deletions

View file

@ -16,3 +16,6 @@ VITE_WS_URL=
UID=1000
GID=1000
RESEND_API_KEY=
EMAIL_FROM=mail@example.com

View file

@ -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": {

View file

@ -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: {

View file

@ -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": {

View file

@ -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)

View 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,
});

View 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,
});

View file

@ -1,3 +1,4 @@
export * from "./constants.js";
export * from "./schemas/game.js";
export * from "./schemas/lobby.js";
export * from "./schemas/auth.js";

View file

@ -0,0 +1,7 @@
import * as z from "zod";
export const ResetPasswordSearchSchema = z.object({
token: z.string().catch(""),
});
export type ResetPasswordSearch = z.infer<typeof ResetPasswordSearchSchema>;

69
pnpm-lock.yaml generated
View file

@ -74,6 +74,9 @@ importers:
helmet:
specifier: ^8.1.0
version: 8.1.0
resend:
specifier: ^6.12.2
version: 6.12.2
ws:
specifier: ^8.20.0
version: 8.20.0
@ -120,6 +123,9 @@ importers:
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
@ -1090,6 +1096,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -2116,6 +2125,9 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -2703,6 +2715,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
postal-mime@2.7.4:
resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@ -2794,6 +2809,15 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resend@6.12.2:
resolution: {integrity: sha512-xwgmU4b0OqoabJsIoK/x0Whk0Fcs3bpbK4i/DEWPiE5hYJHyHl0TbB6QbI3gIr+bLdLUJ1GYm/fe41aVFuHXgw==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@ -2914,6 +2938,12 @@ packages:
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
engines: {node: '>=20'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -2940,6 +2970,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@ -2994,6 +3027,9 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
svix@1.90.0:
resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@ -3131,6 +3167,11 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -3937,6 +3978,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.2.2':
@ -5035,6 +5078,8 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-sha256@1.3.0: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -5542,6 +5587,8 @@ snapshots:
picomatch@4.0.3: {}
postal-mime@2.7.4: {}
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
@ -5638,6 +5685,11 @@ snapshots:
require-from-string@2.0.2: {}
resend@6.12.2:
dependencies:
postal-mime: 2.7.4
svix: 1.90.0
resolve-pkg-maps@1.0.0: {}
restore-cursor@5.1.0:
@ -5791,6 +5843,11 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@ -5810,6 +5867,11 @@ snapshots:
stackback@0.0.2: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
statuses@2.0.2: {}
std-env@4.0.0: {}
@ -5877,6 +5939,11 @@ snapshots:
dependencies:
has-flag: 4.0.0
svix@1.90.0:
dependencies:
standardwebhooks: 1.0.0
uuid: 10.0.0
symbol-tree@3.2.4: {}
tailwindcss@4.2.2: {}
@ -6007,6 +6074,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
vary@1.1.2: {}
vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3):