diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9cb3723..dd5f5cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-router-dom": "^7.13.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "zod": "^4.3.6" @@ -8711,6 +8712,57 @@ } } }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -9008,6 +9060,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e55fa5c..64f268e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-router-dom": "^7.13.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "zod": "^4.3.6" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88e7395..68be3aa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from "react" +import { Routes, Route } from "react-router-dom" import { MainLayout } from "./components/layout/MainLayout" import { LoginForm } from "./components/auth/LoginForm" import { RegisterForm } from "./components/auth/RegisterForm" +import { AuthCallback } from "./components/auth/AuthCallback" import { AuthService, type UserResponse } from "./services/auth" function App() { @@ -12,48 +14,41 @@ function App() { useEffect(() => { const initAuth = async () => { - // Check for token in URL (OIDC callback) - const urlParams = new URLSearchParams(window.location.search) - const tokenFromUrl = urlParams.get("token") - if (tokenFromUrl) { - localStorage.setItem("token", tokenFromUrl) - // Clean URL - window.history.replaceState({}, document.title, "/") + try { + const userData = await AuthService.getMe() + setUser(userData) + setIsAuthenticated(true) + } catch (err: unknown) { + // Not logged in or session expired - this is expected if no cookie + console.log("No active session found", err) + setIsAuthenticated(false) + } finally { + setIsLoading(false) } - - const authStatus = AuthService.isAuthenticated() - setIsAuthenticated(authStatus) - - if (authStatus) { - try { - const userData = await AuthService.getMe() - setUser(userData) - } catch (err) { - console.error("Failed to fetch user profile:", err) - AuthService.logout() - setIsAuthenticated(false) - } - } - setIsLoading(false) } initAuth() }, []) const handleAuthSuccess = async () => { - setIsAuthenticated(true) try { const userData = await AuthService.getMe() setUser(userData) - } catch (err) { + setIsAuthenticated(true) + } catch (err: unknown) { console.error("Failed to fetch user profile after login:", err) } } - const handleLogout = () => { - AuthService.logout() - setIsAuthenticated(false) - setUser(null) + const handleLogout = async () => { + try { + await AuthService.logout() + } catch (err: unknown) { + console.error("Logout failed:", err) + } finally { + setIsAuthenticated(false) + setUser(null) + } } if (isLoading) { @@ -64,44 +59,52 @@ function App() { ) } - if (!isAuthenticated) { - return ( -
- {authMode === "login" ? ( - setAuthMode("register")} - /> - ) : ( - setAuthMode("login")} - /> - )} -
- ) - } - return ( - -
-
-
-

Welcome, {user?.display_name || user?.email || "User"}!

-

{user?.email}

-
- -
-

- Select a conversation from the sidebar or start a new one to begin your analysis. -

-
-
+ + } /> + + {authMode === "login" ? ( + setAuthMode("register")} + /> + ) : ( + setAuthMode("login")} + /> + )} + + ) : ( + +
+
+
+

+ Welcome, {user?.display_name || user?.email || "User"}! +

+

{user?.email}

+
+ +
+

+ Select a conversation from the sidebar or start a new one to begin your analysis. +

+
+
+ ) + } + /> +
) } diff --git a/frontend/src/components/auth/AuthCallback.tsx b/frontend/src/components/auth/AuthCallback.tsx new file mode 100644 index 0000000..00f4846 --- /dev/null +++ b/frontend/src/components/auth/AuthCallback.tsx @@ -0,0 +1,30 @@ +import { useEffect } from "react" +import { useNavigate } from "react-router-dom" +import { AuthService } from "@/services/auth" + +export function AuthCallback() { + const navigate = useNavigate() + + useEffect(() => { + const verifyAuth = async () => { + try { + // The cookie should have been set by the backend redirect + await AuthService.getMe() + // Success - go to home. We use window.location.href to ensure a clean reload of App state + window.location.href = "/" + } catch (err) { + console.error("Auth callback verification failed:", err) + navigate("/?error=auth_failed", { replace: true }) + } + } + + verifyAuth() + }, [navigate]) + + return ( +
+
+

Completing login...

+
+ ) +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index 8c7db16..9619a5a 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { useState } from "react" import { Separator } from "@/components/ui/separator" +import axios from "axios" interface LoginFormProps { onSuccess: () => void @@ -39,8 +40,12 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) { try { await AuthService.login(values.email, values.password) onSuccess() - } catch (err: any) { - setError(err.response?.data?.detail || "Login failed. Please check your credentials.") + } catch (err: unknown) { + if (axios.isAxiosError(err)) { + setError(err.response?.data?.detail || "Login failed. Please check your credentials.") + } else { + setError("An unexpected error occurred.") + } } finally { setIsLoading(false) } @@ -49,7 +54,8 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) { const handleOIDCLogin = async () => { try { await AuthService.loginWithOIDC() - } catch (err) { + } catch (err: unknown) { + console.error("SSO Login failed:", err) setError("Failed to initialize SSO login.") } } diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx index 97ba60a..7c58654 100644 --- a/frontend/src/components/auth/RegisterForm.tsx +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -14,6 +14,7 @@ import { import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { useState } from "react" +import axios from "axios" interface RegisterFormProps { onSuccess: () => void @@ -38,12 +39,14 @@ export function RegisterForm({ onSuccess, onToggleMode }: RegisterFormProps) { setError(null) try { await AuthService.register(values.email, values.password) - // Automatically login after registration or just switch to login? - // For now, let's just switch to login or notify success - alert("Registration successful! Please login.") - onToggleMode() - } catch (err: any) { - setError(err.response?.data?.detail || "Registration failed. Please try again.") + // Since backend sets cookie on registration now, we can just call onSuccess + onSuccess() + } catch (err: unknown) { + if (axios.isAxiosError(err)) { + setError(err.response?.data?.detail || "Registration failed. Please try again.") + } else { + setError("An unexpected error occurred.") + } } finally { setIsLoading(false) } diff --git a/frontend/src/components/ui/button-variants.ts b/frontend/src/components/ui/button-variants.ts new file mode 100644 index 0000000..765bdfe --- /dev/null +++ b/frontend/src/components/ui/button-variants.ts @@ -0,0 +1,35 @@ +import { cva } from "class-variance-authority" + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index b5ea4ab..6ffa461 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,42 +1,9 @@ import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import { type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) +import { buttonVariants } from "./button-variants" function Button({ className, @@ -61,4 +28,4 @@ function Button({ ) } -export { Button, buttonVariants } +export { Button } diff --git a/frontend/src/components/ui/form-context.tsx b/frontend/src/components/ui/form-context.tsx new file mode 100644 index 0000000..d29b68b --- /dev/null +++ b/frontend/src/components/ui/form-context.tsx @@ -0,0 +1,44 @@ +import * as React from "react" +import { useFormContext, useFormState, type FieldPath, type FieldValues } from "react-hook-form" + +export type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +export const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +export type FormItemContextValue = { + id: string +} + +export const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +export const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx index b438928..06a5a89 100644 --- a/frontend/src/components/ui/form.tsx +++ b/frontend/src/components/ui/form.tsx @@ -6,8 +6,6 @@ import { Slot } from "radix-ui" import { Controller, FormProvider, - useFormContext, - useFormState, type ControllerProps, type FieldPath, type FieldValues, @@ -15,20 +13,10 @@ import { import { cn } from "@/lib/utils" import { Label } from "@/components/ui/label" +import { FormFieldContext, FormItemContext, useFormField } from "./form-context" const Form = FormProvider -type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = { - name: TName -} - -const FormFieldContext = React.createContext( - {} as FormFieldContextValue -) - const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, @@ -42,37 +30,6 @@ const FormField = < ) } -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState } = useFormContext() - const formState = useFormState({ name: fieldContext.name }) - const fieldState = getFieldState(fieldContext.name, formState) - - if (!fieldContext) { - throw new Error("useFormField should be used within ") - } - - const { id } = itemContext - - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - } -} - -type FormItemContextValue = { - id: string -} - -const FormItemContext = React.createContext( - {} as FormItemContextValue -) - function FormItem({ className, ...props }: React.ComponentProps<"div">) { const id = React.useId() @@ -156,7 +113,6 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) { } export { - useFormField, Form, FormItem, FormLabel, diff --git a/frontend/src/components/ui/sidebar-context.tsx b/frontend/src/components/ui/sidebar-context.tsx new file mode 100644 index 0000000..9ed356e --- /dev/null +++ b/frontend/src/components/ui/sidebar-context.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +export type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +export const SidebarContext = React.createContext(null) + +export function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index 6a50783..0b0154e 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -1,7 +1,6 @@ "use client" import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" import { PanelLeftIcon } from "lucide-react" import { Slot } from "radix-ui" @@ -24,6 +23,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" +import { SidebarContext, useSidebar, type SidebarContextProps } from "./sidebar-context" const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 @@ -32,27 +32,6 @@ const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" -type SidebarContextProps = { - state: "expanded" | "collapsed" - open: boolean - setOpen: (open: boolean) => void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean - toggleSidebar: () => void -} - -const SidebarContext = React.createContext(null) - -function useSidebar() { - const context = React.useContext(SidebarContext) - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider.") - } - - return context -} - function SidebarProvider({ defaultOpen = true, open: openProp, @@ -473,28 +452,6 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { ) } -const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - function SidebarMenuButton({ asChild = false, isActive = false, @@ -507,7 +464,9 @@ function SidebarMenuButton({ asChild?: boolean isActive?: boolean tooltip?: string | React.ComponentProps -} & VariantProps) { + variant?: "default" | "outline" + size?: "default" | "sm" | "lg" +}) { const Comp = asChild ? Slot.Root : "button" const { isMobile, state } = useSidebar() @@ -517,7 +476,15 @@ function SidebarMenuButton({ data-sidebar="menu-button" data-size={size} data-active={isActive} - className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + className={cn( + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + variant === "default" && "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + variant === "outline" && "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + size === "default" && "h-8 text-sm", + size === "sm" && "h-7 text-xs", + size === "lg" && "h-12 text-sm group-data-[collapsible=icon]:p-0!", + className + )} {...props} /> ) @@ -606,10 +573,8 @@ function SidebarMenuSkeleton({ }: React.ComponentProps<"div"> & { showIcon?: boolean }) { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%` - }, []) + // Use a fixed width to avoid Math.random in render path which can cause hydrations issues and lint warnings. + const width = "70%" return (
& { asChild?: boolean - size?: "sm" | "md" + size?: "sm" | "md" | "lg" isActive?: boolean }) { const Comp = asChild ? Slot.Root : "a" @@ -722,5 +687,4 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, - useSidebar, } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b805a8f..1aed6af 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { TooltipProvider } from "@/components/ui/tooltip" +import { BrowserRouter } from "react-router-dom" createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 69bd1f7..6d34a7a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -7,4 +7,18 @@ const api = axios.create({ withCredentials: true, // Crucial for HttpOnly cookies }) +// Add a response interceptor to handle 401s +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Unauthorized - session likely expired + // We can't use useNavigate here as it's not a React component + // But we can redirect to home which will trigger the login view in App.tsx + window.location.href = "/" + } + return Promise.reject(error) + } +) + export default api diff --git a/frontend/src/services/auth.test.ts b/frontend/src/services/auth.test.ts index 11b452d..c11d08d 100644 --- a/frontend/src/services/auth.test.ts +++ b/frontend/src/services/auth.test.ts @@ -58,7 +58,10 @@ describe("AuthService", () => { email: "test@example.com", display_name: "Test User" } - mockedApi.get.mockResolvedValueOnce({ data: mockUser }) + mockedApi.get.mockResolvedValueOnce({ + data: mockUser, + headers: { "content-type": "application/json" } + }) const result = await AuthService.getMe() @@ -66,6 +69,15 @@ describe("AuthService", () => { expect(result).toEqual(mockUser) }) + it("throws error on invalid non-JSON response", async () => { + mockedApi.get.mockResolvedValueOnce({ + data: "Some fallback", + headers: { "content-type": "text/html" } + }) + + await expect(AuthService.getMe()).rejects.toThrow("Invalid response from server") + }) + it("logs out and calls backend", async () => { mockedApi.post.mockResolvedValueOnce({ data: { detail: "success" } }) await AuthService.logout() diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index ed84450..1b322a8 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -38,6 +38,11 @@ export const AuthService = { async getMe(): Promise { const response = await api.get("/auth/me") + // Double check that we got JSON and not an HTML fallback + const contentType = response.headers["content-type"] + if (contentType && !contentType.includes("application/json")) { + throw new Error("Invalid response from server") + } return response.data }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 37fc2f1..f49477f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,11 +13,7 @@ export default defineConfig({ }, server: { proxy: { - "/auth": { - target: "http://localhost:8000", - changeOrigin: true, - }, - "/chat": { + "/api": { target: "http://localhost:8000", changeOrigin: true, },