diff --git a/.env.example b/.env.example index de7629b..eba6428 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,6 @@ VITE_WS_URL= UID=1000 GID=1000 + +RESEND_API_KEY= +EMAIL_FROM=mail@example.com diff --git a/apps/api/package.json b/apps/api/package.json index 870a77d..2e2944f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": { diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 8e2b818..eef78c3 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -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: `
Click here to reset your password. This link expires in 1 hour.
`, + }); + }, + }, + emailVerification: { + sendVerificationEmail: async ({ user, url }) => { + await resend.emails.send({ + from: emailFrom, + to: user.email, + subject: "Verify your lila account", + html: `Click here to verify your email address.
`, + }); + }, + sendOnSignUp: true, + autoSignInAfterVerification: true, + }, trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"], socialProviders: { google: { diff --git a/apps/web/package.json b/apps/web/package.json index 922f6f9..b458eb3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 96c3044..61b893a 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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) diff --git a/apps/web/src/routes/forgot-password.tsx b/apps/web/src/routes/forgot-password.tsx new file mode 100644 index 0000000..9d929a8 --- /dev/null +++ b/apps/web/src/routes/forgot-password.tsx @@ -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 ( ++ Enter your email and we'll send you a reset link. +
++ This reset link is invalid or has expired. +
+ + Request a new one + ++ Enter your new password below. +
+