forked from cartsnitch/cartsnitch
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f37f0501f | |||
| 4c36fd4156 | |||
| c9172f088f | |||
| ac4cba2b0d | |||
| 0c47be8ef3 | |||
| 440f92e96e | |||
| 97bbdf68a5 | |||
| 02e5bee390 | |||
| d475b3876a | |||
| 76bcc53992 | |||
| 470b615528 | |||
| f26f8f7e56 | |||
| 78b7831d43 | |||
| e45b510519 | |||
| f25044ea7e | |||
| b637fd9c11 | |||
| 983ee2c398 | |||
| 8af7b37b38 | |||
| b21a30b2e7 | |||
| 361ad3acc2 | |||
| 5e165d277e | |||
| 6828e4d0a9 | |||
| 0b9dd74f7d | |||
| 7a06f0618b | |||
| 9385463171 | |||
| b658f77f9c | |||
| 8706112be3 | |||
| 1a464fd77d | |||
| 962e64b72a | |||
| ff91003e90 | |||
| cd733fbc7d | |||
| 8659b99059 |
@@ -95,7 +95,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
|
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
|
||||||
npm install -g @lhci/cli
|
npm install -g @lhci/cli
|
||||||
LHCI_CHROME_PATH="$CHROME_PATH" lhci autorun
|
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
|
|||||||
+1
-1
@@ -30,4 +30,4 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s \
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||||
|
|
||||||
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
||||||
@@ -19,18 +19,27 @@ from cartsnitch_api.database import get_db
|
|||||||
# but we support Bearer tokens for service-to-service or mobile clients.
|
# but we support Bearer tokens for service-to-service or mobile clients.
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
# Better-Auth session cookie name
|
# Better-Auth session cookie names.
|
||||||
SESSION_COOKIE_NAME = "better-auth.session_token"
|
# Over HTTPS Better-Auth adds the __Secure- prefix automatically.
|
||||||
|
SESSION_COOKIE_NAMES = [
|
||||||
|
"__Secure-better-auth.session_token", # HTTPS (deployed)
|
||||||
|
"better-auth.session_token", # HTTP (local dev)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
||||||
"""Validate a Better-Auth session token against the sessions table.
|
"""Validate a Better-Auth session token against the sessions table.
|
||||||
|
|
||||||
Returns the user_id (as UUID) if the session is valid and not expired.
|
Returns the user_id (as UUID) if the session is valid and not expired.
|
||||||
|
Better-Auth v1.5.6 stores raw tokens in the DB. The session cookie
|
||||||
|
is signed: ``rawToken.base64HMACSignature``. Strip the signature
|
||||||
|
before querying.
|
||||||
"""
|
"""
|
||||||
|
# Signed cookie format: rawToken.hmacSignature — split and use only the token part
|
||||||
|
raw_token = token.split(".")[0] if "." in token else token
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
||||||
{"token": token},
|
{"token": raw_token},
|
||||||
)
|
)
|
||||||
row = result.first()
|
row = result.first()
|
||||||
|
|
||||||
@@ -67,8 +76,12 @@ async def get_current_user(
|
|||||||
"""
|
"""
|
||||||
token: str | None = None
|
token: str | None = None
|
||||||
|
|
||||||
# 1. Check session cookie
|
# 1. Check session cookie (try both names for HTTP/HTTPS compatibility)
|
||||||
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
|
cookie_token = None
|
||||||
|
for name in SESSION_COOKIE_NAMES:
|
||||||
|
cookie_token = request.cookies.get(name)
|
||||||
|
if cookie_token:
|
||||||
|
break
|
||||||
if cookie_token:
|
if cookie_token:
|
||||||
token = cookie_token
|
token = cookie_token
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import APIRouter, FastAPI
|
||||||
|
|
||||||
from cartsnitch_api.auth.routes import router as auth_router
|
from cartsnitch_api.auth.routes import router as auth_router
|
||||||
from cartsnitch_api.middleware.cors import add_cors_middleware
|
from cartsnitch_api.middleware.cors import add_cors_middleware
|
||||||
@@ -46,15 +46,19 @@ def create_app() -> FastAPI:
|
|||||||
# Routers
|
# Routers
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(stores_router)
|
|
||||||
app.include_router(purchases_router)
|
# Data endpoints mounted under /api/v1
|
||||||
app.include_router(products_router)
|
v1_router = APIRouter(prefix="/api/v1")
|
||||||
app.include_router(prices_router)
|
v1_router.include_router(stores_router)
|
||||||
app.include_router(coupons_router)
|
v1_router.include_router(purchases_router)
|
||||||
app.include_router(shopping_router)
|
v1_router.include_router(products_router)
|
||||||
app.include_router(alerts_router)
|
v1_router.include_router(prices_router)
|
||||||
app.include_router(scraping_router)
|
v1_router.include_router(coupons_router)
|
||||||
app.include_router(public_router)
|
v1_router.include_router(shopping_router)
|
||||||
|
v1_router.include_router(alerts_router)
|
||||||
|
v1_router.include_router(scraping_router)
|
||||||
|
v1_router.include_router(public_router)
|
||||||
|
app.include_router(v1_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
|
||||||
|
|
||||||
|
test.describe('J1: Registration and Login', () => {
|
||||||
|
test('can register a new account and lands on dashboard', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
|
||||||
|
await page.fill('[placeholder="Email"]', uniqueEmail());
|
||||||
|
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// With VITE_MOCK_AUTH=true the app navigates to "/" on success
|
||||||
|
await expect(page).toHaveURL('http://localhost:5173/');
|
||||||
|
await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation error when registration fields are empty', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can navigate from register to login', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.getByRole('link', { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can sign in with credentials and land on dashboard', async ({ page }) => {
|
||||||
|
// Register first so we have a real account
|
||||||
|
const email = uniqueEmail();
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.fill('[placeholder="Full Name"]', 'Login Betty');
|
||||||
|
await page.fill('[placeholder="Email"]', email);
|
||||||
|
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page).toHaveURL('http://localhost:5173/');
|
||||||
|
|
||||||
|
// Sign out by clearing the mock session (reload with no session)
|
||||||
|
await page.goto('/');
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Now sign in
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('[placeholder="Email"]', email);
|
||||||
|
await page.fill('[placeholder="Password"]', 'TestPass123!');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('http://localhost:5173/');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('J8: Unauthenticated Access', () => {
|
||||||
|
test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => {
|
||||||
|
// No session cookie — start fresh
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects /purchases to /login when not authenticated', async ({ page }) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/purchases');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects /products to /login when not authenticated', async ({ page }) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/products');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects /coupons to /login when not authenticated', async ({ page }) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/coupons');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows loading spinner while auth session is pending', async ({ page }) => {
|
||||||
|
// Intercept but don't respond — session stays pending
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.request.fetch('/api/auth/session', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Just navigate to a protected route — ProtectedRoute will show spinner while session is pending
|
||||||
|
await page.goto('/purchases');
|
||||||
|
// Spinner is visible briefly; once resolved, should redirect to login
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-4
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test("app loads", async ({ page }) => {
|
test('app loads', async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await expect(page).toHaveTitle(/CartSnitch/);
|
// Unauthenticated users are redirected to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-2
@@ -3,7 +3,12 @@
|
|||||||
"collect": {
|
"collect": {
|
||||||
"staticDistDir": "./dist",
|
"staticDistDir": "./dist",
|
||||||
"url": ["http://localhost:4173/"],
|
"url": ["http://localhost:4173/"],
|
||||||
"numberOfRuns": 1
|
"numberOfRuns": 1,
|
||||||
|
"settings": {
|
||||||
|
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||||
|
"skipAudits": ["bf-cache"],
|
||||||
|
"disableFullPageScreenshot": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"assert": {
|
"assert": {
|
||||||
"assertions": {
|
"assertions": {
|
||||||
@@ -16,4 +21,4 @@
|
|||||||
"target": "temporary-public-storage"
|
"target": "temporary-public-storage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+2
-1
@@ -20,7 +20,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.10.0",
|
"@axe-core/playwright": "^4.10.0",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"msw": "^2.12.14",
|
"msw": "^2.12.14",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
|
|||||||
+3
-2
@@ -25,7 +25,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.10.0",
|
"@axe-core/playwright": "^4.10.0",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"msw": "^2.12.14",
|
"msw": "^2.12.14",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
@@ -51,4 +52,4 @@
|
|||||||
"flatted": "^3.4.2",
|
"flatted": "^3.4.2",
|
||||||
"serialize-javascript": "7.0.5"
|
"serialize-javascript": "7.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'VITE_MOCK_AUTH=true npm run dev',
|
||||||
url: 'http://localhost:5173',
|
url: 'http://localhost:5173',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-23
@@ -1,23 +1,17 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
vi.mock('./lib/auth-client.ts', () => ({
|
vi.mock('./lib/auth-client.ts', () => ({
|
||||||
authClient: {
|
authClient: {
|
||||||
useSession: () => ({ data: null, isPending: false }),
|
useSession: () => ({ data: null, isPending: false }),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('renders the dashboard on the root route', () => {
|
it('redirects unauthenticated users to login', () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
|
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
|
||||||
})
|
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
it('renders the bottom navigation', () => {
|
})
|
||||||
render(<App />)
|
|
||||||
expect(screen.getByText('Home')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Purchases')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Products')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
+1
-1
@@ -31,8 +31,8 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="purchases" element={<Purchases />} />
|
<Route path="purchases" element={<Purchases />} />
|
||||||
<Route path="purchases/:id" element={<PurchaseDetail />} />
|
<Route path="purchases/:id" element={<PurchaseDetail />} />
|
||||||
<Route path="products" element={<Products />} />
|
<Route path="products" element={<Products />} />
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import { authClient } from '../lib/auth-client.ts'
|
|||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
|
||||||
export function ProtectedRoute() {
|
export function ProtectedRoute() {
|
||||||
|
const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true'
|
||||||
const { data: session, isPending } = authClient.useSession()
|
const { data: session, isPending } = authClient.useSession()
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAuthenticated(!!session)
|
if (!isMockAuth) {
|
||||||
}, [session, setAuthenticated])
|
setAuthenticated(!!session)
|
||||||
|
}
|
||||||
|
}, [session, setAuthenticated, isMockAuth])
|
||||||
|
|
||||||
|
// In mock auth mode, rely on Zustand store (set by Login/Register pages)
|
||||||
|
if (isMockAuth) {
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+2
-2
@@ -35,7 +35,7 @@ export function useProduct(id: string) {
|
|||||||
export function usePriceHistory(productId: string) {
|
export function usePriceHistory(productId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['priceHistory', productId],
|
queryKey: ['priceHistory', productId],
|
||||||
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
|
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/prices`),
|
||||||
enabled: !!productId,
|
enabled: !!productId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,6 @@ export function useCoupons() {
|
|||||||
export function usePriceAlerts() {
|
export function usePriceAlerts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['priceAlerts'],
|
queryKey: ['priceAlerts'],
|
||||||
queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
|
queryFn: () => api.get<PriceAlert[]>('/alerts'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@ const mockRoutes: Record<string, (path: string) => unknown> = {
|
|||||||
'/purchases': () => mockPurchases,
|
'/purchases': () => mockPurchases,
|
||||||
'/products': () => mockProducts,
|
'/products': () => mockProducts,
|
||||||
'/coupons': () => mockCoupons,
|
'/coupons': () => mockCoupons,
|
||||||
'/price-alerts': () => mockAlerts,
|
'/alerts': () => mockAlerts,
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchMockRoute<T>(path: string): T | null {
|
function matchMockRoute<T>(path: string): T | null {
|
||||||
@@ -30,7 +30,7 @@ function matchMockRoute<T>(path: string): T | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// /products/:id/price-history
|
// /products/:id/price-history
|
||||||
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
|
const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/)
|
||||||
if (priceHistoryMatch) {
|
if (priceHistoryMatch) {
|
||||||
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -31,8 +31,14 @@ export function Login() {
|
|||||||
throw new Error(authError.message ?? 'Sign in failed')
|
throw new Error(authError.message ?? 'Sign in failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthenticated(true)
|
// After successful signIn, force a session fetch to confirm the cookie is set
|
||||||
navigate('/')
|
// before navigating to the protected route
|
||||||
|
const sessionResult = await authClient.getSession()
|
||||||
|
if (sessionResult.data) {
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
setError('Sign in failed. Please try again.')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
@@ -46,7 +52,7 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
<main className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
|
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
|
||||||
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
|
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
|
||||||
|
|
||||||
@@ -88,10 +94,10 @@ export function Login() {
|
|||||||
|
|
||||||
<p className="mt-6 text-sm text-gray-500">
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link to="/register" className="text-brand-blue">
|
<Link to="/register" className="text-brand-blue underline">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,15 @@ export function Register() {
|
|||||||
throw new Error(authError.message ?? 'Registration failed')
|
throw new Error(authError.message ?? 'Registration failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthenticated(true)
|
// After successful signUp, force a session fetch to confirm the cookie is set
|
||||||
navigate('/')
|
// before navigating to the protected route
|
||||||
|
const sessionResult = await authClient.getSession()
|
||||||
|
if (sessionResult.data) {
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
// Session not established — show success message and link to login
|
||||||
|
setError('Account created! Please sign in.')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
|
|||||||
@@ -61,5 +61,5 @@ export const handlers = [
|
|||||||
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
|
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
|
||||||
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
|
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
|
||||||
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
|
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
|
||||||
http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
|
http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user