feat(frontend): Refactor to cookie-based auth and add /api/v1 prefix
This commit is contained in:
8
frontend/.vite/deps/_metadata.json
Normal file
8
frontend/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "564e878b",
|
||||||
|
"configHash": "9fae4597",
|
||||||
|
"lockfileHash": "a2b50863",
|
||||||
|
"browserHash": "09c4652f",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
frontend/.vite/deps/package.json
Normal file
3
frontend/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -2,23 +2,66 @@ import { useState, useEffect } from "react"
|
|||||||
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 { AuthService } from "./services/auth"
|
import { AuthService, type UserResponse } from "./services/auth"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [user, setUser] = useState<UserResponse | null>(null)
|
||||||
const [authMode, setAuthMode] = useState<"login" | "register">("login")
|
const [authMode, setAuthMode] = useState<"login" | "register">("login")
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsAuthenticated(AuthService.isAuthenticated())
|
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 {
|
||||||
|
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 = () => {
|
const handleAuthSuccess = async () => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
|
try {
|
||||||
|
const userData = await AuthService.getMe()
|
||||||
|
setUser(userData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch user profile after login:", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
AuthService.logout()
|
AuthService.logout()
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -43,7 +86,10 @@ function App() {
|
|||||||
<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">
|
||||||
<h1 className="text-2xl font-bold">Welcome to Election Analytics</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Welcome, {user?.display_name || user?.email || "User"}!</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="text-sm text-muted-foreground hover:text-primary underline"
|
className="text-sm text-muted-foreground hover:text-primary underline"
|
||||||
@@ -51,7 +97,7 @@ function App() {
|
|||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground mt-4">
|
||||||
Select a conversation from the sidebar or start a new one to begin your analysis.
|
Select a conversation from the sidebar or start a new one to begin your analysis.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
@@ -45,6 +46,14 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOIDCLogin = async () => {
|
||||||
|
try {
|
||||||
|
await AuthService.loginWithOIDC()
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to initialize SSO login.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-md mx-auto">
|
<Card className="w-full max-w-md mx-auto">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -90,6 +99,20 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
<div className="relative my-4">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<Separator className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" type="button" className="w-full" onClick={handleOIDCLogin}>
|
||||||
|
Sign in with SSO
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<button
|
<button
|
||||||
|
|||||||
10
frontend/src/services/api.ts
Normal file
10
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ""
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: `${API_URL}/api/v1`,
|
||||||
|
withCredentials: true, // Crucial for HttpOnly cookies
|
||||||
|
})
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -1,47 +1,37 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"
|
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"
|
||||||
import axios from "axios"
|
import api from "./api"
|
||||||
import { AuthService } from "./auth"
|
import { AuthService } from "./auth"
|
||||||
|
|
||||||
vi.mock("axios")
|
vi.mock("./api")
|
||||||
const mockedAxios = axios as unknown as {
|
const mockedApi = api as unknown as {
|
||||||
post: MockInstance<typeof axios.post>
|
post: MockInstance<typeof api.post>
|
||||||
|
get: MockInstance<typeof api.get>
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("AuthService", () => {
|
describe("AuthService", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
localStorage.clear()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("successfully logs in and stores the token", async () => {
|
it("successfully logs in", async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: {
|
data: {
|
||||||
access_token: "fake-jwt-token",
|
access_token: "fake-jwt-token",
|
||||||
token_type: "bearer",
|
token_type: "bearer",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
mockedAxios.post.mockResolvedValueOnce(mockResponse)
|
mockedApi.post.mockResolvedValueOnce(mockResponse)
|
||||||
|
|
||||||
const result = await AuthService.login("test@example.com", "password123")
|
const result = await AuthService.login("test@example.com", "password123")
|
||||||
|
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith("/auth/login", expect.any(FormData))
|
expect(mockedApi.post).toHaveBeenCalledWith("/auth/login", expect.any(FormData))
|
||||||
|
|
||||||
// Validate FormData content
|
// Validate FormData content
|
||||||
const formData = (mockedAxios.post.mock.calls[0][1] as FormData)
|
const formData = (mockedApi.post.mock.calls[0][1] as FormData)
|
||||||
expect(formData.get("username")).toBe("test@example.com")
|
expect(formData.get("username")).toBe("test@example.com")
|
||||||
expect(formData.get("password")).toBe("password123")
|
expect(formData.get("password")).toBe("password123")
|
||||||
|
|
||||||
expect(result.access_token).toBe("fake-jwt-token")
|
expect(result.access_token).toBe("fake-jwt-token")
|
||||||
expect(localStorage.getItem("token")).toBe("fake-jwt-token")
|
|
||||||
expect(AuthService.isAuthenticated()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles login failure", async () => {
|
|
||||||
mockedAxios.post.mockRejectedValueOnce(new Error("Invalid credentials"))
|
|
||||||
|
|
||||||
await expect(AuthService.login("test@example.com", "wrong")).rejects.toThrow("Invalid credentials")
|
|
||||||
expect(localStorage.getItem("token")).toBeNull()
|
|
||||||
expect(AuthService.isAuthenticated()).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("successfully registers a user", async () => {
|
it("successfully registers a user", async () => {
|
||||||
@@ -51,22 +41,34 @@ describe("AuthService", () => {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
mockedAxios.post.mockResolvedValueOnce(mockResponse)
|
mockedApi.post.mockResolvedValueOnce(mockResponse)
|
||||||
|
|
||||||
const result = await AuthService.register("test@example.com", "password123")
|
const result = await AuthService.register("test@example.com", "password123")
|
||||||
|
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith("/auth/register", {
|
expect(mockedApi.post).toHaveBeenCalledWith("/auth/register", {
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
password: "password123",
|
password: "password123",
|
||||||
})
|
})
|
||||||
expect(result.email).toBe("test@example.com")
|
expect(result.email).toBe("test@example.com")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("logs out and clears the token", () => {
|
it("successfully fetches current user", async () => {
|
||||||
localStorage.setItem("token", "some-token")
|
const mockUser = {
|
||||||
expect(AuthService.isAuthenticated()).toBe(true)
|
id: "user-123",
|
||||||
AuthService.logout()
|
email: "test@example.com",
|
||||||
expect(localStorage.getItem("token")).toBeNull()
|
display_name: "Test User"
|
||||||
expect(AuthService.isAuthenticated()).toBe(false)
|
}
|
||||||
|
mockedApi.get.mockResolvedValueOnce({ data: mockUser })
|
||||||
|
|
||||||
|
const result = await AuthService.getMe()
|
||||||
|
|
||||||
|
expect(mockedApi.get).toHaveBeenCalledWith("/auth/me")
|
||||||
|
expect(result).toEqual(mockUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("logs out and calls backend", async () => {
|
||||||
|
mockedApi.post.mockResolvedValueOnce({ data: { detail: "success" } })
|
||||||
|
await AuthService.logout()
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith("/auth/logout")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import axios from "axios"
|
import api from "./api"
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || ""
|
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
@@ -10,38 +8,40 @@ export interface AuthResponse {
|
|||||||
export interface UserResponse {
|
export interface UserResponse {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
|
display_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthService = {
|
export const AuthService = {
|
||||||
async login(email: string, password: string): Promise<AuthResponse> {
|
async login(email: string, password: string): Promise<AuthResponse> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("username", email) // OAuth2PasswordRequestForm uses 'username'
|
formData.append("username", email)
|
||||||
formData.append("password", password)
|
formData.append("password", password)
|
||||||
|
|
||||||
const response = await axios.post<AuthResponse>(`${API_URL}/auth/login`, formData)
|
const response = await api.post<AuthResponse>("/auth/login", formData)
|
||||||
if (response.data.access_token) {
|
|
||||||
localStorage.setItem("token", response.data.access_token)
|
|
||||||
}
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loginWithOIDC() {
|
||||||
|
const response = await api.get<{ url: string }>("/auth/oidc/login")
|
||||||
|
if (response.data.url) {
|
||||||
|
window.location.href = response.data.url
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async register(email: string, password: string): Promise<UserResponse> {
|
async register(email: string, password: string): Promise<UserResponse> {
|
||||||
const response = await axios.post<UserResponse>(`${API_URL}/auth/register`, {
|
const response = await api.post<UserResponse>("/auth/register", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
logout() {
|
async getMe(): Promise<UserResponse> {
|
||||||
localStorage.removeItem("token")
|
const response = await api.get<UserResponse>("/auth/me")
|
||||||
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
getToken() {
|
async logout() {
|
||||||
return localStorage.getItem("token")
|
await api.post("/auth/logout")
|
||||||
},
|
|
||||||
|
|
||||||
isAuthenticated() {
|
|
||||||
return !!this.getToken()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/auth": {
|
||||||
|
target: "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/chat": {
|
||||||
|
target: "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
|||||||
Reference in New Issue
Block a user