feat: migrate authentication to Better-Auth (Phase 1)

Replace hand-rolled JWT auth with Better-Auth session-based authentication.

- Scaffold auth/ Node.js service with Better-Auth, bcrypt password compat,
  Postgres adapter mapped to existing users table
- Add Alembic migration (002) creating sessions, accounts, verifications
  tables and migrating password hashes to accounts table
- Update FastAPI auth dependency to validate sessions via shared DB
  (supports both cookie and Bearer token)
- Remove registration/login/refresh endpoints from API gateway (now
  handled by Better-Auth service)
- Update frontend to use better-auth/react client with httpOnly cookies
  (no tokens in localStorage or memory)
- Rewrite auth store, Login, Register, Dashboard, Settings, ProtectedRoute
  to use session-based auth
- Update all tests to create sessions directly in DB instead of JWT tokens

Resolves CAR-27
See plan: CAR-26#document-plan

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Coupon Carl
2026-03-28 04:46:10 +00:00
parent 3a31f82c8d
commit cfda1b544d
11 changed files with 562 additions and 527 deletions
+98 -100
View File
@@ -1,100 +1,98 @@
import { useAuthStore } from '../stores/auth.ts'
import {
mockPurchases,
mockProducts,
mockCoupons,
mockAlerts,
getMockPriceHistory,
} from './mock-data.ts'
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
// Mock response lookup table
const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases,
'/products': () => mockProducts,
'/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts,
}
function matchMockRoute<T>(path: string): T | null {
// Exact match
if (mockRoutes[path]) return mockRoutes[path](path) as T
// /purchases/:id
const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
if (purchaseMatch) {
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
return (purchase ?? null) as T
}
// /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T
}
// /products?q=search or /products/:id
const productMatch = path.match(/^\/products\/(.+)$/)
if (productMatch) {
const product = mockProducts.find((p) => p.id === productMatch[1])
return (product ?? null) as T
}
const productsSearch = path.match(/^\/products\?q=(.+)$/)
if (productsSearch) {
const q = decodeURIComponent(productsSearch[1]).toLowerCase()
return mockProducts.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.brand.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q),
) as T
}
return null
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
// Mock interceptor: return mock data without hitting the network
if (USE_MOCK && (!options?.method || options.method === 'GET')) {
const mockResult = matchMockRoute<T>(path)
if (mockResult !== null) {
// Simulate network delay for realistic loading states
await new Promise((r) => setTimeout(r, 300))
return mockResult
}
}
const token = useAuthStore.getState().token
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
})
if (res.status === 401) {
useAuthStore.getState().logout()
throw new Error('Unauthorized')
}
if (!res.ok) {
throw new Error(`API error: ${res.status}`)
}
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
}
import { useAuthStore } from '../stores/auth.ts'
import {
mockPurchases,
mockProducts,
mockCoupons,
mockAlerts,
getMockPriceHistory,
} from './mock-data.ts'
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
// Mock response lookup table
const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases,
'/products': () => mockProducts,
'/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts,
}
function matchMockRoute<T>(path: string): T | null {
// Exact match
if (mockRoutes[path]) return mockRoutes[path](path) as T
// /purchases/:id
const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
if (purchaseMatch) {
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
return (purchase ?? null) as T
}
// /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T
}
// /products/:id
const productMatch = path.match(/^\/products\/(.+)$/)
if (productMatch) {
const product = mockProducts.find((p) => p.id === productMatch[1])
return (product ?? null) as T
}
const productsSearch = path.match(/^\/products\?q=(.+)$/)
if (productsSearch) {
const q = decodeURIComponent(productsSearch[1]).toLowerCase()
return mockProducts.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.brand.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q),
) as T
}
return null
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
// Mock interceptor: return mock data without hitting the network
if (USE_MOCK && (!options?.method || options.method === 'GET')) {
const mockResult = matchMockRoute<T>(path)
if (mockResult !== null) {
// Simulate network delay for realistic loading states
await new Promise((r) => setTimeout(r, 300))
return mockResult
}
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
credentials: 'include', // Send Better-Auth session cookie
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (res.status === 401) {
useAuthStore.getState().setAuthenticated(false)
throw new Error('Unauthorized')
}
if (!res.ok) {
throw new Error(`API error: ${res.status}`)
}
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
}
+7
View File
@@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
})
export const { useSession, signIn, signUp, signOut } = authClient