From 6297dff399ef92778c959282c2c1a87f8f9ea280 Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 30 Apr 2026 18:30:20 +0200 Subject: [PATCH 1/7] 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 --- .env.example | 3 + apps/api/package.json | 1 + apps/api/src/lib/auth.ts | 28 ++++++++ apps/web/package.json | 1 + apps/web/src/routeTree.gen.ts | 42 ++++++++++++ apps/web/src/routes/forgot-password.tsx | 74 ++++++++++++++++++++ apps/web/src/routes/reset-password.tsx | 91 +++++++++++++++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/auth.ts | 7 ++ pnpm-lock.yaml | 69 +++++++++++++++++++ 10 files changed, 317 insertions(+) create mode 100644 apps/web/src/routes/forgot-password.tsx create mode 100644 apps/web/src/routes/reset-password.tsx create mode 100644 packages/shared/src/schemas/auth.ts 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 ( +
+
+

+ Forgot password +

+

+ Enter your email and we'll send you a reset link. +

+
+ +
{ + e.preventDefault(); + void handleSubmit(); + }} + className="w-full flex flex-col gap-3" + > + 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)" + /> + +
+ + + Back to home + +
+ ); +} + +export const Route = createFileRoute("/forgot-password")({ + component: ForgotPasswordPage, +}); diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx new file mode 100644 index 0000000..c68b3cb --- /dev/null +++ b/apps/web/src/routes/reset-password.tsx @@ -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 ( +
+

+ Invalid link +

+

+ This reset link is invalid or has expired. +

+ + Request a new one + +
+ ); + } + + 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 ( +
+
+

+ Reset password +

+

+ Enter your new password below. +

+
+ +
{ + e.preventDefault(); + void handleSubmit(); + }} + className="w-full flex flex-col gap-3" + > + 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)" + /> + +
+
+ ); +} + +export const Route = createFileRoute("/reset-password")({ + component: ResetPasswordPage, + validateSearch: ResetPasswordSearchSchema, +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7dc79f5..582394e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from "./constants.js"; export * from "./schemas/game.js"; export * from "./schemas/lobby.js"; +export * from "./schemas/auth.js"; diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts new file mode 100644 index 0000000..1b32783 --- /dev/null +++ b/packages/shared/src/schemas/auth.ts @@ -0,0 +1,7 @@ +import * as z from "zod"; + +export const ResetPasswordSearchSchema = z.object({ + token: z.string().catch(""), +}); + +export type ResetPasswordSearch = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f44e67..4453586 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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): From 32ee1edf80149391c03356a5ccd07ac1220b9250 Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 30 Apr 2026 19:38:43 +0200 Subject: [PATCH 2/7] feat: add AuthModal component with login, register and social tabs - Add AuthModal with login/register tabs and social buttons - Add forgot-password and reset-password routes - Add Sonner toaster to root layout - Add auth search schemas to @lila/shared - Add ESLint overrides for TanStack Router generics --- apps/web/src/components/auth/AuthModal.tsx | 246 ++++++++++++++++++++ apps/web/src/components/navbar/NavLogin.tsx | 17 -- apps/web/src/routes/__root.tsx | 4 + apps/web/src/routes/reset-password.tsx | 2 +- eslint.config.mjs | 3 + packages/shared/src/schemas/auth.ts | 7 + 6 files changed, 261 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/auth/AuthModal.tsx delete mode 100644 apps/web/src/components/navbar/NavLogin.tsx diff --git a/apps/web/src/components/auth/AuthModal.tsx b/apps/web/src/components/auth/AuthModal.tsx new file mode 100644 index 0000000..e4d1331 --- /dev/null +++ b/apps/web/src/components/auth/AuthModal.tsx @@ -0,0 +1,246 @@ +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { authClient } from "../../lib/auth-client"; + +type Tab = "login" | "register"; + +type AuthModalProps = { onClose: () => void }; + +type LoginFormProps = { onSuccess: () => void }; + +const LoginForm = ({ onSuccess }: LoginFormProps) => { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isPending, setIsPending] = useState(false); + + const handleSubmit = async () => { + setIsPending(true); + await authClient.signIn.email( + { email, password }, + { + onSuccess: () => { + toast.success("Welcome back!"); + onSuccess(); + }, + onError: (ctx) => { + toast.error(ctx.error.message ?? "Something went wrong."); + setIsPending(false); + }, + }, + ); + }; + + return ( +
{ + e.preventDefault(); + void handleSubmit(); + }} + className="flex flex-col gap-3" + > + 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)" + /> + setPassword(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)" + /> +
+ + Forgot password? + +
+ +
+ ); +}; + +type RegisterFormProps = { onSuccess: () => void }; + +const RegisterForm = ({ onSuccess }: RegisterFormProps) => { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isPending, setIsPending] = useState(false); + + const handleSubmit = async () => { + setIsPending(true); + await authClient.signUp.email( + { name, email, password }, + { + onSuccess: () => { + toast.success("Check your email to verify your account."); + onSuccess(); + }, + onError: (ctx) => { + toast.error(ctx.error.message ?? "Something went wrong."); + setIsPending(false); + }, + }, + ); + }; + + return ( +
{ + e.preventDefault(); + void handleSubmit(); + }} + className="flex flex-col gap-3" + > + setName(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)" + /> + 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)" + /> + 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)" + /> + +
+ ); +}; + +const SocialButtons = () => { + const handleSocial = (provider: "google" | "github") => { + void authClient.signIn.social( + { provider, callbackURL: window.location.origin }, + { + onError: (ctx) => { + toast.error(ctx.error.message ?? "Something went wrong."); + }, + }, + ); + }; + + return ( +
+
+
+ + or continue with + +
+
+ + +
+ ); +}; + +export const AuthModal = ({ onClose }: AuthModalProps) => { + const [tab, setTab] = useState("login"); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > + {/* Close button */} + + + {/* Header */} +
+

+ lila +

+
+ + {/* Tabs */} +
+ {(["login", "register"] as Tab[]).map((t) => ( + + ))} +
+ + {tab === "login" ? ( + + ) : ( + + )} + + {/* Social */} + +
+
+ ); +}; diff --git a/apps/web/src/components/navbar/NavLogin.tsx b/apps/web/src/components/navbar/NavLogin.tsx deleted file mode 100644 index f28bfdd..0000000 --- a/apps/web/src/components/navbar/NavLogin.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Link } from "@tanstack/react-router"; - -const NavLogin = () => { - return ( - - Login - - ); -}; - -export default NavLogin; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c672ced..826aec6 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,8 +1,10 @@ import { createRootRoute, Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { Toaster } from "sonner"; import Navbar from "../components/navbar/NavBar"; import NotFound from "../components/NotFound"; import RootError from "../components/RootError"; +import { AuthModalSearchSchema } from "@lila/shared"; const RootLayout = () => { return ( @@ -11,6 +13,7 @@ const RootLayout = () => {
+ ); @@ -20,4 +23,5 @@ export const Route = createRootRoute({ component: RootLayout, notFoundComponent: NotFound, errorComponent: RootError, + validateSearch: AuthModalSearchSchema, }); diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx index c68b3cb..837949b 100644 --- a/apps/web/src/routes/reset-password.tsx +++ b/apps/web/src/routes/reset-password.tsx @@ -5,7 +5,7 @@ import { toast } from "sonner"; import { ResetPasswordSearchSchema } from "@lila/shared"; function ResetPasswordPage() { - const token = String(Route.useSearch().token); + const { token } = Route.useSearch(); const navigate = useNavigate(); const [password, setPassword] = useState(""); const [isPending, setIsPending] = useState(false); diff --git a/eslint.config.mjs b/eslint.config.mjs index 290fa14..4d2c015 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,9 @@ export default defineConfig([ rules: { "react-refresh/only-export-components": "off", "@typescript-eslint/only-throw-error": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", }, }, { diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts index 1b32783..6aaf35d 100644 --- a/packages/shared/src/schemas/auth.ts +++ b/packages/shared/src/schemas/auth.ts @@ -5,3 +5,10 @@ export const ResetPasswordSearchSchema = z.object({ }); export type ResetPasswordSearch = z.infer; + +export const AuthModalSearchSchema = z.object({ + modal: z.enum(["auth"]).optional().catch(undefined), + redirect: z.string().optional().catch(undefined), +}); + +export type AuthModalSearch = z.infer; From dc11213cb5bf3ef235d2244604edde2b327fdc75 Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 30 Apr 2026 19:46:45 +0200 Subject: [PATCH 3/7] feat: replace login route with auth modal - Add AuthModal to root layout driven by ?modal=auth search param - Update multiplayer and play beforeLoad redirects to use modal - Update NavAuth and Hero links to use modal - Delete login route and NavLogin component --- apps/web/src/components/auth/AuthModal.tsx | 13 +++--- apps/web/src/components/landing/Hero.tsx | 6 ++- apps/web/src/components/navbar/NavAuth.tsx | 5 ++- apps/web/src/routeTree.gen.ts | 21 ---------- apps/web/src/routes/__root.tsx | 22 ++++++++++- apps/web/src/routes/login.tsx | 46 ---------------------- apps/web/src/routes/multiplayer.tsx | 5 ++- apps/web/src/routes/play.tsx | 2 +- 8 files changed, 41 insertions(+), 79 deletions(-) delete mode 100644 apps/web/src/routes/login.tsx diff --git a/apps/web/src/components/auth/AuthModal.tsx b/apps/web/src/components/auth/AuthModal.tsx index e4d1331..01b2c20 100644 --- a/apps/web/src/components/auth/AuthModal.tsx +++ b/apps/web/src/components/auth/AuthModal.tsx @@ -4,7 +4,7 @@ import { authClient } from "../../lib/auth-client"; type Tab = "login" | "register"; -type AuthModalProps = { onClose: () => void }; +type AuthModalProps = { onClose: () => void; onSuccess: () => void }; type LoginFormProps = { onSuccess: () => void }; @@ -142,11 +142,14 @@ const RegisterForm = ({ onSuccess }: RegisterFormProps) => { ); }; -const SocialButtons = () => { +type SocialButtonsProps = { onSuccess: () => void }; + +const SocialButtons = ({ onSuccess }: SocialButtonsProps) => { const handleSocial = (provider: "google" | "github") => { void authClient.signIn.social( { provider, callbackURL: window.location.origin }, { + onSuccess, onError: (ctx) => { toast.error(ctx.error.message ?? "Something went wrong."); }, @@ -179,7 +182,7 @@ const SocialButtons = () => { ); }; -export const AuthModal = ({ onClose }: AuthModalProps) => { +export const AuthModal = ({ onClose, onSuccess }: AuthModalProps) => { const [tab, setTab] = useState("login"); useEffect(() => { @@ -233,13 +236,13 @@ export const AuthModal = ({ onClose }: AuthModalProps) => {
{tab === "login" ? ( - + ) : ( )} {/* Social */} - +
); diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx index 81f7bba..238d313 100644 --- a/apps/web/src/components/landing/Hero.tsx +++ b/apps/web/src/components/landing/Hero.tsx @@ -66,13 +66,15 @@ const Hero = () => { ) : ( <> Get started Log in diff --git a/apps/web/src/components/navbar/NavAuth.tsx b/apps/web/src/components/navbar/NavAuth.tsx index 22b8479..f65f569 100644 --- a/apps/web/src/components/navbar/NavAuth.tsx +++ b/apps/web/src/components/navbar/NavAuth.tsx @@ -24,13 +24,14 @@ const NavAuth = () => { ) : ( - Sign in + Login )} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 61b893a..a85f2f2 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,7 +12,6 @@ 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' @@ -35,11 +34,6 @@ const MultiplayerRoute = MultiplayerRouteImport.update({ path: '/multiplayer', getParentRoute: () => rootRouteImport, } as any) -const LoginRoute = LoginRouteImport.update({ - id: '/login', - path: '/login', - getParentRoute: () => rootRouteImport, -} as any) const ForgotPasswordRoute = ForgotPasswordRouteImport.update({ id: '/forgot-password', path: '/forgot-password', @@ -75,7 +69,6 @@ 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 @@ -87,7 +80,6 @@ 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 @@ -99,7 +91,6 @@ export interface FileRoutesById { '/': typeof IndexRoute '/about': typeof AboutRoute '/forgot-password': typeof ForgotPasswordRoute - '/login': typeof LoginRoute '/multiplayer': typeof MultiplayerRouteWithChildren '/play': typeof PlayRoute '/reset-password': typeof ResetPasswordRoute @@ -113,7 +104,6 @@ export interface FileRouteTypes { | '/' | '/about' | '/forgot-password' - | '/login' | '/multiplayer' | '/play' | '/reset-password' @@ -125,7 +115,6 @@ export interface FileRouteTypes { | '/' | '/about' | '/forgot-password' - | '/login' | '/play' | '/reset-password' | '/multiplayer' @@ -136,7 +125,6 @@ export interface FileRouteTypes { | '/' | '/about' | '/forgot-password' - | '/login' | '/multiplayer' | '/play' | '/reset-password' @@ -149,7 +137,6 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute ForgotPasswordRoute: typeof ForgotPasswordRoute - LoginRoute: typeof LoginRoute MultiplayerRoute: typeof MultiplayerRouteWithChildren PlayRoute: typeof PlayRoute ResetPasswordRoute: typeof ResetPasswordRoute @@ -178,13 +165,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MultiplayerRouteImport parentRoute: typeof rootRouteImport } - '/login': { - id: '/login' - path: '/login' - fullPath: '/login' - preLoaderRoute: typeof LoginRouteImport - parentRoute: typeof rootRouteImport - } '/forgot-password': { id: '/forgot-password' path: '/forgot-password' @@ -250,7 +230,6 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, ForgotPasswordRoute: ForgotPasswordRoute, - LoginRoute: LoginRoute, MultiplayerRoute: MultiplayerRouteWithChildren, PlayRoute: PlayRoute, ResetPasswordRoute: ResetPasswordRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 826aec6..7df9998 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,18 +1,38 @@ -import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { + createRootRoute, + Outlet, + useNavigate, + useSearch, +} from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { Toaster } from "sonner"; import Navbar from "../components/navbar/NavBar"; import NotFound from "../components/NotFound"; import RootError from "../components/RootError"; +import { AuthModal } from "../components/auth/AuthModal"; import { AuthModalSearchSchema } from "@lila/shared"; const RootLayout = () => { + const navigate = useNavigate(); + const { modal, redirect } = useSearch({ from: "__root__" }); + + const handleClose = () => { + void navigate({ to: "/", search: {} }); + }; + + const handleSuccess = () => { + void navigate({ to: (redirect as string) ?? "/", search: {} }); + }; + return ( <>
+ {modal === "auth" && ( + + )} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx deleted file mode 100644 index 8451d41..0000000 --- a/apps/web/src/routes/login.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { signIn, useSession } from "../lib/auth-client"; - -const LoginPage = () => { - const { data: session, isPending } = useSession(); - const navigate = useNavigate(); - - if (isPending) return
Loading...
; - - if (session) { - void navigate({ to: "/" }); - return null; - } - - return ( -
-

sign in to lila

- - -
- ); -}; - -export const Route = createFileRoute("/login")({ component: LoginPage }); diff --git a/apps/web/src/routes/multiplayer.tsx b/apps/web/src/routes/multiplayer.tsx index 7adffd6..0008b37 100644 --- a/apps/web/src/routes/multiplayer.tsx +++ b/apps/web/src/routes/multiplayer.tsx @@ -14,7 +14,10 @@ export const Route = createFileRoute("/multiplayer")({ beforeLoad: async () => { const { data: session } = await authClient.getSession(); if (!session) { - throw redirect({ to: "/login" }); + throw redirect({ + to: "/", + search: { modal: "auth", redirect: "/multiplayer" }, + }); } return { session }; }, diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index df4959d..bc4cde3 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -132,7 +132,7 @@ export const Route = createFileRoute("/play")({ beforeLoad: async () => { const { data: session } = await authClient.getSession(); if (!session) { - throw redirect({ to: "/login" }); + throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } }); } }, }); From e1c4fb574421a7ed57fb947337e797be8bc12b33 Mon Sep 17 00:00:00 2001 From: lila Date: Sat, 2 May 2026 11:22:54 +0200 Subject: [PATCH 4/7] refactoring --- apps/api/src/lib/auth.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index eef78c3..16fe5d0 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -23,7 +23,13 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: true, - sendResetPassword: async ({ user, url }) => { + sendResetPassword: async ({ + user, + url, + }: { + user: { email: string }; + url: string; + }) => { await resend.emails.send({ from: emailFrom, to: user.email, @@ -33,7 +39,16 @@ export const auth = betterAuth({ }, }, emailVerification: { - sendVerificationEmail: async ({ user, url }) => { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ + user, + url, + }: { + user: { email: string }; + url: string; + }) => { + console.log("Sending verification email to", user.email, url); await resend.emails.send({ from: emailFrom, to: user.email, @@ -41,8 +56,6 @@ export const auth = betterAuth({ html: `

Click here to verify your email address.

`, }); }, - sendOnSignUp: true, - autoSignInAfterVerification: true, }, trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"], socialProviders: { From 6ca6fc4e095b64a81a63ea7eb235e10896736589 Mon Sep 17 00:00:00 2001 From: lila Date: Sat, 2 May 2026 11:23:10 +0200 Subject: [PATCH 5/7] fix: correct dotenv path in packages/db/src/index.ts for compiled dist output --- packages/db/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index baa05e0..cfa3cad 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -6,7 +6,7 @@ import { dirname } from "path"; import * as schema from "./db/schema.js"; config({ - path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"), + path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../../.env"), }); export const db = drizzle(process.env["DATABASE_URL"]!, { schema }); From 4ae2c568c69ee422afa134c419ac211875930d0c Mon Sep 17 00:00:00 2001 From: lila Date: Sat, 2 May 2026 12:15:23 +0200 Subject: [PATCH 6/7] fix: resolve ESLint config file ignores and project service coverage --- eslint.config.mjs | 13 +++++++++---- packages/db/tsconfig.json | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4d2c015..a88b6f1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,8 +10,6 @@ export default defineConfig([ globalIgnores([ "**/dist/**", "node_modules/", - "eslint.config.mjs", - "**/*.config.ts", "routeTree.gen.ts", "scripts/**", "data-pipeline/**/*", @@ -24,12 +22,19 @@ export default defineConfig([ { languageOptions: { parserOptions: { - projectService: true, + projectService: { allowDefaultProject: ["*.mjs", "*.ts"] }, tsconfigRootDir: import.meta.dirname, }, }, }, - + { + files: ["eslint.config.mjs"], + rules: { + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + }, + }, { files: ["apps/web/**/*.{ts,tsx}"], extends: [ diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index af1fba6..c8c1b3a 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -10,6 +10,7 @@ "include": [ "src", "vitest.config.ts", + "drizzle.config.ts", "../../data-pipeline/archive/packages-db-src-old-seeding-scripts/data" ] } From ccfd83d16ca3b7879fd351b7f2af981a7a308911 Mon Sep 17 00:00:00 2001 From: lila Date: Sat, 2 May 2026 13:05:43 +0200 Subject: [PATCH 7/7] feat: email/password auth + email verification + password reset via Resend --- apps/api/src/lib/auth.ts | 1 - packages/db/src/index.ts | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 16fe5d0..601708e 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -48,7 +48,6 @@ export const auth = betterAuth({ user: { email: string }; url: string; }) => { - console.log("Sending verification email to", user.email, url); await resend.emails.send({ from: emailFrom, to: user.email, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cfa3cad..567a460 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -9,7 +9,10 @@ config({ path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../../.env"), }); -export const db = drizzle(process.env["DATABASE_URL"]!, { schema }); +export const db = drizzle( + process.env["DATABASE_URL_LOCAL"] ?? process.env["DATABASE_URL"]!, + { schema }, +); export * from "./models/termModel.js"; export * from "./models/lobbyModel.js";