diff --git a/.gitignore b/.gitignore
index a547bf3..438657a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
+.env
# Editor directories and files
.vscode/*
diff --git a/CLAUDE.md b/CLAUDE.md
index 623f979..8f6fc1a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,6 +12,7 @@ CartSnitch is a self-hosted grocery price intelligence platform. This repo (`car
| Directory | Service | Purpose |
|-----------|---------|---------|
| `/` (root) | Frontend | React PWA, mobile-first (this directory) |
+| `auth/` | Auth | Better-Auth Node.js service (session management, email/password, OAuth) |
| `api/` | API Gateway | Frontend-facing REST API |
| `common/` | Common | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
@@ -166,9 +167,13 @@ frontend/
All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`.
-- JWT auth: store access token in memory (not localStorage), refresh token in httpOnly cookie if possible, or secure storage.
+- **Authentication via Better-Auth** (`auth/` service). Sessions are managed via httpOnly cookies — no tokens in localStorage or memory.
+ - Auth service URL configured via `VITE_AUTH_URL` (default: `http://localhost:3001`)
+ - Frontend uses `better-auth/react` client for sign-in, sign-up, sign-out, and `useSession()` hook
+ - API gateway validates sessions by querying the shared `sessions` table in Postgres
+ - Both cookie-based and Bearer token auth are supported (cookies for web, Bearer for API clients)
- TanStack Query handles caching, background refetching, and optimistic updates.
-- API client should handle 401 responses by attempting token refresh before retrying.
+- API client sends `credentials: 'include'` on all requests to forward session cookies.
## Development Workflow
diff --git a/package.json b/package.json
index cbab402..67e3891 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
+ "better-auth": "^1.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.0",
diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx
index b6bb0fa..6c3df87 100644
--- a/src/components/ProtectedRoute.tsx
+++ b/src/components/ProtectedRoute.tsx
@@ -1,10 +1,25 @@
+import { useEffect } from 'react'
import { Navigate, Outlet } from 'react-router-dom'
+import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() {
- const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
+ const { data: session, isPending } = authClient.useSession()
+ const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
- if (!isAuthenticated) {
+ useEffect(() => {
+ setAuthenticated(!!session)
+ }, [session, setAuthenticated])
+
+ if (isPending) {
+ return (
+
+ )
+ }
+
+ if (!session) {
return
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index beaced7..3907dde 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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 unknown> = {
- '/purchases': () => mockPurchases,
- '/products': () => mockProducts,
- '/coupons': () => mockCoupons,
- '/price-alerts': () => mockAlerts,
-}
-
-function matchMockRoute(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(path: string, options?: RequestInit): Promise {
- // Mock interceptor: return mock data without hitting the network
- if (USE_MOCK && (!options?.method || options.method === 'GET')) {
- const mockResult = matchMockRoute(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
-}
-
-export const api = {
- get: (path: string) => apiFetch(path),
- post: (path: string, body: unknown) =>
- apiFetch(path, { method: 'POST', body: JSON.stringify(body) }),
- put: (path: string, body: unknown) =>
- apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }),
- delete: (path: string) => apiFetch(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 unknown> = {
+ '/purchases': () => mockPurchases,
+ '/products': () => mockProducts,
+ '/coupons': () => mockCoupons,
+ '/price-alerts': () => mockAlerts,
+}
+
+function matchMockRoute(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(path: string, options?: RequestInit): Promise {
+ // Mock interceptor: return mock data without hitting the network
+ if (USE_MOCK && (!options?.method || options.method === 'GET')) {
+ const mockResult = matchMockRoute(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
+}
+
+export const api = {
+ get: (path: string) => apiFetch(path),
+ post: (path: string, body: unknown) =>
+ apiFetch(path, { method: 'POST', body: JSON.stringify(body) }),
+ put: (path: string, body: unknown) =>
+ apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }),
+ delete: (path: string) => apiFetch(path, { method: 'DELETE' }),
+}
diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts
new file mode 100644
index 0000000..d724fb8
--- /dev/null
+++ b/src/lib/auth-client.ts
@@ -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
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index e68c63a..d1e885f 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -1,197 +1,200 @@
-import React, { Suspense } from 'react'
-import { Link } from 'react-router-dom'
-import { useAuthStore } from '../stores/auth.ts'
-import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts'
-import { StoreIcon } from '../components/StoreIcon.tsx'
-
-const LazySparklineCard = React.lazy(() =>
- import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
-)
-
-export function Dashboard() {
- const user = useAuthStore((s) => s.user)
- const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
-
- if (!isAuthenticated) {
- return (
-
-
CartSnitch
-
Track prices. Save money.
-
-
- Sign In
-
-
- Create Account
-
-
-
- )
- }
-
- return
-}
-
-function AuthenticatedDashboard({ userName }: { userName: string }) {
- const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
- const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
- const { data: eggHistory = [] } = usePriceHistory('prod10')
- const { data: milkHistory = [] } = usePriceHistory('prod1')
-
- const triggeredAlerts = alerts.filter((a) => a.triggered)
- const watchingAlerts = alerts.filter((a) => !a.triggered)
- const recentPurchases = purchases.slice(0, 3)
-
- const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
- const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
-
- const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
- const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
-
- if (purchasesLoading || alertsLoading) {
- return
- }
-
- return (
-
-
- Hi, {userName.split(' ')[0]}
-
-
- {/* Triggered alerts banner */}
- {triggeredAlerts.length > 0 && (
-
-
- ✓
-
-
-
- {triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
-
-
- {triggeredAlerts.map((a) => a.productName).join(', ')}
-
-
-
- )}
-
- {/* Quick stats */}
-
-
-
Watching
-
{watchingAlerts.length}
-
price alerts
-
-
-
This Month
-
- ${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
-
-
grocery spend
-
-
-
- {/* Price trend sparklines */}
-
- Price Trends
-
- }>
-
-
-
-
-
-
- {/* Recent purchases */}
-
-
-
Recent Purchases
-
- View all
-
-
-
- {recentPurchases.map((purchase) => (
-
-
-
-
{purchase.storeName}
-
- {new Date(purchase.date).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- })}{' '}
- · {purchase.items.length} items
-
-
-
- ${purchase.total.toFixed(2)}
-
-
- ))}
-
-
-
- {/* Quick actions */}
-
- Quick Actions
-
-
- Compare Prices
-
-
- Link a Store
-
-
-
-
- )
-}
-
-function DashboardSkeleton() {
- return (
-
- )
-}
-
-function SparklinePlaceholder() {
- return (
-
- )
-}
+import React, { Suspense } from 'react'
+import { Link } from 'react-router-dom'
+import { authClient } from '../lib/auth-client.ts'
+import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts'
+import { StoreIcon } from '../components/StoreIcon.tsx'
+
+const LazySparklineCard = React.lazy(() =>
+ import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
+)
+
+export function Dashboard() {
+ const { data: session, isPending } = authClient.useSession()
+
+ if (isPending) {
+ return
+ }
+
+ if (!session) {
+ return (
+
+
CartSnitch
+
Track prices. Save money.
+
+
+ Sign In
+
+
+ Create Account
+
+
+
+ )
+ }
+
+ return
+}
+
+function AuthenticatedDashboard({ userName }: { userName: string }) {
+ const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
+ const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
+ const { data: eggHistory = [] } = usePriceHistory('prod10')
+ const { data: milkHistory = [] } = usePriceHistory('prod1')
+
+ const triggeredAlerts = alerts.filter((a) => a.triggered)
+ const watchingAlerts = alerts.filter((a) => !a.triggered)
+ const recentPurchases = purchases.slice(0, 3)
+
+ const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
+ const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
+
+ const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
+ const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
+
+ if (purchasesLoading || alertsLoading) {
+ return
+ }
+
+ return (
+
+
+ Hi, {userName.split(' ')[0]}
+
+
+ {/* Triggered alerts banner */}
+ {triggeredAlerts.length > 0 && (
+
+
+ ✓
+
+
+
+ {triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
+
+
+ {triggeredAlerts.map((a) => a.productName).join(', ')}
+
+
+
+ )}
+
+ {/* Quick stats */}
+
+
+
Watching
+
{watchingAlerts.length}
+
price alerts
+
+
+
This Month
+
+ ${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
+
+
grocery spend
+
+
+
+ {/* Price trend sparklines */}
+
+ Price Trends
+
+ }>
+
+
+
+
+
+
+ {/* Recent purchases */}
+
+
+
Recent Purchases
+
+ View all
+
+
+
+ {recentPurchases.map((purchase) => (
+
+
+
+
{purchase.storeName}
+
+ {new Date(purchase.date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ })}{' '}
+ · {purchase.items.length} items
+
+
+
+ ${purchase.total.toFixed(2)}
+
+
+ ))}
+
+
+
+ {/* Quick actions */}
+
+ Quick Actions
+
+
+ Compare Prices
+
+
+ Link a Store
+
+
+
+
+ )
+}
+
+function DashboardSkeleton() {
+ return (
+
+ )
+}
+
+function SparklinePlaceholder() {
+ return (
+
+ )
+}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index d29b0f1..7489f81 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -1,92 +1,97 @@
-import { useState } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
-import { useAuthStore } from '../stores/auth.ts'
-import { api } from '../lib/api.ts'
-import { mockUser } from '../lib/mock-data.ts'
-import type { User } from '../types/api.ts'
-
-export function Login() {
- const [email, setEmail] = useState('')
- const [password, setPassword] = useState('')
- const [error, setError] = useState('')
- const [loading, setLoading] = useState(false)
- const navigate = useNavigate()
- const setAuth = useAuthStore((s) => s.setAuth)
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault()
- setError('')
-
- if (!email || !password) {
- setError('Please fill in all fields.')
- return
- }
-
- setLoading(true)
- try {
- const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
- setAuth(res.user, res.token)
- navigate('/')
- } catch {
- if (import.meta.env.VITE_MOCK_AUTH === 'true') {
- // Fallback to mock auth for demo
- setAuth(mockUser, 'mock-jwt-token')
- navigate('/')
- } else {
- setError('Invalid email or password. Please try again.')
- }
- } finally {
- setLoading(false)
- }
- }
-
- return (
-
-
CartSnitch
-
Track prices. Save money.
-
- {error && (
-
- {error}
-
- )}
-
-
-
-
- Forgot password?
-
-
-
- Don't have an account?{' '}
-
- Sign up
-
-
-
- )
-}
+import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { authClient } from '../lib/auth-client.ts'
+import { useAuthStore } from '../stores/auth.ts'
+
+export function Login() {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+ const navigate = useNavigate()
+ const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError('')
+
+ if (!email || !password) {
+ setError('Please fill in all fields.')
+ return
+ }
+
+ setLoading(true)
+ try {
+ const { data, error: authError } = await authClient.signIn.email({
+ email,
+ password,
+ })
+
+ if (authError) {
+ throw new Error(authError.message ?? 'Sign in failed')
+ }
+
+ setAuthenticated(true)
+ navigate('/')
+ } catch {
+ if (import.meta.env.VITE_MOCK_AUTH === 'true') {
+ setAuthenticated(true)
+ navigate('/')
+ } else {
+ setError('Invalid email or password. Please try again.')
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
CartSnitch
+
Track prices. Save money.
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ Forgot password?
+
+
+
+ Don't have an account?{' '}
+
+ Sign up
+
+
+
+ )
+}
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx
index 49bcffc..aa00f56 100644
--- a/src/pages/Register.tsx
+++ b/src/pages/Register.tsx
@@ -1,102 +1,108 @@
-import { useState } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
-import { useAuthStore } from '../stores/auth.ts'
-import { api } from '../lib/api.ts'
-import { mockUser } from '../lib/mock-data.ts'
-import type { User } from '../types/api.ts'
-
-export function Register() {
- const [name, setName] = useState('')
- const [email, setEmail] = useState('')
- const [password, setPassword] = useState('')
- const [error, setError] = useState('')
- const [loading, setLoading] = useState(false)
- const navigate = useNavigate()
- const setAuth = useAuthStore((s) => s.setAuth)
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault()
- setError('')
-
- if (!name || !email || !password) {
- setError('Please fill in all fields.')
- return
- }
-
- if (password.length < 8) {
- setError('Password must be at least 8 characters.')
- return
- }
-
- setLoading(true)
- try {
- const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password })
- setAuth(res.user, res.token)
- navigate('/')
- } catch {
- if (import.meta.env.VITE_MOCK_AUTH === 'true') {
- // Fallback to mock auth for demo
- setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
- navigate('/')
- } else {
- setError('Registration failed. Please try again.')
- }
- } finally {
- setLoading(false)
- }
- }
-
- return (
-
-
Create Account
-
Start tracking your grocery prices.
-
- {error && (
-
- {error}
-
- )}
-
-
-
-
- Already have an account?{' '}
-
- Sign in
-
-
-
- )
-}
+import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { authClient } from '../lib/auth-client.ts'
+import { useAuthStore } from '../stores/auth.ts'
+
+export function Register() {
+ const [name, setName] = useState('')
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+ const navigate = useNavigate()
+ const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError('')
+
+ if (!name || !email || !password) {
+ setError('Please fill in all fields.')
+ return
+ }
+
+ if (password.length < 8) {
+ setError('Password must be at least 8 characters.')
+ return
+ }
+
+ setLoading(true)
+ try {
+ const { data, error: authError } = await authClient.signUp.email({
+ name,
+ email,
+ password,
+ })
+
+ if (authError) {
+ throw new Error(authError.message ?? 'Registration failed')
+ }
+
+ setAuthenticated(true)
+ navigate('/')
+ } catch {
+ if (import.meta.env.VITE_MOCK_AUTH === 'true') {
+ setAuthenticated(true)
+ navigate('/')
+ } else {
+ setError('Registration failed. Please try again.')
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
Create Account
+
Start tracking your grocery prices.
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ Already have an account?{' '}
+
+ Sign in
+
+
+
+ )
+}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 43e5d2d..5ad0382 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -1,18 +1,21 @@
import { Link, useNavigate } from 'react-router-dom'
+import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
import { useThemeStore } from '../stores/theme.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function Settings() {
- const user = useAuthStore((s) => s.user)
- const logout = useAuthStore((s) => s.logout)
+ const { data: session } = authClient.useSession()
+ const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
const navigate = useNavigate()
const { theme, setTheme } = useThemeStore()
- const connectedStores = user?.connectedStores ?? []
+ const user = session?.user
+ const connectedStores: string[] = []
- function handleSignOut() {
- logout()
+ async function handleSignOut() {
+ await authClient.signOut()
+ setAuthenticated(false)
navigate('/login')
}
diff --git a/src/stores/auth.ts b/src/stores/auth.ts
index 1ffd8b8..a9abb5d 100644
--- a/src/stores/auth.ts
+++ b/src/stores/auth.ts
@@ -1,27 +1,18 @@
-import { create } from 'zustand'
-import { persist } from 'zustand/middleware'
-import type { User } from '../types/api.ts'
-
-interface AuthState {
- user: User | null
- token: string | null
- isAuthenticated: boolean
- setAuth: (user: User, token: string) => void
- logout: () => void
-}
-
-export const useAuthStore = create()(
- persist(
- (set) => ({
- user: null,
- token: null,
- isAuthenticated: false,
- setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
- logout: () => set({ user: null, token: null, isAuthenticated: false }),
- }),
- {
- name: 'cartsnitch-auth',
- partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
- },
- ),
-)
+import { create } from 'zustand'
+
+/**
+ * Minimal auth state for UI reactivity.
+ *
+ * Session management is handled by Better-Auth via httpOnly cookies.
+ * This store only tracks whether we have an active session for UI
+ * gating (protected routes, nav state). No tokens in memory or localStorage.
+ */
+interface AuthState {
+ isAuthenticated: boolean
+ setAuthenticated: (value: boolean) => void
+}
+
+export const useAuthStore = create()((set) => ({
+ isAuthenticated: false,
+ setAuthenticated: (value) => set({ isAuthenticated: value }),
+}))