Merge branch 'dev'
This commit is contained in:
commit
90b0890263
21 changed files with 636 additions and 93 deletions
|
|
@ -16,3 +16,6 @@ VITE_WS_URL=
|
||||||
|
|
||||||
UID=1000
|
UID=1000
|
||||||
GID=1000
|
GID=1000
|
||||||
|
|
||||||
|
RESEND_API_KEY=
|
||||||
|
EMAIL_FROM=mail@example.com
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.4.0",
|
"express-rate-limit": "^8.4.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"resend": "^6.12.2",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { Resend } from "resend";
|
||||||
import { db } from "@lila/db";
|
import { db } from "@lila/db";
|
||||||
import * as schema from "@lila/db/schema";
|
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({
|
export const auth = betterAuth({
|
||||||
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
|
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
|
||||||
advanced: {
|
advanced: {
|
||||||
|
|
@ -16,6 +20,42 @@ export const auth = betterAuth({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
database: drizzleAdapter(db, { provider: "pg", schema }),
|
database: drizzleAdapter(db, { provider: "pg", schema }),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
requireEmailVerification: true,
|
||||||
|
sendResetPassword: async ({
|
||||||
|
user,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
user: { email: string };
|
||||||
|
url: string;
|
||||||
|
}) => {
|
||||||
|
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: {
|
||||||
|
sendOnSignUp: true,
|
||||||
|
autoSignInAfterVerification: true,
|
||||||
|
sendVerificationEmail: async ({
|
||||||
|
user,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
user: { email: string };
|
||||||
|
url: string;
|
||||||
|
}) => {
|
||||||
|
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>`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
|
trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
google: {
|
google: {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"better-auth": "^1.6.2",
|
"better-auth": "^1.6.2",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwindcss": "^4.2.2"
|
"tailwindcss": "^4.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
249
apps/web/src/components/auth/AuthModal.tsx
Normal file
249
apps/web/src/components/auth/AuthModal.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient } from "../../lib/auth-client";
|
||||||
|
|
||||||
|
type Tab = "login" | "register";
|
||||||
|
|
||||||
|
type AuthModalProps = { onClose: () => void; onSuccess: () => 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 (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}}
|
||||||
|
className="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)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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)"
|
||||||
|
/>
|
||||||
|
<div className="text-right">
|
||||||
|
<a
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-xs text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<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 ? "Logging in..." : "Login"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => 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)"
|
||||||
|
/>
|
||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="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 ? "Creating account..." : "Register"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 h-px bg-(--color-primary-light)" />
|
||||||
|
<span className="text-xs text-(--color-text-muted) font-medium">
|
||||||
|
or continue with
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-(--color-primary-light)" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSocial("github")}
|
||||||
|
className="w-full rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
Continue with GitHub
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSocial("google")}
|
||||||
|
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthModal = ({ onClose, onSuccess }: AuthModalProps) => {
|
||||||
|
const [tab, setTab] = useState<Tab>("login");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-sm rounded-3xl border border-(--color-primary-light) bg-white shadow-lg p-8 flex flex-col gap-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-black tracking-tight text-(--color-text)">
|
||||||
|
lila
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex rounded-2xl border border-(--color-primary-light) overflow-hidden">
|
||||||
|
{(["login", "register"] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`flex-1 py-2 text-sm font-bold transition-colors capitalize ${
|
||||||
|
tab === t
|
||||||
|
? "bg-(--color-primary) text-white"
|
||||||
|
: "text-(--color-text-muted) hover:text-(--color-primary)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "login" ? (
|
||||||
|
<LoginForm onSuccess={onSuccess} />
|
||||||
|
) : (
|
||||||
|
<RegisterForm onSuccess={onClose} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Social */}
|
||||||
|
<SocialButtons onSuccess={onSuccess} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -66,13 +66,15 @@ const Hero = () => {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/"
|
||||||
|
search={{ modal: "auth" }}
|
||||||
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
||||||
>
|
>
|
||||||
Get started
|
Get started
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/"
|
||||||
|
search={{ modal: "auth" }}
|
||||||
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
||||||
>
|
>
|
||||||
Log in
|
Log in
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,14 @@ const NavAuth = () => {
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/"
|
||||||
|
search={{ modal: "auth" }}
|
||||||
className="text-sm font-medium px-4 py-1.5 rounded-full
|
className="text-sm font-medium px-4 py-1.5 rounded-full
|
||||||
text-white bg-(--color-primary)
|
text-white bg-(--color-primary)
|
||||||
hover:bg-(--color-primary-dark)
|
hover:bg-(--color-primary-dark)
|
||||||
transition-colors duration-200"
|
transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Sign in
|
Login
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
const NavLogin = () => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="text-sm font-medium px-4 py-1.5 rounded-full
|
|
||||||
text-white bg-(--color-primary)
|
|
||||||
hover:bg-(--color-primary-dark)
|
|
||||||
transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavLogin;
|
|
||||||
|
|
@ -9,15 +9,21 @@
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||||
import { Route as PlayRouteImport } from './routes/play'
|
import { Route as PlayRouteImport } from './routes/play'
|
||||||
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
|
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 AboutRouteImport } from './routes/about'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
|
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
|
||||||
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
|
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
|
||||||
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$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({
|
const PlayRoute = PlayRouteImport.update({
|
||||||
id: '/play',
|
id: '/play',
|
||||||
path: '/play',
|
path: '/play',
|
||||||
|
|
@ -28,9 +34,9 @@ const MultiplayerRoute = MultiplayerRouteImport.update({
|
||||||
path: '/multiplayer',
|
path: '/multiplayer',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
||||||
id: '/login',
|
id: '/forgot-password',
|
||||||
path: '/login',
|
path: '/forgot-password',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AboutRoute = AboutRouteImport.update({
|
const AboutRoute = AboutRouteImport.update({
|
||||||
|
|
@ -62,9 +68,10 @@ const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/multiplayer': typeof MultiplayerRouteWithChildren
|
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/multiplayer/': typeof MultiplayerIndexRoute
|
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
|
|
@ -72,8 +79,9 @@ export interface FileRoutesByFullPath {
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/multiplayer': typeof MultiplayerIndexRoute
|
'/multiplayer': typeof MultiplayerIndexRoute
|
||||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
|
|
@ -82,9 +90,10 @@ export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/multiplayer': typeof MultiplayerRouteWithChildren
|
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/multiplayer/': typeof MultiplayerIndexRoute
|
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
|
|
@ -94,9 +103,10 @@ export interface FileRouteTypes {
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/about'
|
| '/about'
|
||||||
| '/login'
|
| '/forgot-password'
|
||||||
| '/multiplayer'
|
| '/multiplayer'
|
||||||
| '/play'
|
| '/play'
|
||||||
|
| '/reset-password'
|
||||||
| '/multiplayer/'
|
| '/multiplayer/'
|
||||||
| '/multiplayer/game/$code'
|
| '/multiplayer/game/$code'
|
||||||
| '/multiplayer/lobby/$code'
|
| '/multiplayer/lobby/$code'
|
||||||
|
|
@ -104,8 +114,9 @@ export interface FileRouteTypes {
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/about'
|
| '/about'
|
||||||
| '/login'
|
| '/forgot-password'
|
||||||
| '/play'
|
| '/play'
|
||||||
|
| '/reset-password'
|
||||||
| '/multiplayer'
|
| '/multiplayer'
|
||||||
| '/multiplayer/game/$code'
|
| '/multiplayer/game/$code'
|
||||||
| '/multiplayer/lobby/$code'
|
| '/multiplayer/lobby/$code'
|
||||||
|
|
@ -113,9 +124,10 @@ export interface FileRouteTypes {
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/about'
|
| '/about'
|
||||||
| '/login'
|
| '/forgot-password'
|
||||||
| '/multiplayer'
|
| '/multiplayer'
|
||||||
| '/play'
|
| '/play'
|
||||||
|
| '/reset-password'
|
||||||
| '/multiplayer/'
|
| '/multiplayer/'
|
||||||
| '/multiplayer/game/$code'
|
| '/multiplayer/game/$code'
|
||||||
| '/multiplayer/lobby/$code'
|
| '/multiplayer/lobby/$code'
|
||||||
|
|
@ -124,13 +136,21 @@ export interface FileRouteTypes {
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AboutRoute: typeof AboutRoute
|
AboutRoute: typeof AboutRoute
|
||||||
LoginRoute: typeof LoginRoute
|
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||||
MultiplayerRoute: typeof MultiplayerRouteWithChildren
|
MultiplayerRoute: typeof MultiplayerRouteWithChildren
|
||||||
PlayRoute: typeof PlayRoute
|
PlayRoute: typeof PlayRoute
|
||||||
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/reset-password': {
|
||||||
|
id: '/reset-password'
|
||||||
|
path: '/reset-password'
|
||||||
|
fullPath: '/reset-password'
|
||||||
|
preLoaderRoute: typeof ResetPasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/play': {
|
'/play': {
|
||||||
id: '/play'
|
id: '/play'
|
||||||
path: '/play'
|
path: '/play'
|
||||||
|
|
@ -145,11 +165,11 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof MultiplayerRouteImport
|
preLoaderRoute: typeof MultiplayerRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/forgot-password': {
|
||||||
id: '/login'
|
id: '/forgot-password'
|
||||||
path: '/login'
|
path: '/forgot-password'
|
||||||
fullPath: '/login'
|
fullPath: '/forgot-password'
|
||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof ForgotPasswordRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/about': {
|
'/about': {
|
||||||
|
|
@ -209,9 +229,10 @@ const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren(
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AboutRoute: AboutRoute,
|
AboutRoute: AboutRoute,
|
||||||
LoginRoute: LoginRoute,
|
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||||
MultiplayerRoute: MultiplayerRouteWithChildren,
|
MultiplayerRoute: MultiplayerRouteWithChildren,
|
||||||
PlayRoute: PlayRoute,
|
PlayRoute: PlayRoute,
|
||||||
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,39 @@
|
||||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
import {
|
||||||
|
createRootRoute,
|
||||||
|
Outlet,
|
||||||
|
useNavigate,
|
||||||
|
useSearch,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import Navbar from "../components/navbar/NavBar";
|
import Navbar from "../components/navbar/NavBar";
|
||||||
import NotFound from "../components/NotFound";
|
import NotFound from "../components/NotFound";
|
||||||
import RootError from "../components/RootError";
|
import RootError from "../components/RootError";
|
||||||
|
import { AuthModal } from "../components/auth/AuthModal";
|
||||||
|
import { AuthModalSearchSchema } from "@lila/shared";
|
||||||
|
|
||||||
const RootLayout = () => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
{modal === "auth" && (
|
||||||
|
<AuthModal onClose={handleClose} onSuccess={handleSuccess} />
|
||||||
|
)}
|
||||||
|
<Toaster richColors position="top-center" />
|
||||||
<TanStackRouterDevtools />
|
<TanStackRouterDevtools />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -20,4 +43,5 @@ export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
notFoundComponent: NotFound,
|
notFoundComponent: NotFound,
|
||||||
errorComponent: RootError,
|
errorComponent: RootError,
|
||||||
|
validateSearch: AuthModalSearchSchema,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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,
|
||||||
|
});
|
||||||
|
|
@ -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 <div className="p-4">Loading...</div>;
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
void navigate({ to: "/" });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
|
||||||
<h1 className="text-2xl font-bold">sign in to lila</h1>
|
|
||||||
<button
|
|
||||||
className="w-64 rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
|
|
||||||
onClick={() => {
|
|
||||||
void signIn
|
|
||||||
.social({ provider: "github", callbackURL: window.location.origin })
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("GitHub sign in error:", err);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Continue with GitHub
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-64 rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
|
||||||
onClick={() => {
|
|
||||||
void signIn
|
|
||||||
.social({ provider: "google", callbackURL: window.location.origin })
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Google sign in error:", err);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Continue with Google
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/login")({ component: LoginPage });
|
|
||||||
|
|
@ -14,7 +14,10 @@ export const Route = createFileRoute("/multiplayer")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const { data: session } = await authClient.getSession();
|
const { data: session } = await authClient.getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw redirect({ to: "/login" });
|
throw redirect({
|
||||||
|
to: "/",
|
||||||
|
search: { modal: "auth", redirect: "/multiplayer" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { session };
|
return { session };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ export const Route = createFileRoute("/play")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const { data: session } = await authClient.getSession();
|
const { data: session } = await authClient.getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw redirect({ to: "/login" });
|
throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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 } = Route.useSearch();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
@ -10,8 +10,6 @@ export default defineConfig([
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
"node_modules/",
|
"node_modules/",
|
||||||
"eslint.config.mjs",
|
|
||||||
"**/*.config.ts",
|
|
||||||
"routeTree.gen.ts",
|
"routeTree.gen.ts",
|
||||||
"scripts/**",
|
"scripts/**",
|
||||||
"data-pipeline/**/*",
|
"data-pipeline/**/*",
|
||||||
|
|
@ -24,12 +22,19 @@ export default defineConfig([
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
projectService: { allowDefaultProject: ["*.mjs", "*.ts"] },
|
||||||
tsconfigRootDir: import.meta.dirname,
|
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}"],
|
files: ["apps/web/**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
|
|
@ -43,6 +48,9 @@ export default defineConfig([
|
||||||
rules: {
|
rules: {
|
||||||
"react-refresh/only-export-components": "off",
|
"react-refresh/only-export-components": "off",
|
||||||
"@typescript-eslint/only-throw-error": "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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ import { dirname } from "path";
|
||||||
import * as schema from "./db/schema.js";
|
import * as schema from "./db/schema.js";
|
||||||
|
|
||||||
config({
|
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 });
|
export const db = drizzle(
|
||||||
|
process.env["DATABASE_URL_LOCAL"] ?? process.env["DATABASE_URL"]!,
|
||||||
|
{ schema },
|
||||||
|
);
|
||||||
|
|
||||||
export * from "./models/termModel.js";
|
export * from "./models/termModel.js";
|
||||||
export * from "./models/lobbyModel.js";
|
export * from "./models/lobbyModel.js";
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"include": [
|
"include": [
|
||||||
"src",
|
"src",
|
||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
|
"drizzle.config.ts",
|
||||||
"../../data-pipeline/archive/packages-db-src-old-seeding-scripts/data"
|
"../../data-pipeline/archive/packages-db-src-old-seeding-scripts/data"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./constants.js";
|
export * from "./constants.js";
|
||||||
export * from "./schemas/game.js";
|
export * from "./schemas/game.js";
|
||||||
export * from "./schemas/lobby.js";
|
export * from "./schemas/lobby.js";
|
||||||
|
export * from "./schemas/auth.js";
|
||||||
|
|
|
||||||
14
packages/shared/src/schemas/auth.ts
Normal file
14
packages/shared/src/schemas/auth.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
export const ResetPasswordSearchSchema = z.object({
|
||||||
|
token: z.string().catch(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResetPasswordSearch = z.infer<typeof ResetPasswordSearchSchema>;
|
||||||
|
|
||||||
|
export const AuthModalSearchSchema = z.object({
|
||||||
|
modal: z.enum(["auth"]).optional().catch(undefined),
|
||||||
|
redirect: z.string().optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuthModalSearch = z.infer<typeof AuthModalSearchSchema>;
|
||||||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
|
|
@ -74,6 +74,9 @@ importers:
|
||||||
helmet:
|
helmet:
|
||||||
specifier: ^8.1.0
|
specifier: ^8.1.0
|
||||||
version: 8.1.0
|
version: 8.1.0
|
||||||
|
resend:
|
||||||
|
specifier: ^6.12.2
|
||||||
|
version: 6.12.2
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.20.0
|
specifier: ^8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
|
|
@ -120,6 +123,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.4
|
specifier: ^19.2.4
|
||||||
version: 19.2.4(react@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:
|
tailwindcss:
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
|
|
@ -1090,6 +1096,9 @@ packages:
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||||
|
|
||||||
|
'@stablelib/base64@1.0.1':
|
||||||
|
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
|
@ -2116,6 +2125,9 @@ packages:
|
||||||
fast-safe-stringify@2.1.1:
|
fast-safe-stringify@2.1.1:
|
||||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
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:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
@ -2703,6 +2715,9 @@ packages:
|
||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
postal-mime@2.7.4:
|
||||||
|
resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.8:
|
||||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
@ -2794,6 +2809,15 @@ packages:
|
||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
|
|
@ -2914,6 +2938,12 @@ packages:
|
||||||
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
|
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
|
||||||
engines: {node: '>=20'}
|
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:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2940,6 +2970,9 @@ packages:
|
||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
standardwebhooks@1.0.0:
|
||||||
|
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
@ -2994,6 +3027,9 @@ packages:
|
||||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
svix@1.90.0:
|
||||||
|
resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==}
|
||||||
|
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
|
|
@ -3131,6 +3167,11 @@ packages:
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
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:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
@ -3937,6 +3978,8 @@ snapshots:
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||||
|
|
||||||
|
'@stablelib/base64@1.0.1': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@tailwindcss/node@4.2.2':
|
'@tailwindcss/node@4.2.2':
|
||||||
|
|
@ -5035,6 +5078,8 @@ snapshots:
|
||||||
|
|
||||||
fast-safe-stringify@2.1.1: {}
|
fast-safe-stringify@2.1.1: {}
|
||||||
|
|
||||||
|
fast-sha256@1.3.0: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.3):
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
@ -5542,6 +5587,8 @@ snapshots:
|
||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
postal-mime@2.7.4: {}
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
|
|
@ -5638,6 +5685,11 @@ snapshots:
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
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: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
restore-cursor@5.1.0:
|
restore-cursor@5.1.0:
|
||||||
|
|
@ -5791,6 +5843,11 @@ snapshots:
|
||||||
ansi-styles: 6.2.3
|
ansi-styles: 6.2.3
|
||||||
is-fullwidth-code-point: 5.1.0
|
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-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
|
|
@ -5810,6 +5867,11 @@ snapshots:
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
standardwebhooks@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@stablelib/base64': 1.0.1
|
||||||
|
fast-sha256: 1.3.0
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
std-env@4.0.0: {}
|
std-env@4.0.0: {}
|
||||||
|
|
@ -5877,6 +5939,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
svix@1.90.0:
|
||||||
|
dependencies:
|
||||||
|
standardwebhooks: 1.0.0
|
||||||
|
uuid: 10.0.0
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
tailwindcss@4.2.2: {}
|
tailwindcss@4.2.2: {}
|
||||||
|
|
@ -6007,6 +6074,8 @@ snapshots:
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
uuid@10.0.0: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
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):
|
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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue