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,
},