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} -
- )} - -
- setEmail(e.target.value)} - autoComplete="email" - className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" - /> - setPassword(e.target.value)} - autoComplete="current-password" - className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" - /> - -
- - - 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} +
+ )} + +
+ setEmail(e.target.value)} + autoComplete="email" + className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" + /> + setPassword(e.target.value)} + autoComplete="current-password" + className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" + /> + +
+ + + 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} -
- )} - -
- setName(e.target.value)} - autoComplete="name" - className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" - /> - setEmail(e.target.value)} - autoComplete="email" - className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" - /> - setPassword(e.target.value)} - autoComplete="new-password" - className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" - /> - -
- -

- 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} +
+ )} + +
+ setName(e.target.value)} + autoComplete="name" + className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" + /> + setEmail(e.target.value)} + autoComplete="email" + className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" + /> + setPassword(e.target.value)} + autoComplete="new-password" + className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" + /> + +
+ +

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