feat(frontend): Finalize auth refactor, routing, and resolve all code review findings
This commit is contained in:
58
frontend/package-lock.json
generated
58
frontend/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"zod": "^4.3.6"
|
"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": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@@ -9008,6 +9060,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
import { Routes, Route } from "react-router-dom"
|
||||||
import { MainLayout } from "./components/layout/MainLayout"
|
import { MainLayout } from "./components/layout/MainLayout"
|
||||||
import { LoginForm } from "./components/auth/LoginForm"
|
import { LoginForm } from "./components/auth/LoginForm"
|
||||||
import { RegisterForm } from "./components/auth/RegisterForm"
|
import { RegisterForm } from "./components/auth/RegisterForm"
|
||||||
|
import { AuthCallback } from "./components/auth/AuthCallback"
|
||||||
import { AuthService, type UserResponse } from "./services/auth"
|
import { AuthService, type UserResponse } from "./services/auth"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -12,49 +14,42 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
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, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
const authStatus = AuthService.isAuthenticated()
|
|
||||||
setIsAuthenticated(authStatus)
|
|
||||||
|
|
||||||
if (authStatus) {
|
|
||||||
try {
|
try {
|
||||||
const userData = await AuthService.getMe()
|
const userData = await AuthService.getMe()
|
||||||
setUser(userData)
|
setUser(userData)
|
||||||
} catch (err) {
|
setIsAuthenticated(true)
|
||||||
console.error("Failed to fetch user profile:", err)
|
} catch (err: unknown) {
|
||||||
AuthService.logout()
|
// Not logged in or session expired - this is expected if no cookie
|
||||||
|
console.log("No active session found", err)
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
}
|
} finally {
|
||||||
}
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initAuth()
|
initAuth()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAuthSuccess = async () => {
|
const handleAuthSuccess = async () => {
|
||||||
setIsAuthenticated(true)
|
|
||||||
try {
|
try {
|
||||||
const userData = await AuthService.getMe()
|
const userData = await AuthService.getMe()
|
||||||
setUser(userData)
|
setUser(userData)
|
||||||
} catch (err) {
|
setIsAuthenticated(true)
|
||||||
|
} catch (err: unknown) {
|
||||||
console.error("Failed to fetch user profile after login:", err)
|
console.error("Failed to fetch user profile after login:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
AuthService.logout()
|
try {
|
||||||
|
await AuthService.logout()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Logout failed:", err)
|
||||||
|
} finally {
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -64,8 +59,13 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return (
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
!isAuthenticated ? (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
{authMode === "login" ? (
|
{authMode === "login" ? (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
@@ -79,15 +79,14 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
) : (
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Welcome, {user?.display_name || user?.email || "User"}!</h1>
|
<h1 className="text-2xl font-bold">
|
||||||
|
Welcome, {user?.display_name || user?.email || "User"}!
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -103,6 +102,10 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
30
frontend/src/components/auth/AuthCallback.tsx
Normal file
30
frontend/src/components/auth/AuthCallback.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-background">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Completing login...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import axios from "axios"
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
@@ -39,8 +40,12 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
|
|||||||
try {
|
try {
|
||||||
await AuthService.login(values.email, values.password)
|
await AuthService.login(values.email, values.password)
|
||||||
onSuccess()
|
onSuccess()
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
setError(err.response?.data?.detail || "Login failed. Please check your credentials.")
|
setError(err.response?.data?.detail || "Login failed. Please check your credentials.")
|
||||||
|
} else {
|
||||||
|
setError("An unexpected error occurred.")
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -49,7 +54,8 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
|
|||||||
const handleOIDCLogin = async () => {
|
const handleOIDCLogin = async () => {
|
||||||
try {
|
try {
|
||||||
await AuthService.loginWithOIDC()
|
await AuthService.loginWithOIDC()
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
|
console.error("SSO Login failed:", err)
|
||||||
setError("Failed to initialize SSO login.")
|
setError("Failed to initialize SSO login.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import axios from "axios"
|
||||||
|
|
||||||
interface RegisterFormProps {
|
interface RegisterFormProps {
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
@@ -38,12 +39,14 @@ export function RegisterForm({ onSuccess, onToggleMode }: RegisterFormProps) {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await AuthService.register(values.email, values.password)
|
await AuthService.register(values.email, values.password)
|
||||||
// Automatically login after registration or just switch to login?
|
// Since backend sets cookie on registration now, we can just call onSuccess
|
||||||
// For now, let's just switch to login or notify success
|
onSuccess()
|
||||||
alert("Registration successful! Please login.")
|
} catch (err: unknown) {
|
||||||
onToggleMode()
|
if (axios.isAxiosError(err)) {
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.detail || "Registration failed. Please try again.")
|
setError(err.response?.data?.detail || "Registration failed. Please try again.")
|
||||||
|
} else {
|
||||||
|
setError("An unexpected error occurred.")
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
35
frontend/src/components/ui/button-variants.ts
Normal file
35
frontend/src/components/ui/button-variants.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,42 +1,9 @@
|
|||||||
import * as React from "react"
|
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 { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "./button-variants"
|
||||||
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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -61,4 +28,4 @@ function Button({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button }
|
||||||
|
|||||||
44
frontend/src/components/ui/form-context.tsx
Normal file
44
frontend/src/components/ui/form-context.tsx
Normal file
@@ -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<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
export type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} 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 <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@ import { Slot } from "radix-ui"
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
FormProvider,
|
FormProvider,
|
||||||
useFormContext,
|
|
||||||
useFormState,
|
|
||||||
type ControllerProps,
|
type ControllerProps,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
@@ -15,20 +13,10 @@ import {
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { FormFieldContext, FormItemContext, useFormField } from "./form-context"
|
||||||
|
|
||||||
const Form = FormProvider
|
const Form = FormProvider
|
||||||
|
|
||||||
type FormFieldContextValue<
|
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
||||||
> = {
|
|
||||||
name: TName
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
|
||||||
{} as FormFieldContextValue
|
|
||||||
)
|
|
||||||
|
|
||||||
const FormField = <
|
const FormField = <
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
@@ -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 <FormField>")
|
|
||||||
}
|
|
||||||
|
|
||||||
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<FormItemContextValue>(
|
|
||||||
{} as FormItemContextValue
|
|
||||||
)
|
|
||||||
|
|
||||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
const id = React.useId()
|
const id = React.useId()
|
||||||
|
|
||||||
@@ -156,7 +113,6 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
|
||||||
Form,
|
Form,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
|||||||
22
frontend/src/components/ui/sidebar-context.tsx
Normal file
22
frontend/src/components/ui/sidebar-context.tsx
Normal file
@@ -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<SidebarContextProps | null>(null)
|
||||||
|
|
||||||
|
export function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
|
import { SidebarContext, useSidebar, type SidebarContextProps } from "./sidebar-context"
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
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_WIDTH_ICON = "3rem"
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
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<SidebarContextProps | null>(null)
|
|
||||||
|
|
||||||
function useSidebar() {
|
|
||||||
const context = React.useContext(SidebarContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarProvider({
|
function SidebarProvider({
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
open: openProp,
|
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({
|
function SidebarMenuButton({
|
||||||
asChild = false,
|
asChild = false,
|
||||||
isActive = false,
|
isActive = false,
|
||||||
@@ -507,7 +464,9 @@ function SidebarMenuButton({
|
|||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
variant?: "default" | "outline"
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
@@ -517,7 +476,15 @@ function SidebarMenuButton({
|
|||||||
data-sidebar="menu-button"
|
data-sidebar="menu-button"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
data-active={isActive}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -606,10 +573,8 @@ function SidebarMenuSkeleton({
|
|||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showIcon?: boolean
|
showIcon?: boolean
|
||||||
}) {
|
}) {
|
||||||
// Random width between 50 to 90%.
|
// Use a fixed width to avoid Math.random in render path which can cause hydrations issues and lint warnings.
|
||||||
const width = React.useMemo(() => {
|
const width = "70%"
|
||||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -674,7 +639,7 @@ function SidebarMenuSubButton({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"a"> & {
|
}: React.ComponentProps<"a"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
size?: "sm" | "md"
|
size?: "sm" | "md" | "lg"
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "a"
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
@@ -722,5 +687,4 @@ export {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
|
import { BrowserRouter } from "react-router-dom"
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<App />
|
<App />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,4 +7,18 @@ const api = axios.create({
|
|||||||
withCredentials: true, // Crucial for HttpOnly cookies
|
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
|
export default api
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ describe("AuthService", () => {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
display_name: "Test User"
|
display_name: "Test User"
|
||||||
}
|
}
|
||||||
mockedApi.get.mockResolvedValueOnce({ data: mockUser })
|
mockedApi.get.mockResolvedValueOnce({
|
||||||
|
data: mockUser,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
})
|
||||||
|
|
||||||
const result = await AuthService.getMe()
|
const result = await AuthService.getMe()
|
||||||
|
|
||||||
@@ -66,6 +69,15 @@ describe("AuthService", () => {
|
|||||||
expect(result).toEqual(mockUser)
|
expect(result).toEqual(mockUser)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("throws error on invalid non-JSON response", async () => {
|
||||||
|
mockedApi.get.mockResolvedValueOnce({
|
||||||
|
data: "<html>Some fallback</html>",
|
||||||
|
headers: { "content-type": "text/html" }
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(AuthService.getMe()).rejects.toThrow("Invalid response from server")
|
||||||
|
})
|
||||||
|
|
||||||
it("logs out and calls backend", async () => {
|
it("logs out and calls backend", async () => {
|
||||||
mockedApi.post.mockResolvedValueOnce({ data: { detail: "success" } })
|
mockedApi.post.mockResolvedValueOnce({ data: { detail: "success" } })
|
||||||
await AuthService.logout()
|
await AuthService.logout()
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export const AuthService = {
|
|||||||
|
|
||||||
async getMe(): Promise<UserResponse> {
|
async getMe(): Promise<UserResponse> {
|
||||||
const response = await api.get<UserResponse>("/auth/me")
|
const response = await api.get<UserResponse>("/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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/auth": {
|
"/api": {
|
||||||
target: "http://localhost:8000",
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
"/chat": {
|
|
||||||
target: "http://localhost:8000",
|
target: "http://localhost:8000",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user