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
This commit is contained in:
parent
6297dff399
commit
32ee1edf80
6 changed files with 261 additions and 18 deletions
246
apps/web/src/components/auth/AuthModal.tsx
Normal file
246
apps/web/src/components/auth/AuthModal.tsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<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 }: 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={onClose} />
|
||||
) : (
|
||||
<RegisterForm onSuccess={onClose} />
|
||||
)}
|
||||
|
||||
{/* Social */}
|
||||
<SocialButtons />
|
||||
</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;
|
||||
|
|
@ -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 = () => {
|
|||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Toaster richColors position="top-center" />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
);
|
||||
|
|
@ -20,4 +23,5 @@ export const Route = createRootRoute({
|
|||
component: RootLayout,
|
||||
notFoundComponent: NotFound,
|
||||
errorComponent: RootError,
|
||||
validateSearch: AuthModalSearchSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,3 +5,10 @@ export const ResetPasswordSearchSchema = z.object({
|
|||
});
|
||||
|
||||
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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue