Compare commits

..

2 Commits

Author SHA1 Message Date
cartsnitch-ceo[bot] f1c794ec6b Merge branch 'main' into fix/auth-session-table-mapping 2026-03-31 03:06:04 +00:00
Stockboy Steve 6f8ca890ec fix(auth): add session table model mapping for plural table name
Better-Auth defaults to singular "session" table name, but our DB uses
the plural "sessions" table (created by migration 002). Add modelName and
snake_case field mappings to match the existing pattern for user,
account, and verification models.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 02:13:16 +00:00
26 changed files with 152 additions and 490 deletions
+2 -87
View File
@@ -48,18 +48,6 @@ jobs:
- name: Run tests - name: Run tests
run: npx vitest run run: npx vitest run
audit:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- name: Check for vulnerabilities
run: npm audit --audit-level=high
e2e: e2e:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
steps: steps:
@@ -72,34 +60,8 @@ jobs:
- run: npx playwright install --with-deps chromium - run: npx playwright install --with-deps chromium
- run: npx playwright test - run: npx playwright test
lighthouse:
runs-on: runners-cartsnitch
needs: [test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run build
- name: Install Chromium for Lighthouse
run: |
npm install -g playwright
npx playwright install --with-deps chromium
- name: Start preview server
run: |
npm run preview &
npx wait-on http://localhost:4173/ --timeout 30000
- name: Run Lighthouse CI
run: |
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
npm install -g @lhci/cli
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
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test, e2e] needs: [lint, test, e2e]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
@@ -125,13 +87,6 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "CalVer tag: $VERSION" echo "CalVer tag: $VERSION"
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -169,7 +124,6 @@ jobs:
build-and-push-auth: build-and-push-auth:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test, e2e] needs: [lint, test, e2e]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
@@ -194,13 +148,6 @@ jobs:
fi fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -230,7 +177,6 @@ jobs:
build-and-push-receiptwitness: build-and-push-receiptwitness:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test] needs: [lint, test]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
@@ -250,13 +196,6 @@ jobs:
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -286,7 +225,6 @@ jobs:
build-and-push-api: build-and-push-api:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test] needs: [lint, test]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
@@ -306,13 +244,6 @@ jobs:
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -343,7 +274,7 @@ jobs:
deploy-dev: deploy-dev:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api] needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Generate GitHub App token - name: Generate GitHub App token
id: app-token id: app-token
@@ -368,28 +299,12 @@ jobs:
- name: Install kustomize - name: Install kustomize
uses: imranismail/setup-kustomize@v2 uses: imranismail/setup-kustomize@v2
- name: Update frontend image tag - name: Update dev overlay image tags
if: needs.build-and-push.result == 'success'
run: | run: |
cd infra/apps/overlays/dev cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }} kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
- name: Update auth image tag
if: needs.build-and-push-auth.result == 'success'
run: |
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }} kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Update receiptwitness image tag
if: needs.build-and-push-receiptwitness.result == 'success'
run: |
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }} kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}
- name: Update api image tag
if: needs.build-and-push-api.result == 'success'
run: |
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }} kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
- name: Commit and push to infra - name: Commit and push to infra
+1 -1
View File
@@ -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 ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"] CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -1,122 +0,0 @@
"""Fix users.id UUID->text type mismatch for Better-Auth compatibility.
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT
a new user, Postgres throws:
ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"
The sessions, accounts, and verifications tables already use text IDs — only users,
user_store_accounts.user_id, and purchases.user_id needed fixing.
Revision ID: 004_fix_user_id_text
Revises: 003_make_users_hashed_password_nullable
Create Date: 2026-03-31
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "004_fix_user_id_text"
down_revision = "003_make_users_hashed_password_nullable"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Step 1: Drop existing FK constraints
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Step 2: Alter users.id from uuid to text
op.alter_column(
"users",
"id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="id::text",
)
# Step 3: Alter user_store_accounts.user_id from uuid to text
op.alter_column(
"user_store_accounts",
"user_id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="user_id::text",
)
# Step 4: Alter purchases.user_id from uuid to text
op.alter_column(
"purchases",
"user_id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="user_id::text",
)
# Step 5: Re-add FK constraints
op.execute(
text(
"ALTER TABLE user_store_accounts "
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
op.execute(
text(
"ALTER TABLE purchases "
"ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
def downgrade() -> None:
# Drop FK constraints
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Revert users.id from text to uuid
op.alter_column(
"users",
"id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="id::uuid",
)
# Revert user_store_accounts.user_id from text to uuid
op.alter_column(
"user_store_accounts",
"user_id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="user_id::uuid",
)
# Revert purchases.user_id from text to uuid
op.alter_column(
"purchases",
"user_id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="user_id::uuid",
)
# Re-add FK constraints (PostgreSQL will auto-name them)
op.execute(
text(
"ALTER TABLE user_store_accounts "
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
op.execute(
text(
"ALTER TABLE purchases "
"ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
+1 -5
View File
@@ -5,7 +5,6 @@ Sessions are verified by querying the shared sessions table directly.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from hashlib import sha256
from uuid import UUID from uuid import UUID
from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi import Cookie, Depends, Header, HTTPException, Request, status
@@ -28,13 +27,10 @@ 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 tokens as SHA-256 hashes, so we hash the
incoming raw token before querying.
""" """
hashed_token = sha256(token.encode("utf-8")).hexdigest()
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": hashed_token}, {"token": token},
) )
row = result.first() row = result.first()
+10 -14
View File
@@ -2,7 +2,7 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI from fastapi import 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,19 +46,15 @@ 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)
# Data endpoints mounted under /api/v1 app.include_router(purchases_router)
v1_router = APIRouter(prefix="/api/v1") app.include_router(products_router)
v1_router.include_router(stores_router) app.include_router(prices_router)
v1_router.include_router(purchases_router) app.include_router(coupons_router)
v1_router.include_router(products_router) app.include_router(shopping_router)
v1_router.include_router(prices_router) app.include_router(alerts_router)
v1_router.include_router(coupons_router) app.include_router(scraping_router)
v1_router.include_router(shopping_router) app.include_router(public_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
+1 -1
View File
@@ -32,7 +32,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "purchases" __tablename__ = "purchases"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False) store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id")) store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False) receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
+3 -4
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus from cartsnitch_api.constants import AccountStatus
@@ -16,12 +16,11 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store from cartsnitch_api.models.store import Store
class User(TimestampMixin, Base): class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Application user.""" """Application user."""
__tablename__ = "users" __tablename__ = "users"
id: Mapped[str] = mapped_column(Text, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100)) display_name: Mapped[str | None] = mapped_column(String(100))
@@ -37,7 +36,7 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_store_accounts" __tablename__ = "user_store_accounts"
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),) __table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False) store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON) session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+1 -1
View File
@@ -16,7 +16,7 @@ class UpdateUserRequest(BaseModel):
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: str id: UUID
email: str email: str
display_name: str display_name: str
created_at: datetime created_at: datetime
-12
View File
@@ -1,12 +0,0 @@
import { test as base, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
export const test = base.extend<{ axeCheck: void }>({
axeCheck: [async ({ page }, use) => {
await use();
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
}, { auto: true }],
});
export { expect } from "@playwright/test";
@@ -1,56 +0,0 @@
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/');
});
});
-49
View File
@@ -1,49 +0,0 @@
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 });
});
});
+2 -4
View File
@@ -1,8 +1,6 @@
import { test, expect } from './fixtures'; import { test, expect } from '@playwright/test';
test('app loads', async ({ page }) => { test('app loads', async ({ page }) => {
await page.goto('/'); await page.goto('/');
// Unauthenticated users are redirected to /login await expect(page).toHaveTitle(/CartSnitch/);
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible();
}); });
-24
View File
@@ -1,24 +0,0 @@
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"url": ["http://localhost:4173/"],
"numberOfRuns": 1,
"settings": {
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
"skipAudits": ["bf-cache"],
"disableFullPageScreenshot": true
}
},
"assert": {
"assertions": {
"categories:performance": ["warn", { "minScore": 0.7 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["warn", { "minScore": 0.8 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
+90 -39
View File
@@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0", "better-auth": "^1.2.0",
"picomatch": "4.0.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0",
@@ -18,23 +17,21 @@
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.49.0",
"@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",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"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",
@@ -71,19 +68,6 @@
"devOptional": true, "devOptional": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/@axe-core/playwright": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
"integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.11.1"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -4508,16 +4492,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/axe-core": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
"integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/babel-plugin-polyfill-corejs2": { "node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.16", "version": "0.4.16",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz",
@@ -6078,9 +6052,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.2", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -8203,9 +8177,10 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -8363,6 +8338,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -8769,6 +8754,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -8844,13 +8850,13 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "7.0.5", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "dependencies": {
"node": ">=20.0.0" "randombytes": "^2.1.0"
} }
}, },
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
@@ -10440,6 +10446,31 @@
"rollup": "^1.20.0 || ^2.0.0" "rollup": "^1.20.0 || ^2.0.0"
} }
}, },
"node_modules/workbox-build/node_modules/@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "0.0.39",
"estree-walker": "^1.0.1",
"picomatch": "^2.2.2"
},
"engines": {
"node": ">= 8.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0"
}
},
"node_modules/workbox-build/node_modules/@types/estree": {
"version": "0.0.39",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-build/node_modules/ajv": { "node_modules/workbox-build/node_modules/ajv": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@@ -10457,6 +10488,13 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/workbox-build/node_modules/estree-walker": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-build/node_modules/json-schema-traverse": { "node_modules/workbox-build/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -10474,6 +10512,19 @@
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8"
} }
}, },
"node_modules/workbox-build/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/workbox-build/node_modules/pretty-bytes": { "node_modules/workbox-build/node_modules/pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+2 -10
View File
@@ -15,7 +15,6 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0", "better-auth": "^1.2.0",
"picomatch": "4.0.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0",
@@ -23,33 +22,26 @@
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@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",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"@playwright/test": "^1.49.0",
"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",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-pwa": "^0.21.2", "vite-plugin-pwa": "^0.21.2",
"vitest": "^3.2.4" "vitest": "^3.2.4"
},
"overrides": {
"@rollup/pluginutils": "5.3.0",
"flatted": "^3.4.2",
"serialize-javascript": "7.0.5"
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
}, },
], ],
webServer: { webServer: {
command: 'VITE_MOCK_AUTH=true npm run dev', command: 'npm run dev',
url: 'http://localhost:5173', url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://cartsnitch.com/sitemap.xml
+8 -2
View File
@@ -9,9 +9,15 @@ vi.mock('./lib/auth-client.ts', () => ({
})) }))
describe('App', () => { describe('App', () => {
it('redirects unauthenticated users to login', () => { it('renders the dashboard on the root route', () => {
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
View File
@@ -31,8 +31,8 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route element={<ProtectedRoute />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route element={<ProtectedRoute />}>
<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 />} />
+1 -11
View File
@@ -4,22 +4,12 @@ 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(() => {
if (!isMockAuth) {
setAuthenticated(!!session) setAuthenticated(!!session)
} }, [session, setAuthenticated])
}, [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
View File
@@ -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}/prices`), queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
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[]>('/alerts'), queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
}) })
} }
+2 -2
View File
@@ -15,7 +15,7 @@ const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases, '/purchases': () => mockPurchases,
'/products': () => mockProducts, '/products': () => mockProducts,
'/coupons': () => mockCoupons, '/coupons': () => mockCoupons,
'/alerts': () => mockAlerts, '/price-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\/(.+)\/prices$/) const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
if (priceHistoryMatch) { if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T return getMockPriceHistory(priceHistoryMatch[1]) as T
} }
-1
View File
@@ -173,7 +173,6 @@ function AuthenticatedDashboard({ userName }: { userName: string }) {
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
<div className="animate-pulse"> <div className="animate-pulse">
<h1 className="sr-only">Loading CartSnitch</h1>
<div className="h-8 w-40 rounded bg-gray-200" /> <div className="h-8 w-40 rounded bg-gray-200" />
<div className="mt-4 grid grid-cols-2 gap-3"> <div className="mt-4 grid grid-cols-2 gap-3">
<div className="h-24 rounded-xl bg-gray-200" /> <div className="h-24 rounded-xl bg-gray-200" />
+4 -10
View File
@@ -31,14 +31,8 @@ export function Login() {
throw new Error(authError.message ?? 'Sign in failed') throw new Error(authError.message ?? 'Sign in failed')
} }
// After successful signIn, force a session fetch to confirm the cookie is set setAuthenticated(true)
// before navigating to the protected route
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/') 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)
@@ -52,7 +46,7 @@ export function Login() {
} }
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center px-4"> <div 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>
@@ -94,10 +88,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 underline"> <Link to="/register" className="text-brand-blue">
Sign up Sign up
</Link> </Link>
</p> </p>
</main> </div>
) )
} }
+1 -8
View File
@@ -38,15 +38,8 @@ export function Register() {
throw new Error(authError.message ?? 'Registration failed') throw new Error(authError.message ?? 'Registration failed')
} }
// After successful signUp, force a session fetch to confirm the cookie is set setAuthenticated(true)
// before navigating to the protected route
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/') 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)
+1 -1
View File
@@ -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/alerts', () => HttpResponse.json(mockAlerts)), http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
] ]