diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3033151..6eb4577 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as LoginImport } from './routes/login' import { Route as AboutImport } from './routes/about' import { Route as IndexImport } from './routes/index' import { Route as SessionsIndexImport } from './routes/sessions.index' @@ -19,6 +20,12 @@ import { Route as CampaignsCampaignIdImport } from './routes/campaigns.$campaign // Create/Update Routes +const LoginRoute = LoginImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRoute, +} as any) + const AboutRoute = AboutImport.update({ id: '/about', path: '/about', @@ -67,6 +74,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AboutImport parentRoute: typeof rootRoute } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginImport + parentRoute: typeof rootRoute + } '/campaigns/$campaignId': { id: '/campaigns/$campaignId' path: '/campaigns/$campaignId' @@ -96,6 +110,7 @@ declare module '@tanstack/react-router' { export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute + '/login': typeof LoginRoute '/campaigns/$campaignId': typeof CampaignsCampaignIdRoute '/campaigns': typeof CampaignsIndexRoute '/sessions': typeof SessionsIndexRoute @@ -104,6 +119,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute + '/login': typeof LoginRoute '/campaigns/$campaignId': typeof CampaignsCampaignIdRoute '/campaigns': typeof CampaignsIndexRoute '/sessions': typeof SessionsIndexRoute @@ -113,6 +129,7 @@ export interface FileRoutesById { __root__: typeof rootRoute '/': typeof IndexRoute '/about': typeof AboutRoute + '/login': typeof LoginRoute '/campaigns/$campaignId': typeof CampaignsCampaignIdRoute '/campaigns/': typeof CampaignsIndexRoute '/sessions/': typeof SessionsIndexRoute @@ -123,15 +140,23 @@ export interface FileRouteTypes { fullPaths: | '/' | '/about' + | '/login' | '/campaigns/$campaignId' | '/campaigns' | '/sessions' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' | '/campaigns/$campaignId' | '/campaigns' | '/sessions' + to: + | '/' + | '/about' + | '/login' + | '/campaigns/$campaignId' + | '/campaigns' + | '/sessions' id: | '__root__' | '/' | '/about' + | '/login' | '/campaigns/$campaignId' | '/campaigns/' | '/sessions/' @@ -141,6 +166,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute + LoginRoute: typeof LoginRoute CampaignsCampaignIdRoute: typeof CampaignsCampaignIdRoute CampaignsIndexRoute: typeof CampaignsIndexRoute SessionsIndexRoute: typeof SessionsIndexRoute @@ -149,6 +175,7 @@ export interface RootRouteChildren { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, + LoginRoute: LoginRoute, CampaignsCampaignIdRoute: CampaignsCampaignIdRoute, CampaignsIndexRoute: CampaignsIndexRoute, SessionsIndexRoute: SessionsIndexRoute, @@ -166,6 +193,7 @@ export const routeTree = rootRoute "children": [ "/", "/about", + "/login", "/campaigns/$campaignId", "/campaigns/", "/sessions/" @@ -177,6 +205,9 @@ export const routeTree = rootRoute "/about": { "filePath": "about.tsx" }, + "/login": { + "filePath": "login.tsx" + }, "/campaigns/$campaignId": { "filePath": "campaigns.$campaignId.tsx" }, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 39c2a73..d1920f6 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,52 +1,87 @@ import { Link, Outlet, createRootRoute } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -import { AuthProvider } from "@/context/auth/AuthContext"; +import { AuthProvider, useAuth } from "@/context/auth/AuthContext"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient(); -/** Root of the application */ +/** + * Root header with navigation and user authentication controls. + */ +function RootHeader() { + const { user, logout, isLoading } = useAuth(); + return ( +
+

+ DM's Table Companion +

+ +
+ {user ? ( + <> + + {user.email} + + + + ) : ( + + Log in + + )} +
+
+ ); +} + export const Route = createRootRoute({ component: () => ( -
-

- DM's Table Companion -

- -
+
diff --git a/src/routes/login.tsx b/src/routes/login.tsx new file mode 100644 index 0000000..a432257 --- /dev/null +++ b/src/routes/login.tsx @@ -0,0 +1,205 @@ +import { useState, useEffect } from "react"; +import { useAuth } from "@/context/auth/AuthContext"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { ClientResponseError } from "pocketbase"; + +/** + * Login and signup page for authentication. + * Allows users to log in or create a new account. + */ +export const Route = createFileRoute("/login")({ + component: LoginPage, +}); + +function LoginPage() { + const { login, signup, user, isLoading } = useAuth(); + const navigate = useNavigate(); + const [mode, setMode] = useState<"login" | "signup">("login"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState<{ + email?: string; + password?: string; + passwordConfirm?: string; + }>({}); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (user) { + navigate({ to: "/" }); + } + }, [user, navigate]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setFieldErrors({}); + setSubmitting(true); + try { + if (mode === "login") { + await login(email, password); + } else { + if (password !== passwordConfirm) { + setFieldErrors({ passwordConfirm: "Passwords do not match" }); + setSubmitting(false); + return; + } + await signup(email, password, passwordConfirm); + } + } catch (err: unknown) { + if (err instanceof ClientResponseError) { + setFieldErrors({ + email: err.response.data.email?.message, + password: err.response.data.password?.message, + passwordConfirm: err.response.data.passwordConfirm?.message, + }); + } else { + setError((err as Error)?.message || "Authentication failed"); + } + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

+ {mode === "login" ? "Sign In" : "Sign Up"} +

+
+ + setEmail(e.target.value)} + disabled={submitting || isLoading} + aria-invalid={!!fieldErrors.email} + aria-describedby={fieldErrors.email ? "email-error" : undefined} + /> + {fieldErrors.email && ( + + )} +
+
+ + setPassword(e.target.value)} + disabled={submitting || isLoading} + aria-invalid={!!fieldErrors.password} + aria-describedby={ + fieldErrors.password ? "password-error" : undefined + } + /> + {fieldErrors.password && ( + + )} +
+ {mode === "signup" && ( +
+ + setPasswordConfirm(e.target.value)} + disabled={submitting || isLoading} + aria-invalid={!!fieldErrors.passwordConfirm} + aria-describedby={ + fieldErrors.passwordConfirm + ? "passwordConfirm-error" + : undefined + } + /> + {fieldErrors.passwordConfirm && ( + + )} +
+ )} + {!fieldErrors.email && + !fieldErrors.password && + !fieldErrors.passwordConfirm && + error && ( +
+ {error} +
+ )} + + +
+
+ ); +}