diff --git a/frontend/package.json b/frontend/package.json index 5ddd6e5..9906d00 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,9 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.5.4", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "true-myth": "^8.0.1", + "zustand": "^5.0.1" }, "devDependencies": { "@eslint/js": "^9.13.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b06b89e..e8ce723 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) + true-myth: + specifier: ^8.0.1 + version: 8.0.1 + zustand: + specifier: ^5.0.1 + version: 5.0.1(@types/react@18.3.12)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)) devDependencies: '@eslint/js': specifier: ^9.13.0 @@ -1746,6 +1752,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + true-myth@8.0.1: + resolution: {integrity: sha512-/6Tga43I+LfMJmg7hiCGI8KqjjlDEa1XJnWTZOKmmyQ9s7Kv4WPmpoQT/LST0Q98GAnsB+UPtGFwVHi8h7+qFw==} + engines: {node: 18.* || >= 20.*} + ts-api-utils@1.4.0: resolution: {integrity: sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==} engines: {node: '>=16'} @@ -1908,6 +1918,24 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zustand@5.0.1: + resolution: {integrity: sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3433,6 +3461,8 @@ snapshots: dependencies: is-number: 7.0.0 + true-myth@8.0.1: {} + ts-api-utils@1.4.0(typescript@5.6.3): dependencies: typescript: 5.6.3 @@ -3579,3 +3609,9 @@ snapshots: yocto-queue@0.1.0: {} zod@3.23.8: {} + + zustand@5.0.1(@types/react@18.3.12)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.12 + react: 18.3.1 + use-sync-external-store: 1.2.2(react@18.3.1) diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..cd894d3 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,37 @@ +import Result, { ok, err } from "true-myth/result"; +import { useUserStore } from "./state"; + +// Authentication on the frontend is managed by Zustand's state. +// Upon application load, a single request is made to acquire session state. +// Any route that requires authentication will redirect if a valid session is not acquired. +// The session state is stored in the user store. +// If any protected API call suddenly fails with a 401 status code, the user store is reset, a logout message is displayed, and the user is redirected to the login page. +// All redirects to the login page will carry a masked URL parameter that can be used to redirect the user back to the page they were on after logging in. + +const TARGET = import.meta.env.DEV + ? `http://${import.meta.env.VITE_BACKEND_TARGET}` + : ""; + +console.log({ env: import.meta.env, target: TARGET }); +type ErrorResponse = { + detail: string; +}; + +type SessionResponse = { + user: {}; +}; + +export const getSession = async (): Promise< + Result +> => { + const response = await fetch(TARGET + "/api/session"); + if (response.ok) { + const user = await response.json(); + useUserStore.getState().setUser(user); + return ok({ user }); + } else { + useUserStore.getState().logout(); + const error = await response.json(); + return err({ detail: error.detail }); + } +}; diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts new file mode 100644 index 0000000..728fcb1 --- /dev/null +++ b/frontend/src/lib/state.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +type UserState = { + user: { + // TODO: This will eventually carry more user information (name, avatar, etc.) + email: string; + } | null; +}; + +type UserActions = { + setUser: (user: UserState["user"]) => void; + logout: () => void; +}; + +export const useUserStore = create((set) => ({ + user: null, + setUser: (user) => set({ user }), + logout: () => set({ user: null }), +})); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 82a622b..12f00c3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"; import { StrictMode } from "react"; +import { getSession } from "@/lib/auth"; import ReactDOM from "react-dom/client"; import "./index.css"; @@ -9,6 +10,8 @@ import { routeTree } from "./routeTree.gen"; // Create a new router instance const router = createRouter({ routeTree }); +getSession(); + // Register the router instance for type safety declare module "@tanstack/react-router" { interface Register { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index d3d440b..d5d7791 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -13,19 +13,26 @@ import { createFileRoute } from '@tanstack/react-router' // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as LoginImport } from './routes/login' +import { Route as DashboardImport } from './routes/dashboard' // Create Virtual Routes -const LoginLazyImport = createFileRoute('/login')() const IndexLazyImport = createFileRoute('/')() // Create/Update Routes -const LoginLazyRoute = LoginLazyImport.update({ +const LoginRoute = LoginImport.update({ id: '/login', path: '/login', getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/login.lazy').then((d) => d.Route)) +} as any) + +const DashboardRoute = DashboardImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRoute, +} as any) const IndexLazyRoute = IndexLazyImport.update({ id: '/', @@ -44,11 +51,18 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexLazyImport parentRoute: typeof rootRoute } + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardImport + parentRoute: typeof rootRoute + } '/login': { id: '/login' path: '/login' fullPath: '/login' - preLoaderRoute: typeof LoginLazyImport + preLoaderRoute: typeof LoginImport parentRoute: typeof rootRoute } } @@ -58,37 +72,42 @@ declare module '@tanstack/react-router' { export interface FileRoutesByFullPath { '/': typeof IndexLazyRoute - '/login': typeof LoginLazyRoute + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute } export interface FileRoutesByTo { '/': typeof IndexLazyRoute - '/login': typeof LoginLazyRoute + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute } export interface FileRoutesById { __root__: typeof rootRoute '/': typeof IndexLazyRoute - '/login': typeof LoginLazyRoute + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/login' + fullPaths: '/' | '/dashboard' | '/login' fileRoutesByTo: FileRoutesByTo - to: '/' | '/login' - id: '__root__' | '/' | '/login' + to: '/' | '/dashboard' | '/login' + id: '__root__' | '/' | '/dashboard' | '/login' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexLazyRoute: typeof IndexLazyRoute - LoginLazyRoute: typeof LoginLazyRoute + DashboardRoute: typeof DashboardRoute + LoginRoute: typeof LoginRoute } const rootRouteChildren: RootRouteChildren = { IndexLazyRoute: IndexLazyRoute, - LoginLazyRoute: LoginLazyRoute, + DashboardRoute: DashboardRoute, + LoginRoute: LoginRoute, } export const routeTree = rootRoute @@ -102,14 +121,18 @@ export const routeTree = rootRoute "filePath": "__root.tsx", "children": [ "/", + "/dashboard", "/login" ] }, "/": { "filePath": "index.lazy.tsx" }, + "/dashboard": { + "filePath": "dashboard.tsx" + }, "/login": { - "filePath": "login.lazy.tsx" + "filePath": "login.tsx" } } } diff --git a/frontend/src/routes/dashboard.tsx b/frontend/src/routes/dashboard.tsx new file mode 100644 index 0000000..1b14c8c --- /dev/null +++ b/frontend/src/routes/dashboard.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/dashboard")({ + component: RouteComponent, +}); + +function RouteComponent() { + return "Hello /dashboard!"; +} diff --git a/frontend/src/routes/login.lazy.tsx b/frontend/src/routes/login.tsx similarity index 86% rename from frontend/src/routes/login.lazy.tsx rename to frontend/src/routes/login.tsx index ef46739..146803e 100644 --- a/frontend/src/routes/login.lazy.tsx +++ b/frontend/src/routes/login.tsx @@ -1,14 +1,25 @@ -import { createLazyFileRoute } from "@tanstack/react-router"; - -export const Route = createLazyFileRoute("/login")({ - component: Login, -}); +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { useUserStore } from "@/lib/state"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { UserAuthForm } from "@/components/auth/UserAuthForm"; import { Icons } from "@/components/icons"; +export const Route = createFileRoute("/login")({ + beforeLoad: async ({ location }) => { + const isLoggedIn = useUserStore.getState().user !== null; + + if (isLoggedIn) { + return redirect({ + to: "/dashboard", + search: { redirect: location.href }, + }); + } + }, + component: Login, +}); + function Login() { return ( <>