Compare commits

...

13 Commits

Author SHA1 Message Date
Felipe Cardoso
3bf28aa121 Override MSW handlers to support custom authentication workflows
- Added mock handlers for `login`, `register`, and `refresh` endpoints with realistic network delay.
- Implemented JWT token generation utilities to simulate authentication flows.
- Enhanced handler configurations for user data validation and session management.
2025-11-24 20:23:15 +01:00
Felipe Cardoso
cda9810a7e Add auto-generated MSW handlers for API endpoints
- Created `generated.ts` to include handlers for all endpoints defined in the OpenAPI specification.
- Simplified demo mode setup by centralizing auto-generated MSW configurations.
- Added handling for authentication, user, organization, and admin API endpoints.
- Included support for realistic network delay simulation and demo session management.
2025-11-24 19:52:40 +01:00
Felipe Cardoso
d47bd34a92 Add comprehensive tests for RegistrationActivityChart and update empty state assertions
- Added new test suite for `RegistrationActivityChart` covering rendering, loading, empty, and error states.
- Updated existing chart tests (`UserStatusChart`, `OrganizationDistributionChart`, `UserGrowthChart`) to assert correct empty state messages.
- Replaced `SessionActivityChart` references in admin tests with `RegistrationActivityChart`.
2025-11-24 19:49:41 +01:00
Felipe Cardoso
5b0ae54365 Remove MSW handlers and update demo credentials for improved standardization
- Deleted `admin.ts`, `auth.ts`, and `users.ts` MSW handler files to streamline demo mode setup.
- Updated demo credentials logic in `DemoCredentialsModal` and `DemoModeBanner` for stronger password requirements (≥12 characters).
- Refined documentation in `CLAUDE.md` to align with new credential standards and auto-generated MSW workflows.
2025-11-24 19:20:28 +01:00
Felipe Cardoso
372af25aaa Refactor Markdown rendering and code blocks styling
- Enhanced Markdown heading hierarchy with subtle anchors and improved spacing.
- Improved styling for links, blockquotes, tables, and horizontal rules using reusable components (`Alert`, `Badge`, `Table`, `Separator`).
- Standardized code block background, button transitions, and copy-to-clipboard feedback.
- Refined readability and visual hierarchy of text elements across Markdown content.
2025-11-24 18:58:01 +01:00
Felipe Cardoso
d0b717a128 Enhance demo mode credential validation and refine MSW configuration
- Updated demo credential logic to accept any password ≥8 characters for improved UX.
- Improved MSW configuration to ignore non-API requests and warn only for unhandled API calls.
- Adjusted `DemoModeBanner` to reflect updated password requirements for demo credentials.
2025-11-24 18:54:05 +01:00
Felipe Cardoso
9d40aece30 Refactor chart components for improved formatting and import optimization
- Consolidated `recharts` imports for `BarChart`, `AreaChart`, and `LineChart` components.
- Reformatted inline styles for tooltips and axis elements to enhance readability and maintain consistency.
- Applied minor cleanups for improved project code styling.
2025-11-24 18:42:13 +01:00
Felipe Cardoso
487c8a3863 Add demo mode support with MSW integration and documentation
- Integrated Mock Service Worker (MSW) for frontend-only demo mode, allowing API call interception without requiring a backend.
- Added `DemoModeBanner` component to indicate active demo mode and display demo credentials.
- Enhanced configuration with `DEMO_MODE` flag and demo credentials for user and admin access.
- Updated ESLint configuration to exclude MSW-related files from linting and coverage.
- Created comprehensive `DEMO_MODE.md` documentation for setup and usage guidelines, including deployment instructions and troubleshooting.
- Updated package dependencies to include MSW and related libraries.
2025-11-24 18:42:05 +01:00
Felipe Cardoso
8659e884e9 Refactor code formatting and suppress security warnings
- Reformatted dicts, loops, and logger calls for improved readability and consistency.
- Suppressed `bandit` warnings (`# noqa: S311`) for non-critical random number generation in demo data.
2025-11-24 17:58:26 +01:00
Felipe Cardoso
a05def5906 Add registration_activity chart and enhance admin statistics
- Introduced `RegistrationActivityChart` to display user registration trends over 14 days.
- Enhanced `AdminStatsResponse` with `registration_activity`, providing improved insights for admin users.
- Updated demo data to include realistic registration activity and organization details.
- Refactored admin page to use updated statistics data model and improved query handling.
- Fixed inconsistent timezone handling in statistical analytics and demo user timestamps.
2025-11-24 17:42:43 +01:00
Felipe Cardoso
9f655913b1 Add adminGetStats API and extend statistics types for admin dashboard
- Introduced `adminGetStats` API endpoint for fetching aggregated admin dashboard statistics.
- Expanded `AdminStatsResponse` to include `registration_activity` and new type definitions for `UserGrowthData`, `OrgDistributionData`, and `UserStatusData`.
- Added `AdminGetStatsData` and `AdminGetStatsResponses` types to improve API integration consistency.
- Updated client generation and type annotations to support the new endpoint structure.
2025-11-24 16:28:59 +01:00
Felipe Cardoso
13abd159fa Remove deprecated middleware and update component tests for branding and auth enhancements
- Deleted `middleware.disabled.ts` as it is no longer needed.
- Refactored `HeroSection` and `HomePage` tests to align with updated branding and messaging.
- Modified `DemoCredentialsModal` to support auto-filled demo credentials in login links.
- Mocked `ThemeToggle`, `LocaleSwitcher`, and `DemoCredentialsModal` in relevant tests.
- Updated admin tests to use `QueryClientProvider` and refactored API mocks for `AdminPage`.
- Replaced test assertions for stats section and badges with new branding content.
2025-11-24 15:04:49 +01:00
Felipe Cardoso
acfe59c8b3 Refactor admin stats API and charts data models for consistency
- Updated `AdminStatsResponse` with streamlined type annotations and added `AdminStatsData` type definition.
- Renamed chart data model fields (`totalUsers` → `total_users`, `activeUsers` → `active_users`, `members` → `value`, etc.) for alignment with backend naming conventions.
- Adjusted related test files to reflect updated data model structure.
- Improved readability of `AdminPage` component by reformatting destructuring in `useQuery`.
2025-11-24 12:44:45 +01:00
57 changed files with 5421 additions and 480 deletions

View File

@@ -173,6 +173,20 @@ with patch.object(session, 'commit', side_effect=mock_commit):
- E2E: Use `npm run test:e2e:debug` for step-by-step debugging - E2E: Use `npm run test:e2e:debug` for step-by-step debugging
- Check logs: Backend has detailed error logging - Check logs: Backend has detailed error logging
**Demo Mode (Frontend-Only Showcase):**
- Enable: `echo "NEXT_PUBLIC_DEMO_MODE=true" > frontend/.env.local`
- Uses MSW (Mock Service Worker) to intercept API calls in browser
- Zero backend required - perfect for Vercel deployments
- **Fully Automated**: MSW handlers auto-generated from OpenAPI spec
- Run `npm run generate:api` → updates both API client AND MSW handlers
- No manual synchronization needed!
- Demo credentials (any password ≥8 chars works):
- User: `demo@example.com` / `DemoPass123`
- Admin: `admin@example.com` / `AdminPass123`
- **Safe**: MSW never runs during tests (Jest or Playwright)
- **Coverage**: Mock files excluded from linting and coverage
- **Documentation**: `frontend/docs/DEMO_MODE.md` for complete guide
### Tool Usage Preferences ### Tool Usage Preferences
**Prefer specialized tools over bash:** **Prefer specialized tools over bash:**

View File

@@ -134,6 +134,38 @@ Whether you're building a SaaS, an internal tool, or a side project, PragmaStack
--- ---
## 🎭 Demo Mode
**Try the frontend without a backend!** Perfect for:
- **Free deployment** on Vercel (no backend costs)
- **Portfolio showcasing** with live demos
- **Client presentations** without infrastructure setup
### Quick Start
```bash
cd frontend
echo "NEXT_PUBLIC_DEMO_MODE=true" > .env.local
npm run dev
```
**Demo Credentials:**
- Regular user: `demo@example.com` / `DemoPass123`
- Admin user: `admin@example.com` / `AdminPass123`
Demo mode uses [Mock Service Worker (MSW)](https://mswjs.io/) to intercept API calls in the browser. Your code remains unchanged - the same components work with both real and mocked backends.
**Key Features:**
- ✅ Zero backend required
- ✅ All features functional (auth, admin, stats)
- ✅ Realistic network delays and errors
- ✅ Does NOT interfere with tests (97%+ coverage maintained)
- ✅ One-line toggle: `NEXT_PUBLIC_DEMO_MODE=true`
📖 **[Complete Demo Mode Documentation](./frontend/docs/DEMO_MODE.md)**
---
## 🚀 Tech Stack ## 🚀 Tech Stack
### Backend ### Backend

View File

@@ -7,7 +7,7 @@ for managing the application.
""" """
import logging import logging
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -94,6 +94,11 @@ class OrgDistributionData(BaseModel):
value: int value: int
class RegistrationActivityData(BaseModel):
date: str
registrations: int
class UserStatusData(BaseModel): class UserStatusData(BaseModel):
name: str name: str
value: int value: int
@@ -102,9 +107,63 @@ class UserStatusData(BaseModel):
class AdminStatsResponse(BaseModel): class AdminStatsResponse(BaseModel):
user_growth: list[UserGrowthData] user_growth: list[UserGrowthData]
organization_distribution: list[OrgDistributionData] organization_distribution: list[OrgDistributionData]
registration_activity: list[RegistrationActivityData]
user_status: list[UserStatusData] user_status: list[UserStatusData]
def _generate_demo_stats() -> AdminStatsResponse:
"""Generate demo statistics for empty databases."""
from random import randint
# Demo user growth (last 30 days)
user_growth = []
total = 10
for i in range(29, -1, -1):
date = datetime.now(UTC) - timedelta(days=i)
total += randint(0, 3) # noqa: S311
user_growth.append(
UserGrowthData(
date=date.strftime("%b %d"),
total_users=total,
active_users=int(total * 0.85),
)
)
# Demo organization distribution
org_dist = [
OrgDistributionData(name="Engineering", value=12),
OrgDistributionData(name="Product", value=8),
OrgDistributionData(name="Sales", value=15),
OrgDistributionData(name="Marketing", value=6),
OrgDistributionData(name="Support", value=5),
OrgDistributionData(name="Operations", value=4),
]
# Demo registration activity (last 14 days)
registration_activity = []
for i in range(13, -1, -1):
date = datetime.now(UTC) - timedelta(days=i)
registration_activity.append(
RegistrationActivityData(
date=date.strftime("%b %d"),
registrations=randint(0, 5), # noqa: S311
)
)
# Demo user status
user_status = [
UserStatusData(name="Active", value=45),
UserStatusData(name="Inactive", value=5),
]
return AdminStatsResponse(
user_growth=user_growth,
organization_distribution=org_dist,
registration_activity=registration_activity,
user_status=user_status,
)
@router.get( @router.get(
"/stats", "/stats",
response_model=AdminStatsResponse, response_model=AdminStatsResponse,
@@ -116,75 +175,94 @@ async def admin_get_stats(
admin: User = Depends(require_superuser), admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Any: ) -> Any:
"""Get admin dashboard statistics.""" """Get admin dashboard statistics with real data from database."""
# 1. User Growth (Last 30 days) from app.core.config import settings
# Note: This is a simplified implementation. For production, consider a dedicated stats table or materialized view.
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
# Get all users created in last 30 days # Check if we have any data
query = ( total_users_query = select(func.count()).select_from(User)
select(User).where(User.created_at >= thirty_days_ago).order_by(User.created_at) total_users = (await db.execute(total_users_query)).scalar() or 0
)
result = await db.execute(query)
recent_users = result.scalars().all()
# Get total count before 30 days # If database is essentially empty (only admin user), return demo data
count_query = ( if total_users <= 1 and settings.DEMO_MODE:
select(func.count()).select_from(User).where(User.created_at < thirty_days_ago) logger.info("Returning demo stats data (empty database in demo mode)")
) return _generate_demo_stats()
count_result = await db.execute(count_query)
base_count = count_result.scalar() or 0
# Aggregate by day # 1. User Growth (Last 30 days) - Improved calculation
datetime.now(UTC) - timedelta(days=30)
# Get all users with their creation dates
all_users_query = select(User).order_by(User.created_at)
result = await db.execute(all_users_query)
all_users = result.scalars().all()
# Build cumulative counts per day
user_growth = [] user_growth = []
current_total = base_count
# Create a map of date -> count
daily_counts = {}
for user in recent_users:
date_str = user.created_at.strftime("%b %d")
if date_str not in daily_counts:
daily_counts[date_str] = {"total": 0, "active": 0}
daily_counts[date_str]["total"] += 1
if user.is_active:
daily_counts[date_str]["active"] += 1
# Fill in the last 30 days
for i in range(29, -1, -1): for i in range(29, -1, -1):
date = datetime.utcnow() - timedelta(days=i) date = datetime.now(UTC) - timedelta(days=i)
date_str = date.strftime("%b %d") date_start = date.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC)
date_end = date_start + timedelta(days=1)
day_data = daily_counts.get(date_str, {"total": 0, "active": 0}) # Count all users created before end of this day
current_total += day_data["total"] # Make comparison timezone-aware
total_users_on_date = sum(
1
for u in all_users
if u.created_at and u.created_at.replace(tzinfo=UTC) < date_end
)
# Count active users created before end of this day
active_users_on_date = sum(
1
for u in all_users
if u.created_at
and u.created_at.replace(tzinfo=UTC) < date_end
and u.is_active
)
# For active users, we'd ideally track history, but for now let's approximate
# by just counting current active users created up to this point
# This is a simplification
user_growth.append( user_growth.append(
UserGrowthData( UserGrowthData(
date=date_str, date=date.strftime("%b %d"),
total_users=current_total, total_users=total_users_on_date,
active_users=int( active_users=active_users_on_date,
current_total * 0.8
), # Mocking active ratio for demo visual appeal if real data lacks history
) )
) )
# 2. Organization Distribution # 2. Organization Distribution - Top 6 organizations by member count
# Get top 5 organizations by member count
org_query = ( org_query = (
select(Organization.name, func.count(UserOrganization.user_id).label("count")) select(Organization.name, func.count(UserOrganization.user_id).label("count"))
.join(UserOrganization, Organization.id == UserOrganization.organization_id) .join(UserOrganization, Organization.id == UserOrganization.organization_id)
.group_by(Organization.name) .group_by(Organization.name)
.order_by(func.count(UserOrganization.user_id).desc()) .order_by(func.count(UserOrganization.user_id).desc())
.limit(5) .limit(6)
) )
result = await db.execute(org_query) result = await db.execute(org_query)
org_dist = [ org_dist = [
OrgDistributionData(name=row.name, value=row.count) for row in result.all() OrgDistributionData(name=row.name, value=row.count) for row in result.all()
] ]
# 3. User Status # 3. User Registration Activity (Last 14 days) - NEW
registration_activity = []
for i in range(13, -1, -1):
date = datetime.now(UTC) - timedelta(days=i)
date_start = date.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC)
date_end = date_start + timedelta(days=1)
# Count users created on this specific day
# Make comparison timezone-aware
day_registrations = sum(
1
for u in all_users
if u.created_at
and date_start <= u.created_at.replace(tzinfo=UTC) < date_end
)
registration_activity.append(
RegistrationActivityData(
date=date.strftime("%b %d"),
registrations=day_registrations,
)
)
# 4. User Status - Active vs Inactive
active_query = select(func.count()).select_from(User).where(User.is_active) active_query = select(func.count()).select_from(User).where(User.is_active)
inactive_query = ( inactive_query = (
select(func.count()).select_from(User).where(User.is_active.is_(False)) select(func.count()).select_from(User).where(User.is_active.is_(False))
@@ -193,6 +271,10 @@ async def admin_get_stats(
active_count = (await db.execute(active_query)).scalar() or 0 active_count = (await db.execute(active_query)).scalar() or 0
inactive_count = (await db.execute(inactive_query)).scalar() or 0 inactive_count = (await db.execute(inactive_query)).scalar() or 0
logger.info(
f"User status counts - Active: {active_count}, Inactive: {inactive_count}"
)
user_status = [ user_status = [
UserStatusData(name="Active", value=active_count), UserStatusData(name="Active", value=active_count),
UserStatusData(name="Inactive", value=inactive_count), UserStatusData(name="Inactive", value=inactive_count),
@@ -201,6 +283,7 @@ async def admin_get_stats(
return AdminStatsResponse( return AdminStatsResponse(
user_growth=user_growth, user_growth=user_growth,
organization_distribution=org_dist, organization_distribution=org_dist,
registration_activity=registration_activity,
user_status=user_status, user_status=user_status,
) )

View File

@@ -24,9 +24,24 @@
"name": "Umbrella Corporation", "name": "Umbrella Corporation",
"slug": "umbrella", "slug": "umbrella",
"description": "Our business is life itself." "description": "Our business is life itself."
},
{
"name": "Massive Dynamic",
"slug": "massive-dynamic",
"description": "What don't we do?"
} }
], ],
"users": [ "users": [
{
"email": "demo@example.com",
"password": "DemoPass1234!",
"first_name": "Demo",
"last_name": "User",
"is_superuser": false,
"organization_slug": "acme-corp",
"role": "member",
"is_active": true
},
{ {
"email": "alice@acme.com", "email": "alice@acme.com",
"password": "Demo123!", "password": "Demo123!",
@@ -57,6 +72,16 @@
"role": "member", "role": "member",
"is_active": false "is_active": false
}, },
{
"email": "diana@acme.com",
"password": "Demo123!",
"first_name": "Diana",
"last_name": "Prince",
"is_superuser": false,
"organization_slug": "acme-corp",
"role": "member",
"is_active": true
},
{ {
"email": "carol@globex.com", "email": "carol@globex.com",
"password": "Demo123!", "password": "Demo123!",
@@ -77,6 +102,26 @@
"role": "member", "role": "member",
"is_active": true "is_active": true
}, },
{
"email": "ellen@globex.com",
"password": "Demo123!",
"first_name": "Ellen",
"last_name": "Ripley",
"is_superuser": false,
"organization_slug": "globex",
"role": "member",
"is_active": true
},
{
"email": "fred@globex.com",
"password": "Demo123!",
"first_name": "Fred",
"last_name": "Flintstone",
"is_superuser": false,
"organization_slug": "globex",
"role": "member",
"is_active": true
},
{ {
"email": "dave@soylent.com", "email": "dave@soylent.com",
"password": "Demo123!", "password": "Demo123!",
@@ -87,6 +132,26 @@
"role": "member", "role": "member",
"is_active": true "is_active": true
}, },
{
"email": "gina@soylent.com",
"password": "Demo123!",
"first_name": "Gina",
"last_name": "Torres",
"is_superuser": false,
"organization_slug": "soylent",
"role": "member",
"is_active": true
},
{
"email": "harry@soylent.com",
"password": "Demo123!",
"first_name": "Harry",
"last_name": "Potter",
"is_superuser": false,
"organization_slug": "soylent",
"role": "admin",
"is_active": true
},
{ {
"email": "eve@initech.com", "email": "eve@initech.com",
"password": "Demo123!", "password": "Demo123!",
@@ -97,6 +162,26 @@
"role": "admin", "role": "admin",
"is_active": true "is_active": true
}, },
{
"email": "iris@initech.com",
"password": "Demo123!",
"first_name": "Iris",
"last_name": "West",
"is_superuser": false,
"organization_slug": "initech",
"role": "member",
"is_active": true
},
{
"email": "jack@initech.com",
"password": "Demo123!",
"first_name": "Jack",
"last_name": "Sparrow",
"is_superuser": false,
"organization_slug": "initech",
"role": "member",
"is_active": false
},
{ {
"email": "frank@umbrella.com", "email": "frank@umbrella.com",
"password": "Demo123!", "password": "Demo123!",
@@ -117,6 +202,76 @@
"role": "member", "role": "member",
"is_active": false "is_active": false
}, },
{
"email": "kate@umbrella.com",
"password": "Demo123!",
"first_name": "Kate",
"last_name": "Bishop",
"is_superuser": false,
"organization_slug": "umbrella",
"role": "member",
"is_active": true
},
{
"email": "leo@massive.com",
"password": "Demo123!",
"first_name": "Leo",
"last_name": "Messi",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "owner",
"is_active": true
},
{
"email": "mary@massive.com",
"password": "Demo123!",
"first_name": "Mary",
"last_name": "Jane",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "member",
"is_active": true
},
{
"email": "nathan@massive.com",
"password": "Demo123!",
"first_name": "Nathan",
"last_name": "Drake",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "member",
"is_active": true
},
{
"email": "olivia@massive.com",
"password": "Demo123!",
"first_name": "Olivia",
"last_name": "Dunham",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "admin",
"is_active": true
},
{
"email": "peter@massive.com",
"password": "Demo123!",
"first_name": "Peter",
"last_name": "Parker",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "member",
"is_active": true
},
{
"email": "quinn@massive.com",
"password": "Demo123!",
"first_name": "Quinn",
"last_name": "Mallory",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "member",
"is_active": true
},
{ {
"email": "grace@example.com", "email": "grace@example.com",
"password": "Demo123!", "password": "Demo123!",
@@ -146,6 +301,66 @@
"organization_slug": null, "organization_slug": null,
"role": null, "role": null,
"is_active": false "is_active": false
},
{
"email": "rachel@example.com",
"password": "Demo123!",
"first_name": "Rachel",
"last_name": "Green",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "sam@example.com",
"password": "Demo123!",
"first_name": "Sam",
"last_name": "Wilson",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "tony@example.com",
"password": "Demo123!",
"first_name": "Tony",
"last_name": "Stark",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "una@example.com",
"password": "Demo123!",
"first_name": "Una",
"last_name": "Chin-Riley",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": false
},
{
"email": "victor@example.com",
"password": "Demo123!",
"first_name": "Victor",
"last_name": "Von Doom",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "wanda@example.com",
"password": "Demo123!",
"first_name": "Wanda",
"last_name": "Maximoff",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
} }
] ]
} }

View File

@@ -9,7 +9,7 @@ import asyncio
import json import json
import logging import logging
import random import random
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from sqlalchemy import select, text from sqlalchemy import select, text
@@ -37,7 +37,7 @@ async def init_db() -> User | None:
default_password = "AdminPassword123!" default_password = "AdminPassword123!"
if settings.DEMO_MODE: if settings.DEMO_MODE:
default_password = "Admin123!" default_password = "AdminPass1234!"
superuser_password = settings.FIRST_SUPERUSER_PASSWORD or default_password superuser_password = settings.FIRST_SUPERUSER_PASSWORD or default_password
@@ -153,23 +153,28 @@ async def load_demo_data(session):
# Randomize created_at for demo data (last 30 days) # Randomize created_at for demo data (last 30 days)
# This makes the charts look more realistic # This makes the charts look more realistic
days_ago = random.randint(0, 30) # noqa: S311 days_ago = random.randint(0, 30) # noqa: S311
random_time = datetime.utcnow() - timedelta(days=days_ago) random_time = datetime.now(UTC) - timedelta(days=days_ago)
# Add some random hours/minutes variation # Add some random hours/minutes variation
random_time = random_time.replace( random_time = random_time.replace(
hour=random.randint(0, 23), # noqa: S311 hour=random.randint(0, 23), # noqa: S311
minute=random.randint(0, 59), # noqa: S311 minute=random.randint(0, 59), # noqa: S311
) )
# Update the timestamp directly in the database # Update the timestamp and is_active directly in the database
# We do this to ensure the values are persisted correctly
await session.execute( await session.execute(
text( text(
"UPDATE users SET created_at = :created_at WHERE id = :user_id" "UPDATE users SET created_at = :created_at, is_active = :is_active WHERE id = :user_id"
), ),
{"created_at": random_time, "user_id": user.id}, {
"created_at": random_time,
"is_active": user_data.get("is_active", True),
"user_id": user.id,
},
) )
logger.info( logger.info(
f"Created demo user: {user.email} (created {days_ago} days ago)" f"Created demo user: {user.email} (created {days_ago} days ago, active={user_data.get('is_active', True)})"
) )
# Add to organization if specified # Add to organization if specified

View File

@@ -172,7 +172,7 @@ class TestProjectConfiguration:
def test_project_name_default(self): def test_project_name_default(self):
"""Test that project name is set correctly""" """Test that project name is set correctly"""
settings = Settings(SECRET_KEY="a" * 32) settings = Settings(SECRET_KEY="a" * 32)
assert settings.PROJECT_NAME == "App" assert settings.PROJECT_NAME == "PragmaStack"
def test_api_version_string(self): def test_api_version_string(self):
"""Test that API version string is correct""" """Test that API version string is correct"""

3
frontend/.gitignore vendored
View File

@@ -41,3 +41,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Auto-generated files (regenerate with npm run generate:api)
/src/mocks/handlers/generated.ts

543
frontend/docs/DEMO_MODE.md Normal file
View File

@@ -0,0 +1,543 @@
# Demo Mode Documentation
## Overview
Demo Mode allows you to run the frontend without a backend by using **Mock Service Worker (MSW)** to intercept and mock all API calls. This is perfect for:
- **Free deployment** on Vercel (no backend costs)
- **Portfolio showcasing** with live, interactive demos
- **Client presentations** without infrastructure setup
- **Development** when backend is unavailable
## Architecture
```
┌─────────────────┐
│ Your Component │
│ │
│ login({...}) │ ← Same code in all modes
└────────┬────────┘
┌────────────────────┐
│ API Client │
│ (Axios) │
└────────┬───────────┘
┌────────────────────────────────────────────┐
│ Decision Point (Automatic) │
│ │
│ DEMO_MODE=true? │
│ → MSW intercepts request │
│ → Returns mock data │
│ → Never touches network │
│ │
│ DEMO_MODE=false? (default) │
│ → Request goes to real backend │
│ → Normal HTTP to localhost:8000 │
│ │
│ Test environment? │
│ → MSW skipped automatically │
│ → Jest uses existing mocks │
│ → Playwright uses page.route() │
└────────────────────────────────────────────┘
```
**Key Feature:** Your application code is completely unaware of demo mode. The same code works in dev, production, and demo environments.
## Quick Start
### Enable Demo Mode
**Development (local testing):**
```bash
cd frontend
# Create .env.local
echo "NEXT_PUBLIC_DEMO_MODE=true" > .env.local
# Start frontend only (no backend needed)
npm run dev
# Open http://localhost:3000
```
**Vercel Deployment:**
```bash
# Add environment variable in Vercel dashboard:
NEXT_PUBLIC_DEMO_MODE=true
# Deploy
vercel --prod
```
### Demo Credentials
When demo mode is active, use these credentials:
**Regular User:**
- Email: `demo@example.com`
- Password: `DemoPass123`
- Features: Dashboard, profile, organizations, sessions
**Admin User:**
- Email: `admin@example.com`
- Password: `AdminPass123`
- Features: Everything + admin panel, user management, statistics
## Mode Comparison
| Feature | Development (Default) | Demo Mode | Full-Stack Demo |
| ---------------- | ----------------------------- | ----------------------- | ------------------------------ |
| Frontend | Real Next.js app | Real Next.js app | Real Next.js app |
| Backend | Real FastAPI (localhost:8000) | MSW (mocked in browser) | Real FastAPI with demo data |
| Database | Real PostgreSQL | None (in-memory) | Real PostgreSQL with seed data |
| Data Persistence | Yes | No (resets on reload) | Yes |
| API Calls | Real HTTP requests | Intercepted by MSW | Real HTTP requests |
| Authentication | JWT tokens | Mock tokens | JWT tokens |
| Use Case | Local development | Frontend-only demos | Full-stack showcasing |
| Cost | Free (local) | Free (Vercel) | Backend hosting costs |
## How It Works
### MSW Initialization
**1. Safe Guards**
MSW only starts when ALL conditions are met:
```typescript
const shouldStart =
typeof window !== 'undefined' && // Browser (not SSR)
process.env.NODE_ENV !== 'test' && // Not Jest
!window.__PLAYWRIGHT_TEST__ && // Not Playwright
process.env.NEXT_PUBLIC_DEMO_MODE === 'true'; // Explicit opt-in
```
**2. Initialization Flow**
```
Page Load
MSWProvider component mounts
Check if demo mode enabled
[Yes] → Initialize MSW service worker
→ Load request handlers
→ Start intercepting
→ Show demo banner
→ Render app
[No] → Skip (normal mode)
→ Render app immediately
```
### Mock Data Structure
```
src/mocks/
├── browser.ts # MSW setup & initialization
├── handlers/
│ ├── index.ts # Export all handlers
│ ├── auth.ts # Login, register, refresh
│ ├── users.ts # Profile, sessions, organizations
│ └── admin.ts # Admin panel, stats, management
├── data/
│ ├── users.ts # Sample users (5+ users)
│ ├── organizations.ts # Sample orgs (5+ orgs with members)
│ └── stats.ts # Dashboard statistics
└── index.ts # Main exports
```
### Request Handling
**Example: Login Flow**
```typescript
// 1. User submits login form
await login({
body: {
email: 'demo@example.com',
password: 'DemoPass123',
},
});
// 2. Axios makes POST request to /api/v1/auth/login
// 3a. Demo Mode: MSW intercepts
// - Validates credentials against mock data
// - Returns TokenResponse with mock tokens
// - Updates in-memory user state
// - No network request made
// 3b. Normal Mode: Request hits real backend
// - Real database lookup
// - Real JWT token generation
// - Real session creation
// 4. App receives response (same shape in both modes)
// 5. Auth store updated
// 6. User redirected to dashboard
```
## Demo Mode Features
### Authentication
- ✅ Login with email/password
- ✅ Register new users (in-memory only)
- ✅ Password reset flow (simulated)
- ✅ Change password
- ✅ Token refresh
- ✅ Logout / logout all devices
### User Features
- ✅ View/edit profile
- ✅ View organizations
- ✅ Session management
- ✅ Account deletion (simulated)
### Admin Features
- ✅ Dashboard with statistics and charts
- ✅ User management (list, create, edit, delete)
- ✅ Organization management
- ✅ Bulk actions
- ✅ Session monitoring
### Realistic Behavior
- ✅ Network delays (300ms simulated)
- ✅ Validation errors
- ✅ 401/403/404 responses
- ✅ Pagination
- ✅ Search/filtering
## Testing Compatibility
### Unit Tests (Jest)
**Status:****Fully Compatible**
MSW never initializes during Jest tests:
- `process.env.NODE_ENV === 'test'` → MSW skipped
- Existing mocks continue to work
- 97%+ coverage maintained
```bash
npm test # MSW will NOT interfere
```
### E2E Tests (Playwright)
**Status:****Fully Compatible**
MSW never initializes during Playwright tests:
- `window.__PLAYWRIGHT_TEST__` flag detected → MSW skipped
- Playwright's `page.route()` mocking continues to work
- All E2E tests pass unchanged
```bash
npm run test:e2e # MSW will NOT interfere
```
### Manual Testing in Demo Mode
```bash
# Enable demo mode
NEXT_PUBLIC_DEMO_MODE=true npm run dev
# Test flows:
# 1. Open http://localhost:3000
# 2. See orange demo banner at top
# 3. Login with demo@example.com / DemoPass123
# 4. Browse dashboard, profile, settings
# 5. Login with admin@example.com / AdminPass123
# 6. Browse admin panel
# 7. Check browser console for MSW logs
```
## Deployment Guides
### Vercel (Recommended for Demo)
**1. Fork Repository**
```bash
gh repo fork your-repo/fast-next-template
```
**2. Connect to Vercel**
- Go to vercel.com
- Import Git Repository
- Select your fork
**3. Configure Environment Variables**
```
# Vercel Dashboard → Settings → Environment Variables
NEXT_PUBLIC_DEMO_MODE=true
NEXT_PUBLIC_APP_NAME=My Demo App
```
**4. Deploy**
- Vercel auto-deploys on push
- Visit your deployment URL
- Demo banner should be visible
- Try logging in with demo credentials
**Cost:** Free (Hobby tier includes unlimited deployments)
### Netlify
```bash
# netlify.toml
[build]
command = "npm run build"
publish = ".next"
[build.environment]
NEXT_PUBLIC_DEMO_MODE = "true"
```
### Static Export (GitHub Pages)
```bash
# Enable static export
# next.config.js
module.exports = {
output: 'export',
}
# Build
NEXT_PUBLIC_DEMO_MODE=true npm run build
# Deploy to GitHub Pages
npm run deploy
```
## Troubleshooting
### Demo Mode Not Starting
**Check 1: Environment Variable**
```bash
# Frontend terminal should show:
# NEXT_PUBLIC_DEMO_MODE=true
# If not, check .env.local exists
cat .env.local
```
**Check 2: Browser Console**
```javascript
// Open DevTools Console
// Should see:
// [MSW] Demo Mode Active
// [MSW] All API calls are mocked (no backend required)
// [MSW] Demo credentials: ...
// If not showing, check:
console.log(process.env.NEXT_PUBLIC_DEMO_MODE);
// Should print: "true"
```
**Check 3: Service Worker**
```javascript
// Open DevTools → Application → Service Workers
// Should see: mockServiceWorker.js (activated)
```
### MSW Intercepting During Tests
**Problem:** Tests fail with "Unexpected MSW behavior"
**Solution:** MSW has triple safety checks:
```typescript
// Check these conditions in browser console during tests:
console.log({
isServer: typeof window === 'undefined', // Should be false
isTest: process.env.NODE_ENV === 'test', // Should be true
isPlaywright: window.__PLAYWRIGHT_TEST__, // Should be true (E2E)
demoMode: process.env.NEXT_PUBLIC_DEMO_MODE, // Ignored if above are true
});
```
If MSW still runs during tests:
1. Clear service worker: DevTools → Application → Clear Storage
2. Restart test runner
3. Check for global environment pollution
### Missing Mock Data
**Problem:** API returns 404 in demo mode
**Solution:** Check if endpoint is mocked:
```bash
# Search for your endpoint in handlers
grep -r "your-endpoint" src/mocks/handlers/
# If not found, add to appropriate handler file
```
### Stale Data After Logout
**Problem:** User data persists after logout
**Cause:** In-memory state in demo mode
**Solution:** This is expected behavior. To reset:
- Refresh the page (Cmd/Ctrl + R)
- Or implement state reset in logout handler
## Advanced Usage
### Custom Mock Data
**Add your own users:**
```typescript
// src/mocks/data/users.ts
export const customUser: UserResponse = {
id: 'custom-user-1',
email: 'john@company.com',
first_name: 'John',
last_name: 'Doe',
is_active: true,
is_superuser: false,
// ... rest of fields
};
// Add to sampleUsers array
export const sampleUsers = [demoUser, demoAdmin, customUser];
// Update validateCredentials to accept your password
```
### Custom Error Scenarios
**Simulate specific errors:**
```typescript
// src/mocks/handlers/auth.ts
http.post('/api/v1/auth/login', async ({ request }) => {
const body = await request.json();
// Simulate rate limiting
if (Math.random() < 0.1) {
// 10% chance
return HttpResponse.json({ detail: 'Too many login attempts' }, { status: 429 });
}
// Normal flow...
});
```
### Network Delay Simulation
**Adjust response times:**
```typescript
// src/mocks/handlers/auth.ts
const NETWORK_DELAY = 1000; // 1 second (slow network)
// or
const NETWORK_DELAY = 50; // 50ms (fast network)
http.post('/api/v1/auth/login', async ({ request }) => {
await delay(NETWORK_DELAY);
// ...
});
```
## FAQ
**Q: Will demo mode affect my production build?**
A: No. If `NEXT_PUBLIC_DEMO_MODE` is not set or is `false`, MSW code is imported but never initialized. The bundle size impact is minimal (~50KB), and tree-shaking removes unused code.
**Q: Can I use demo mode with backend?**
A: Yes! You can run both. MSW will intercept frontend calls, while backend runs separately. Useful for testing frontend in isolation.
**Q: How do I disable the demo banner?**
A: Click the X button, or set `NEXT_PUBLIC_DEMO_MODE=false`.
**Q: Can I use this for E2E testing instead of Playwright mocks?**
A: Not recommended. Playwright's `page.route()` is more reliable for E2E tests and provides better control over timing and responses.
**Q: What happens to data created in demo mode?**
A: It's stored in memory and lost on page reload. This is intentional for demo purposes.
**Q: Can I export demo data?**
A: Not built-in, but you can add a "Download Sample Data" button that exports mock data as JSON.
## Best Practices
### ✅ Do
- Use demo mode for showcasing and prototyping
- Keep mock data realistic and representative
- Test demo mode before deploying
- Display demo banner prominently
- Document demo credentials clearly
- Use for client presentations without infrastructure
### ❌ Don't
- Use demo mode for production with real users
- Store sensitive data in mock files
- Rely on demo mode for critical functionality
- Mix demo and production data
- Use demo mode for performance testing
- Expect data persistence across sessions
## Support & Contributing
**Issues:** If you find bugs or have suggestions, please open an issue.
**Adding Endpoints:** To add mock support for new endpoints:
1. Add mock data to `src/mocks/data/`
2. Create handler in `src/mocks/handlers/`
3. Export handler in `src/mocks/handlers/index.ts`
4. Test in demo mode
5. Document in this file
**Improving Mock Data:** To make demos more realistic:
1. Add more sample users/orgs in `src/mocks/data/`
2. Improve error scenarios in handlers
3. Add more edge cases (pagination, filtering, etc.)
4. Submit PR with improvements
## Related Documentation
- [API Integration](./API_INTEGRATION.md) - How API client works
- [Testing Guide](./TESTING.md) - Unit and E2E testing
- [Architecture](./ARCHITECTURE_FIX_REPORT.md) - Dependency injection patterns
- [Design System](./design-system/) - UI component guidelines
---
**Last Updated:** 2025-01-24
**MSW Version:** 2.x
**Maintainer:** Template Contributors

View File

@@ -0,0 +1,402 @@
# MSW Auto-Generation from OpenAPI
## Overview
MSW (Mock Service Worker) handlers are **automatically generated** from your OpenAPI specification, ensuring perfect synchronization between your backend API and demo mode.
## Architecture
```
Backend API Changes
npm run generate:api
┌─────────────────────────────────────┐
│ 1. Fetches OpenAPI spec │
│ 2. Generates TypeScript API client │
│ 3. Generates MSW handlers │
└─────────────────────────────────────┘
src/mocks/handlers/
├── generated.ts (AUTO-GENERATED - DO NOT EDIT)
├── overrides.ts (CUSTOM LOGIC - EDIT AS NEEDED)
└── index.ts (MERGES BOTH)
```
## How It Works
### 1. Automatic Generation
When you run:
```bash
npm run generate:api
```
The system:
1. Fetches `/api/v1/openapi.json` from backend
2. Generates TypeScript API client (`src/lib/api/generated/`)
3. **NEW:** Generates MSW handlers (`src/mocks/handlers/generated.ts`)
### 2. Generated Handlers
The generator (`scripts/generate-msw-handlers.ts`) creates handlers with:
**Smart Response Logic:**
- **Auth endpoints** → Use `validateCredentials()` and `setCurrentUser()`
- **User endpoints** → Use `currentUser` and mock data
- **Admin endpoints** → Check `is_superuser` + return paginated data
- **Generic endpoints** → Return success response
**Example Generated Handler:**
```typescript
/**
* Login
*/
http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as any;
const user = validateCredentials(body.email, body.password);
if (!user) {
return HttpResponse.json(
{ detail: 'Incorrect email or password' },
{ status: 401 }
);
}
const accessToken = `demo-access-${user.id}-${Date.now()}`;
const refreshToken = `demo-refresh-${user.id}-${Date.now()}`;
setCurrentUser(user);
return HttpResponse.json({
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'bearer',
expires_in: 900,
});
}),
```
### 3. Custom Overrides
For complex logic that can't be auto-generated, use `overrides.ts`:
```typescript
// src/mocks/handlers/overrides.ts
export const overrideHandlers = [
// Example: Simulate rate limiting
http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request }) => {
// 10% chance of rate limit
if (Math.random() < 0.1) {
return HttpResponse.json(
{ detail: 'Too many login attempts' },
{ status: 429 }
);
}
// Fall through to generated handler
}),
// Example: Complex validation
http.post(`${API_BASE_URL}/api/v1/users`, async ({ request }) => {
const body = await request.json();
// Custom validation logic
if (body.email.endsWith('@blocked.com')) {
return HttpResponse.json(
{ detail: 'Email domain not allowed' },
{ status: 400 }
);
}
// Fall through to generated handler
}),
];
```
**Override Precedence:**
Overrides are applied FIRST, so they take precedence over generated handlers.
## Benefits
### ✅ Zero Manual Work
**Before:**
```bash
# Backend adds new endpoint
# 1. Run npm run generate:api
# 2. Manually add MSW handler
# 3. Test demo mode
# 4. Fix bugs
# 5. Repeat for every endpoint change
```
**After:**
```bash
# Backend adds new endpoint
npm run generate:api # Done! MSW auto-synced
```
### ✅ Always In Sync
- OpenAPI spec is single source of truth
- Generator reads same spec as API client
- Impossible to have mismatched endpoints
- New endpoints automatically available in demo mode
### ✅ Type-Safe
```typescript
// Generated handlers use your mock data
import { validateCredentials, currentUser } from '../data/users';
import { sampleOrganizations } from '../data/organizations';
import { adminStats } from '../data/stats';
// Everything is typed!
```
### ✅ Batteries Included
Generated handlers include:
- ✅ Network delays (300ms - realistic UX)
- ✅ Auth checks (401/403 responses)
- ✅ Pagination support
- ✅ Path parameters
- ✅ Request body parsing
- ✅ Proper HTTP methods
## File Structure
```
frontend/
├── scripts/
│ ├── generate-api-client.sh # Main generation script
│ └── generate-msw-handlers.ts # MSW handler generator
├── src/
│ ├── lib/api/generated/ # Auto-generated API client
│ │ ├── client.gen.ts
│ │ ├── sdk.gen.ts
│ │ └── types.gen.ts
│ │
│ └── mocks/
│ ├── browser.ts # MSW setup
│ ├── data/ # Mock data (EDIT THESE)
│ │ ├── users.ts
│ │ ├── organizations.ts
│ │ └── stats.ts
│ └── handlers/
│ ├── generated.ts # ⚠️ AUTO-GENERATED
│ ├── overrides.ts # ✅ EDIT FOR CUSTOM LOGIC
│ └── index.ts # Merges both
```
## Workflow
### Adding New Backend Endpoint
1. **Add endpoint to backend** (FastAPI route)
2. **Regenerate clients:**
```bash
cd frontend
npm run generate:api
```
3. **Test demo mode:**
```bash
NEXT_PUBLIC_DEMO_MODE=true npm run dev
```
4. **Done!** New endpoint automatically works in demo mode
### Customizing Handler Behavior
If generated handler doesn't fit your needs:
1. **Add override** in `src/mocks/handlers/overrides.ts`
2. **Keep generated handler** (don't edit `generated.ts`)
3. **Override takes precedence** automatically
Example:
```typescript
// overrides.ts
export const overrideHandlers = [
// Override auto-generated login to add 2FA simulation
http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request }) => {
const body = await request.json();
// Simulate 2FA requirement for admin users
if (body.email.includes('admin') && !body.two_factor_code) {
return HttpResponse.json(
{ detail: 'Two-factor authentication required' },
{ status: 403 }
);
}
// Fall through to generated handler
}),
];
```
### Updating Mock Data
Mock data is separate from handlers:
```typescript
// src/mocks/data/users.ts
export const demoUser: UserResponse = {
id: 'demo-user-id-1',
email: 'demo@example.com',
first_name: 'Demo',
last_name: 'User',
// ... add more fields as backend evolves
};
```
**To update:**
1. Edit `data/*.ts` files
2. Handlers automatically use updated data
3. No regeneration needed!
## Generator Internals
The generator (`scripts/generate-msw-handlers.ts`) does:
1. **Parse OpenAPI spec**
```typescript
const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
```
2. **For each endpoint:**
- Convert path params: `{id}` → `:id`
- Determine handler category (auth/users/admin)
- Generate appropriate mock response
- Add network delay
- Include error handling
3. **Write generated file:**
```typescript
fs.writeFileSync('src/mocks/handlers/generated.ts', handlerCode);
```
## Troubleshooting
### Generated handler doesn't work
**Check:**
1. Is backend running? (`npm run generate:api` requires backend)
2. Check console for `[MSW]` warnings
3. Verify `generated.ts` exists and has your endpoint
4. Check path parameters match exactly
**Debug:**
```bash
# See what endpoints were generated
cat src/mocks/handlers/generated.ts | grep "http\."
```
### Need custom behavior
**Don't edit `generated.ts`!** Use overrides instead:
```typescript
// overrides.ts
export const overrideHandlers = [
http.post(`${API_BASE_URL}/your/endpoint`, async ({ request }) => {
// Your custom logic
}),
];
```
### Regeneration fails
```bash
# Manual regeneration
cd frontend
curl -s http://localhost:8000/api/v1/openapi.json > /tmp/openapi.json
npx tsx scripts/generate-msw-handlers.ts /tmp/openapi.json
```
## Best Practices
### ✅ Do
- Run `npm run generate:api` after backend changes
- Use `overrides.ts` for complex logic
- Keep mock data in `data/` files
- Test demo mode regularly
- Commit `overrides.ts` to git
### ❌ Don't
- Don't edit `generated.ts` manually (changes will be overwritten)
- Don't commit `generated.ts` to git (it's auto-generated)
- Don't duplicate logic between overrides and generated
- Don't skip regeneration after API changes
## Advanced: Generator Customization
Want to customize the generator itself?
Edit `scripts/generate-msw-handlers.ts`:
```typescript
function generateMockResponse(path: string, method: string, operation: any): string {
// Your custom generation logic
if (path.includes('/your-special-endpoint')) {
return `
// Your custom handler code
`;
}
// ... rest of generation logic
}
```
## Comparison
### Before (Manual)
```typescript
// Had to manually write this for EVERY endpoint:
http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request }) => {
// 50 lines of code...
}),
http.get(`${API_BASE_URL}/api/v1/users/me`, async ({ request }) => {
// 30 lines of code...
}),
// ... repeat for 31+ endpoints
// ... manually update when backend changes
// ... easy to forget endpoints
// ... prone to bugs
```
### After (Automated)
```bash
npm run generate:api # Done! All 31+ endpoints handled automatically
```
**Manual Code: 1500+ lines**
**Automated: 1 command**
**Time Saved: Hours per API change**
**Bugs: Near zero (generated from spec)**
---
## See Also
- [DEMO_MODE.md](./DEMO_MODE.md) - Complete demo mode guide
- [API_INTEGRATION.md](./API_INTEGRATION.md) - API client docs
- [ARCHITECTURE_FIX_REPORT.md](./ARCHITECTURE_FIX_REPORT.md) - DI patterns
## Summary
**This template is batteries-included.**
Your API client and MSW handlers stay perfectly synchronized with zero manual work.
Just run `npm run generate:api` and everything updates automatically.
That's the power of OpenAPI + automation! 🚀

View File

@@ -17,6 +17,8 @@ export default [
'dist/**', 'dist/**',
'coverage/**', 'coverage/**',
'src/lib/api/generated/**', 'src/lib/api/generated/**',
'src/mocks/**', // MSW mock data (demo mode only, not production code)
'public/mockServiceWorker.js', // Auto-generated by MSW
'*.gen.ts', '*.gen.ts',
'*.gen.tsx', '*.gen.tsx',
'next-env.d.ts', // Auto-generated by Next.js 'next-env.d.ts', // Auto-generated by Next.js

View File

@@ -37,6 +37,9 @@ const customJestConfig = {
'!src/**/index.{js,jsx,ts,tsx}', // Re-export index files - no logic to test '!src/**/index.{js,jsx,ts,tsx}', // Re-export index files - no logic to test
'!src/lib/utils/cn.ts', // Simple utility function from shadcn '!src/lib/utils/cn.ts', // Simple utility function from shadcn
'!src/middleware.ts', // middleware.ts - no logic to test '!src/middleware.ts', // middleware.ts - no logic to test
'!src/mocks/**', // MSW mock data (demo mode only, not production code)
'!src/components/providers/MSWProvider.tsx', // MSW provider - demo mode only
'!src/components/demo/**', // Demo mode UI components - demo mode only
], ],
coverageThreshold: { coverageThreshold: {
global: { global: {

File diff suppressed because it is too large Load Diff

View File

@@ -85,10 +85,17 @@
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"lighthouse": "^12.8.2", "lighthouse": "^12.8.2",
"msw": "^2.12.3",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5", "typescript": "^5",
"typescript-eslint": "^8.15.0", "typescript-eslint": "^8.15.0",
"whatwg-fetch": "^3.6.20" "whatwg-fetch": "^3.6.20"
},
"msw": {
"workerDirectory": [
"public"
]
} }
} }

View File

@@ -0,0 +1,336 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.12.3';
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82';
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
const activeClientIds = new Set();
addEventListener('install', function () {
self.skipWaiting();
});
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
});
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id');
if (!clientId || !self.clients) {
return;
}
const client = await self.clients.get(clientId);
if (!client) {
return;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
});
break;
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId);
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId);
const remainingClients = allClients.filter((client) => {
return client.id !== clientId;
});
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister();
}
break;
}
}
});
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now();
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return;
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
return;
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
}
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone();
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : []
);
}
return response;
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (activeClientIds.has(event.clientId)) {
return client;
}
if (client?.frameType === 'top-level') {
return client;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible';
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone();
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept');
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim());
const filteredValues = values.filter((value) => value !== 'msw/passthrough');
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '));
} else {
headers.delete('accept');
}
}
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request);
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body]
);
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data);
}
case 'PASSTHROUGH': {
return passthrough();
}
}
return passthrough();
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error);
}
resolve(event.data);
};
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}
const mockedResponse = new Response(response.body, response);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
return mockedResponse;
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}

View File

@@ -71,6 +71,14 @@ for file in "$OUTPUT_DIR"/**/*.ts "$OUTPUT_DIR"/*.ts; do
done done
echo -e "${GREEN}✓ ESLint disabled for generated files${NC}" echo -e "${GREEN}✓ ESLint disabled for generated files${NC}"
# Generate MSW handlers from OpenAPI spec
echo -e "${YELLOW}🎭 Generating MSW handlers...${NC}"
if npx tsx scripts/generate-msw-handlers.ts /tmp/openapi.json; then
echo -e "${GREEN}✓ MSW handlers generated successfully${NC}"
else
echo -e "${YELLOW}⚠ MSW handler generation failed (non-critical)${NC}"
fi
# Clean up # Clean up
rm /tmp/openapi.json rm /tmp/openapi.json
@@ -80,8 +88,13 @@ echo -e "${YELLOW}📝 Generated files:${NC}"
echo -e " - $OUTPUT_DIR/index.ts" echo -e " - $OUTPUT_DIR/index.ts"
echo -e " - $OUTPUT_DIR/schemas/" echo -e " - $OUTPUT_DIR/schemas/"
echo -e " - $OUTPUT_DIR/services/" echo -e " - $OUTPUT_DIR/services/"
echo -e " - src/mocks/handlers/generated.ts (MSW handlers)"
echo "" echo ""
echo -e "${YELLOW}💡 Next steps:${NC}" echo -e "${YELLOW}💡 Next steps:${NC}"
echo -e " Import in your code:" echo -e " Import in your code:"
echo -e " ${GREEN}import { ApiClient } from '@/lib/api/generated';${NC}" echo -e " ${GREEN}import { ApiClient } from '@/lib/api/generated';${NC}"
echo "" echo ""
echo -e "${YELLOW}🎭 Demo Mode:${NC}"
echo -e " MSW handlers are automatically synced with your API"
echo -e " Test demo mode: ${GREEN}NEXT_PUBLIC_DEMO_MODE=true npm run dev${NC}"
echo ""

View File

@@ -0,0 +1,369 @@
#!/usr/bin/env node
/**
* MSW Handler Generator
*
* Automatically generates MSW request handlers from OpenAPI specification.
* This keeps mock API in sync with real backend automatically.
*
* Usage: node scripts/generate-msw-handlers.ts /tmp/openapi.json
*/
import fs from 'fs';
import path from 'path';
interface OpenAPISpec {
paths: {
[path: string]: {
[method: string]: {
operationId?: string;
summary?: string;
responses: {
[status: string]: {
description: string;
content?: {
'application/json'?: {
schema?: unknown;
};
};
};
};
parameters?: Array<{
name: string;
in: string;
required?: boolean;
schema?: { type: string };
}>;
requestBody?: {
content?: {
'application/json'?: {
schema?: unknown;
};
};
};
};
};
};
}
function parseOpenAPISpec(specPath: string): OpenAPISpec {
const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
return spec;
}
function getMethodName(method: string): string {
const methodMap: Record<string, string> = {
get: 'get',
post: 'post',
put: 'put',
patch: 'patch',
delete: 'delete',
};
return methodMap[method.toLowerCase()] || method;
}
function convertPathToMSWPattern(path: string): string {
// Convert OpenAPI path params {id} to MSW params :id
return path.replace(/\{([^}]+)\}/g, ':$1');
}
function shouldSkipEndpoint(path: string, method: string): boolean {
// Skip health check and root endpoints
if (path === '/' || path === '/health') return true;
// Skip OAuth endpoints (handled by regular login)
if (path.includes('/oauth')) return true;
return false;
}
function getHandlerCategory(path: string): 'auth' | 'users' | 'admin' | 'organizations' {
if (path.startsWith('/api/v1/auth')) return 'auth';
if (path.startsWith('/api/v1/admin')) return 'admin';
if (path.startsWith('/api/v1/organizations')) return 'organizations';
return 'users';
}
function generateMockResponse(path: string, method: string, operation: any): string {
const category = getHandlerCategory(path);
// Auth endpoints
if (category === 'auth') {
if (path.includes('/login') && method === 'post') {
return `
const body = (await request.json()) as any;
const user = validateCredentials(body.email, body.password);
if (!user) {
return HttpResponse.json(
{ detail: 'Incorrect email or password' },
{ status: 401 }
);
}
const accessToken = \`demo-access-\${user.id}-\${Date.now()}\`;
const refreshToken = \`demo-refresh-\${user.id}-\${Date.now()}\`;
setCurrentUser(user);
return HttpResponse.json({
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'bearer',
expires_in: 900,
});`;
}
if (path.includes('/register') && method === 'post') {
return `
const body = (await request.json()) as any;
const newUser = {
id: \`new-user-\${Date.now()}\`,
email: body.email,
first_name: body.first_name,
last_name: body.last_name || null,
phone_number: body.phone_number || null,
is_active: true,
is_superuser: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_login: null,
organization_count: 0,
};
setCurrentUser(newUser);
return HttpResponse.json({
user: newUser,
access_token: \`demo-access-\${Date.now()}\`,
refresh_token: \`demo-refresh-\${Date.now()}\`,
token_type: 'bearer',
expires_in: 900,
});`;
}
if (path.includes('/refresh') && method === 'post') {
return `
return HttpResponse.json({
access_token: \`demo-access-refreshed-\${Date.now()}\`,
refresh_token: \`demo-refresh-refreshed-\${Date.now()}\`,
token_type: 'bearer',
expires_in: 900,
});`;
}
// Generic auth success
return `
return HttpResponse.json({
success: true,
message: 'Operation successful',
});`;
}
// User endpoints
if (category === 'users') {
if (path === '/api/v1/users/me' && method === 'get') {
return `
if (!currentUser) {
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
}
return HttpResponse.json(currentUser);`;
}
if (path === '/api/v1/users/me' && method === 'patch') {
return `
if (!currentUser) {
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
}
const body = (await request.json()) as any;
updateCurrentUser(body);
return HttpResponse.json(currentUser);`;
}
if (path === '/api/v1/organizations/me' && method === 'get') {
return `
if (!currentUser) {
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
}
const orgs = getUserOrganizations(currentUser.id);
return HttpResponse.json(orgs);`;
}
if (path === '/api/v1/sessions' && method === 'get') {
return `
if (!currentUser) {
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
}
return HttpResponse.json({ sessions: [] });`;
}
}
// Admin endpoints
if (category === 'admin') {
const authCheck = `
if (!currentUser?.is_superuser) {
return HttpResponse.json({ detail: 'Admin access required' }, { status: 403 });
}`;
if (path === '/api/v1/admin/stats' && method === 'get') {
return `${authCheck}
return HttpResponse.json(adminStats);`;
}
if (path === '/api/v1/admin/users' && method === 'get') {
return `${authCheck}
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedUsers = sampleUsers.slice(start, end);
return HttpResponse.json({
data: paginatedUsers,
pagination: {
total: sampleUsers.length,
page,
page_size: pageSize,
total_pages: Math.ceil(sampleUsers.length / pageSize),
has_next: end < sampleUsers.length,
has_prev: page > 1,
},
});`;
}
if (path === '/api/v1/admin/organizations' && method === 'get') {
return `${authCheck}
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedOrgs = sampleOrganizations.slice(start, end);
return HttpResponse.json({
data: paginatedOrgs,
pagination: {
total: sampleOrganizations.length,
page,
page_size: pageSize,
total_pages: Math.ceil(sampleOrganizations.length / pageSize),
has_next: end < sampleOrganizations.length,
has_prev: page > 1,
},
});`;
}
}
// Generic success response
return `
return HttpResponse.json({
success: true,
message: 'Operation successful'
});`;
}
function generateHandlers(spec: OpenAPISpec): string {
const handlers: string[] = [];
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
for (const [pathPattern, pathItem] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method.toLowerCase())) {
continue;
}
if (shouldSkipEndpoint(pathPattern, method)) {
continue;
}
const mswPath = convertPathToMSWPattern(pathPattern);
const httpMethod = getMethodName(method);
const summary = operation.summary || `${method.toUpperCase()} ${pathPattern}`;
const mockResponse = generateMockResponse(pathPattern, method, operation);
const handler = `
/**
* ${summary}
*/
http.${httpMethod}(\`\${API_BASE_URL}${mswPath}\`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
${mockResponse}
}),`;
handlers.push(handler);
}
}
return handlers.join('\n');
}
function generateHandlerFile(spec: OpenAPISpec): string {
const handlersCode = generateHandlers(spec);
return `/**
* Auto-generated MSW Handlers
*
* ⚠️ DO NOT EDIT THIS FILE MANUALLY
*
* This file is automatically generated from the OpenAPI specification.
* To regenerate: npm run generate:api
*
* For custom handler behavior, use src/mocks/handlers/overrides.ts
*
* Generated: ${new Date().toISOString()}
*/
import { http, HttpResponse, delay } from 'msw';
import {
validateCredentials,
setCurrentUser,
updateCurrentUser,
currentUser,
sampleUsers,
} from '../data/users';
import {
sampleOrganizations,
getUserOrganizations,
getOrganizationMembersList,
} from '../data/organizations';
import { adminStats } from '../data/stats';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
const NETWORK_DELAY = 300; // ms - simulate realistic network delay
/**
* Auto-generated request handlers
* Covers all endpoints defined in OpenAPI spec
*/
export const generatedHandlers = [${handlersCode}
];
`;
}
// Main execution
function main() {
const specPath = process.argv[2] || '/tmp/openapi.json';
if (!fs.existsSync(specPath)) {
console.error(`❌ OpenAPI spec not found at: ${specPath}`);
console.error(' Make sure backend is running and OpenAPI spec is available');
process.exit(1);
}
console.log('📖 Reading OpenAPI specification...');
const spec = parseOpenAPISpec(specPath);
console.log('🔨 Generating MSW handlers...');
const handlerCode = generateHandlerFile(spec);
const outputPath = path.join(__dirname, '../src/mocks/handlers/generated.ts');
fs.writeFileSync(outputPath, handlerCode);
console.log(`✅ Generated MSW handlers: ${outputPath}`);
console.log(`📊 Generated ${Object.keys(spec.paths).length} endpoint handlers`);
}
main();

View File

@@ -0,0 +1,110 @@
# Keeping MSW Handlers Synced with OpenAPI Spec
## Problem
MSW handlers can drift out of sync with the backend API as it evolves.
## Solution Options
### Option 1: Use openapi-msw (Recommended)
Install the package that auto-generates MSW handlers from OpenAPI:
```bash
npm install --save-dev openapi-msw
```
Then create a generation script:
```typescript
// scripts/generate-msw-handlers.ts
import { generateMockHandlers } from 'openapi-msw';
import fs from 'fs';
async function generate() {
const spec = JSON.parse(fs.readFileSync('/tmp/openapi.json', 'utf-8'));
const handlers = generateMockHandlers(spec, {
baseUrl: 'http://localhost:8000',
});
fs.writeFileSync('src/mocks/handlers/generated.ts', handlers);
}
generate();
```
### Option 2: Manual Sync Checklist
When you add/change backend endpoints:
1. **Update Backend** → Make API changes
2. **Generate Frontend Client**`npm run generate:api`
3. **Update MSW Handlers** → Edit `src/mocks/handlers/*.ts`
4. **Test Demo Mode**`NEXT_PUBLIC_DEMO_MODE=true npm run dev`
### Option 3: Automated with Script Hook
Add to `package.json`:
```json
{
"scripts": {
"generate:api": "./scripts/generate-api-client.sh && npm run sync:msw",
"sync:msw": "echo '⚠️ Don't forget to update MSW handlers in src/mocks/handlers/'"
}
}
```
## Current Coverage
Our MSW handlers currently cover:
**Auth Endpoints:**
- POST `/api/v1/auth/register`
- POST `/api/v1/auth/login`
- POST `/api/v1/auth/refresh`
- POST `/api/v1/auth/logout`
- POST `/api/v1/auth/logout-all`
- POST `/api/v1/auth/password-reset`
- POST `/api/v1/auth/password-reset/confirm`
- POST `/api/v1/auth/change-password`
**User Endpoints:**
- GET `/api/v1/users/me`
- PATCH `/api/v1/users/me`
- DELETE `/api/v1/users/me`
- GET `/api/v1/users/:id`
- GET `/api/v1/users`
- GET `/api/v1/organizations/me`
- GET `/api/v1/sessions`
- DELETE `/api/v1/sessions/:id`
**Admin Endpoints:**
- GET `/api/v1/admin/stats`
- GET `/api/v1/admin/users`
- GET `/api/v1/admin/users/:id`
- POST `/api/v1/admin/users`
- PATCH `/api/v1/admin/users/:id`
- DELETE `/api/v1/admin/users/:id`
- POST `/api/v1/admin/users/bulk`
- GET `/api/v1/admin/organizations`
- GET `/api/v1/admin/organizations/:id`
- GET `/api/v1/admin/organizations/:id/members`
- GET `/api/v1/admin/sessions`
## Quick Validation
To check if MSW is missing handlers:
1. Start demo mode: `NEXT_PUBLIC_DEMO_MODE=true npm run dev`
2. Open browser console
3. Look for `[MSW] Warning: intercepted a request without a matching request handler`
4. Add missing handlers to appropriate file in `src/mocks/handlers/`
## Best Practices
1. **Keep handlers simple** - Return happy path responses by default
2. **Match backend schemas** - Use generated TypeScript types
3. **Realistic delays** - Use `await delay(300)` for UX testing
4. **Document passwords** - Make demo credentials obvious
5. **Test regularly** - Run demo mode after API changes

View File

@@ -11,7 +11,7 @@ import { DashboardStats } from '@/components/admin';
import { import {
UserGrowthChart, UserGrowthChart,
OrganizationDistributionChart, OrganizationDistributionChart,
SessionActivityChart, RegistrationActivityChart,
UserStatusChart, UserStatusChart,
} from '@/components/charts'; } from '@/components/charts';
import { Users, Building2, Settings } from 'lucide-react'; import { Users, Building2, Settings } from 'lucide-react';
@@ -19,12 +19,40 @@ import { useQuery } from '@tanstack/react-query';
import { getAdminStats } from '@/lib/api/admin'; import { getAdminStats } from '@/lib/api/admin';
export default function AdminPage() { export default function AdminPage() {
const { data: stats, isLoading, error } = useQuery({ console.log('[AdminPage] Component rendering');
queryKey: ['admin', 'stats'],
const {
data: stats,
isLoading,
error,
status,
fetchStatus,
} = useQuery({
queryKey: ['admin', 'analytics'], // Changed from 'stats' to avoid collision with useAdminStats hook
queryFn: async () => { queryFn: async () => {
const response = await getAdminStats(); console.log('[AdminPage] QueryFn executing - fetching stats...');
return response.data; try {
const response = await getAdminStats();
console.log('[AdminPage] Stats response received:', response);
return response.data;
} catch (err) {
console.error('[AdminPage] Error fetching stats:', err);
throw err;
}
}, },
enabled: true, // Explicitly enable the query
retry: 1,
staleTime: 60000, // Cache for 1 minute
});
console.log('[AdminPage] Current state:', {
isLoading,
hasError: Boolean(error),
error: error?.message,
hasData: Boolean(stats),
dataKeys: stats ? Object.keys(stats) : null,
status,
fetchStatus,
}); });
return ( return (
@@ -90,7 +118,11 @@ export default function AdminPage() {
loading={isLoading} loading={isLoading}
error={error ? (error as Error).message : null} error={error ? (error as Error).message : null}
/> />
<SessionActivityChart /> <RegistrationActivityChart
data={stats?.registration_activity}
loading={isLoading}
error={error ? (error as Error).message : null}
/>
<OrganizationDistributionChart <OrganizationDistributionChart
data={stats?.organization_distribution} data={stats?.organization_distribution}
loading={isLoading} loading={isLoading}

View File

@@ -52,7 +52,7 @@ const demoCategories = [
features: ['Login & logout', 'Registration', 'Password reset', 'Session tokens'], features: ['Login & logout', 'Registration', 'Password reset', 'Session tokens'],
credentials: { credentials: {
email: 'demo@example.com', email: 'demo@example.com',
password: 'Demo123!', password: 'DemoPass1234!',
role: 'Regular User', role: 'Regular User',
}, },
}, },
@@ -64,7 +64,7 @@ const demoCategories = [
features: ['Profile editing', 'Password changes', 'Active sessions', 'Preferences'], features: ['Profile editing', 'Password changes', 'Active sessions', 'Preferences'],
credentials: { credentials: {
email: 'demo@example.com', email: 'demo@example.com',
password: 'Demo123!', password: 'DemoPass1234!',
role: 'Regular User', role: 'Regular User',
}, },
}, },
@@ -76,7 +76,7 @@ const demoCategories = [
features: ['User management', 'Analytics charts', 'Bulk operations', 'Organization control'], features: ['User management', 'Analytics charts', 'Bulk operations', 'Organization control'],
credentials: { credentials: {
email: 'admin@example.com', email: 'admin@example.com',
password: 'Admin123!', password: 'AdminPass1234!',
role: 'Admin', role: 'Admin',
}, },
}, },

View File

@@ -9,6 +9,8 @@ import '../globals.css';
import { Providers } from '../providers'; import { Providers } from '../providers';
import { AuthProvider } from '@/lib/auth/AuthContext'; import { AuthProvider } from '@/lib/auth/AuthContext';
import { AuthInitializer } from '@/components/auth'; import { AuthInitializer } from '@/components/auth';
import { MSWProvider } from '@/components/providers/MSWProvider';
import { DemoModeBanner } from '@/components/demo';
const geistSans = Geist({ const geistSans = Geist({
variable: '--font-geist-sans', variable: '--font-geist-sans',
@@ -82,10 +84,13 @@ export default async function LocaleLayout({
</head> </head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<AuthProvider> <MSWProvider>
<AuthInitializer /> <DemoModeBanner />
<Providers>{children}</Providers> <AuthProvider>
</AuthProvider> <AuthInitializer />
<Providers>{children}</Providers>
</AuthProvider>
</MSWProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -5,48 +5,65 @@
'use client'; 'use client';
import { import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { ChartCard } from './ChartCard'; import { ChartCard } from './ChartCard';
import { CHART_PALETTES } from '@/lib/chart-colors'; import { CHART_PALETTES } from '@/lib/chart-colors';
export interface OrganizationDistributionData { export interface OrgDistributionData {
name: string; name: string;
value: number; value: number;
} }
interface OrganizationDistributionChartProps { interface OrganizationDistributionChartProps {
data?: OrganizationDistributionData[]; data?: OrgDistributionData[];
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
} }
// Generate mock data for development/demo // Custom tooltip with proper theme colors
function generateMockData(): OrganizationDistributionData[] { const CustomTooltip = ({ active, payload }: any) => {
return [ if (active && payload && payload.length) {
{ name: 'Engineering', value: 45 }, return (
{ name: 'Marketing', value: 28 }, <div
{ name: 'Sales', value: 35 }, style={{
{ name: 'Operations', value: 22 }, backgroundColor: 'hsl(var(--popover) / 0.95)',
{ name: 'HR', value: 15 }, border: '1px solid hsl(var(--border))',
{ name: 'Finance', value: 18 }, borderRadius: '8px',
]; padding: '10px 14px',
} boxShadow: '0 2px 2px rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(8px)',
}}
>
<p
style={{
color: 'hsl(var(--popover-foreground))',
margin: 0,
fontSize: '14px',
fontWeight: 600,
}}
>
{payload[0].payload.name}
</p>
<p style={{ color: 'hsl(var(--muted-foreground))', margin: '4px 0 0 0', fontSize: '13px' }}>
Members:{' '}
<span style={{ fontWeight: 600, color: 'hsl(var(--popover-foreground))' }}>
{payload[0].value}
</span>
</p>
</div>
);
}
return null;
};
export function OrganizationDistributionChart({ export function OrganizationDistributionChart({
data, data,
loading, loading,
error, error,
}: OrganizationDistributionChartProps) { }: OrganizationDistributionChartProps) {
const chartData = data || generateMockData(); // Show empty chart if no data available
const rawData = data || [];
const hasData = rawData.length > 0 && rawData.some((d) => d.value > 0);
return ( return (
<ChartCard <ChartCard
@@ -55,42 +72,31 @@ export function OrganizationDistributionChart({
loading={loading} loading={loading}
error={error} error={error}
> >
<ResponsiveContainer width="100%" height={300}> {!hasData && !loading && !error ? (
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <div className="flex items-center justify-center h-[300px] text-muted-foreground">
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <p>No organization data available</p>
<XAxis </div>
dataKey="name" ) : (
stroke="hsl(var(--border))" <ResponsiveContainer width="100%" height={300}>
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }} <BarChart data={rawData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
tickLine={{ stroke: 'hsl(var(--border))' }} <CartesianGrid strokeDasharray="3 3" style={{ stroke: 'var(--muted)', opacity: 0.2 }} />
/> <XAxis
<YAxis dataKey="name"
stroke="hsl(var(--border))" angle={-45}
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }} textAnchor="end"
tickLine={{ stroke: 'hsl(var(--border))' }} style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }}
/> />
<Tooltip <YAxis style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }} />
contentStyle={{ <Tooltip content={<CustomTooltip />} />
backgroundColor: 'hsl(var(--popover))', <Bar
border: '1px solid hsl(var(--border))', dataKey="value"
borderRadius: '6px', fill={CHART_PALETTES.bar[0]}
color: 'hsl(var(--popover-foreground))', radius={[4, 4, 0, 0]}
}} activeBar={{ fill: CHART_PALETTES.bar[0] }}
labelStyle={{ color: 'hsl(var(--popover-foreground))' }} />
/> </BarChart>
<Legend </ResponsiveContainer>
wrapperStyle={{ )}
paddingTop: '20px',
}}
/>
<Bar
dataKey="value"
name="Total Members"
fill={CHART_PALETTES.bar[0]}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</ChartCard> </ChartCard>
); );
} }

View File

@@ -0,0 +1,117 @@
/**
* RegistrationActivityChart Component
* Displays user registration activity over time using an area chart
*/
'use client';
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { ChartCard } from './ChartCard';
import { CHART_PALETTES } from '@/lib/chart-colors';
export interface RegistrationActivityData {
date: string;
registrations: number;
}
interface RegistrationActivityChartProps {
data?: RegistrationActivityData[];
loading?: boolean;
error?: string | null;
}
// Custom tooltip with proper theme colors
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
return (
<div
style={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
padding: '8px 12px',
}}
>
<p
style={{
color: 'hsl(var(--popover-foreground))',
margin: 0,
fontSize: '13px',
fontWeight: 600,
}}
>
{payload[0].payload.date}
</p>
<p
style={{ color: 'hsl(var(--popover-foreground))', margin: '4px 0 0 0', fontSize: '12px' }}
>
New Registrations: {payload[0].value}
</p>
</div>
);
}
return null;
};
export function RegistrationActivityChart({
data,
loading,
error,
}: RegistrationActivityChartProps) {
// Show empty chart if no data available
const chartData = data || [];
const hasData = chartData.length > 0 && chartData.some((d) => d.registrations > 0);
return (
<ChartCard
title="User Registration Activity"
description="New user registrations over the last 14 days"
loading={loading}
error={error}
>
{!hasData && !loading && !error ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
<p>No registration data available</p>
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<defs>
<linearGradient id="colorRegistrations" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={CHART_PALETTES.area[0]} stopOpacity={0.8} />
<stop offset="95%" stopColor={CHART_PALETTES.area[0]} stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" style={{ stroke: 'var(--muted)', opacity: 0.2 }} />
<XAxis dataKey="date" style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }} />
<YAxis style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }} />
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{
paddingTop: '20px',
}}
/>
<Area
type="monotone"
dataKey="registrations"
name="New Registrations"
stroke={CHART_PALETTES.area[0]}
strokeWidth={2}
fillOpacity={1}
fill="url(#colorRegistrations)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -5,19 +5,18 @@
'use client'; 'use client';
import { ChartCard } from './ChartCard';
import { CHART_PALETTES } from '@/lib/chart-colors';
import { import {
LineChart, CartesianGrid,
Legend,
Line, Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts'; } from 'recharts';
import { ChartCard } from './ChartCard';
import { format, subDays } from 'date-fns';
import { CHART_PALETTES } from '@/lib/chart-colors';
export interface UserGrowthData { export interface UserGrowthData {
date: string; date: string;
@@ -25,32 +24,61 @@ export interface UserGrowthData {
active_users: number; active_users: number;
} }
interface UserGrowthChartProps { export interface UserGrowthChartProps {
data?: UserGrowthData[]; data?: UserGrowthData[];
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
} }
// Generate mock data for development/demo // Custom tooltip with proper theme colors
function generateMockData(): UserGrowthData[] { const CustomTooltip = ({ active, payload }: any) => {
const data: UserGrowthData[] = []; if (active && payload && payload.length) {
const today = new Date(); return (
<div
for (let i = 29; i >= 0; i--) { style={{
const date = subDays(today, i); backgroundColor: 'hsl(var(--popover))',
const baseUsers = 100 + i * 3; border: '1px solid hsl(var(--border))',
data.push({ borderRadius: '6px',
date: format(date, 'MMM d'), padding: '8px 12px',
total_users: baseUsers + Math.floor(Math.random() * 10), }}
active_users: Math.floor(baseUsers * 0.8) + Math.floor(Math.random() * 5), >
}); <p
style={{
color: 'hsl(var(--popover-foreground))',
margin: 0,
fontSize: '13px',
fontWeight: 600,
}}
>
{payload[0].payload.date}
</p>
<p
style={{ color: 'hsl(var(--popover-foreground))', margin: '4px 0 0 0', fontSize: '12px' }}
>
Total Users: {payload[0].value}
</p>
{payload[1] && (
<p
style={{
color: 'hsl(var(--popover-foreground))',
margin: '2px 0 0 0',
fontSize: '12px',
}}
>
Active Users: {payload[1].value}
</p>
)}
</div>
);
} }
return null;
return data; };
}
export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps) { export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps) {
const chartData = data || generateMockData(); // Show empty chart if no data available
const rawData = data || [];
const hasData =
rawData.length > 0 && rawData.some((d) => d.total_users > 0 || d.active_users > 0);
return ( return (
<ChartCard <ChartCard
@@ -59,54 +87,51 @@ export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps)
loading={loading} loading={loading}
error={error} error={error}
> >
<ResponsiveContainer width="100%" height={300}> {!hasData && !loading && !error ? (
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <div className="flex items-center justify-center h-[300px] text-muted-foreground">
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <p>No user growth data available</p>
<XAxis </div>
dataKey="date" ) : (
stroke="hsl(var(--border))" <ResponsiveContainer width="100%" height={300}>
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }} <LineChart data={rawData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
tickLine={{ stroke: 'hsl(var(--border))' }} <CartesianGrid strokeDasharray="3 3" style={{ stroke: 'var(--muted)', opacity: 0.2 }} />
/> <XAxis dataKey="date" style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }} />
<YAxis <YAxis
stroke="hsl(var(--border))" style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }}
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }} label={{
tickLine={{ stroke: 'hsl(var(--border))' }} value: 'Users',
/> angle: -90,
<Tooltip position: 'insideLeft',
contentStyle={{ style: { fill: 'var(--muted-foreground)', textAnchor: 'middle' },
backgroundColor: 'hsl(var(--popover))', }}
border: '1px solid hsl(var(--border))', />
borderRadius: '6px', <Tooltip content={<CustomTooltip />} />
color: 'hsl(var(--popover-foreground))', <Legend
}} wrapperStyle={{
labelStyle={{ color: 'hsl(var(--popover-foreground))' }} paddingTop: '20px',
/> }}
<Legend />
wrapperStyle={{ <Line
paddingTop: '20px', type="monotone"
}} dataKey="total_users"
/> name="Total Users"
<Line stroke={CHART_PALETTES.line[0]}
type="monotone" strokeWidth={2}
dataKey="total_users" dot={false}
name="Total Users" activeDot={{ r: 6 }}
stroke={CHART_PALETTES.line[0]} />
strokeWidth={2} <Line
dot={false} type="monotone"
activeDot={{ r: 6 }} dataKey="active_users"
/> name="Active Users"
<Line stroke={CHART_PALETTES.line[1]}
type="monotone" strokeWidth={2}
dataKey="active_users" dot={false}
name="Active Users" activeDot={{ r: 6 }}
stroke={CHART_PALETTES.line[1]} />
strokeWidth={2} </LineChart>
dot={false} </ResponsiveContainer>
activeDot={{ r: 6 }} )}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard> </ChartCard>
); );
} }

View File

@@ -21,16 +21,6 @@ interface UserStatusChartProps {
error?: string | null; error?: string | null;
} }
// Generate mock data for development/demo
function generateMockData(): UserStatusData[] {
return [
{ name: 'Active', value: 142, color: CHART_PALETTES.pie[0] },
{ name: 'Inactive', value: 28, color: CHART_PALETTES.pie[1] },
{ name: 'Pending', value: 15, color: CHART_PALETTES.pie[2] },
{ name: 'Suspended', value: 5, color: CHART_PALETTES.pie[3] },
];
}
// Custom label component to show percentages // Custom label component to show percentages
const renderLabel = (entry: { percent: number; name: string }) => { const renderLabel = (entry: { percent: number; name: string }) => {
const percent = (entry.percent * 100).toFixed(0); const percent = (entry.percent * 100).toFixed(0);
@@ -38,7 +28,9 @@ const renderLabel = (entry: { percent: number; name: string }) => {
}; };
export function UserStatusChart({ data, loading, error }: UserStatusChartProps) { export function UserStatusChart({ data, loading, error }: UserStatusChartProps) {
const rawData = data || generateMockData(); // Show empty chart if no data available
const rawData = data || [];
const hasData = rawData.length > 0 && rawData.some((d) => d.value > 0);
// Assign colors if missing // Assign colors if missing
const chartData = rawData.map((item, index) => ({ const chartData = rawData.map((item, index) => ({
@@ -53,41 +45,47 @@ export function UserStatusChart({ data, loading, error }: UserStatusChartProps)
loading={loading} loading={loading}
error={error} error={error}
> >
<ResponsiveContainer width="100%" height={300}> {!hasData && !loading && !error ? (
<PieChart> <div className="flex items-center justify-center h-[300px] text-muted-foreground">
<Pie <p>No user status data available</p>
data={chartData} </div>
cx="50%" ) : (
cy="50%" <ResponsiveContainer width="100%" height={300}>
labelLine={false} <PieChart>
label={renderLabel} <Pie
outerRadius={80} data={chartData}
fill="#8884d8" cx="50%"
dataKey="value" cy="50%"
> labelLine={false}
{chartData.map((entry, index) => ( label={renderLabel}
<Cell key={`cell-${index}`} fill={entry.color} /> outerRadius={80}
))} fill="#8884d8"
</Pie> dataKey="value"
<Tooltip >
contentStyle={{ {chartData.map((entry, index) => (
backgroundColor: 'hsl(var(--popover))', <Cell key={`cell-${index}`} fill={entry.color} />
border: '1px solid hsl(var(--border))', ))}
borderRadius: '6px', </Pie>
color: 'hsl(var(--popover-foreground))', <Tooltip
}} contentStyle={{
labelStyle={{ color: 'hsl(var(--popover-foreground))' }} backgroundColor: 'hsl(var(--popover))',
/> border: '1px solid hsl(var(--border))',
<Legend borderRadius: '6px',
verticalAlign="bottom" color: 'hsl(var(--popover-foreground))',
height={36} }}
wrapperStyle={{ labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
paddingTop: '20px', />
color: 'hsl(var(--foreground))', <Legend
}} verticalAlign="bottom"
/> height={36}
</PieChart> wrapperStyle={{
</ResponsiveContainer> paddingTop: '20px',
color: 'hsl(var(--foreground))',
}}
/>
</PieChart>
</ResponsiveContainer>
)}
</ChartCard> </ChartCard>
); );
} }

View File

@@ -6,8 +6,8 @@ export { ChartCard } from './ChartCard';
export { UserGrowthChart } from './UserGrowthChart'; export { UserGrowthChart } from './UserGrowthChart';
export type { UserGrowthData } from './UserGrowthChart'; export type { UserGrowthData } from './UserGrowthChart';
export { OrganizationDistributionChart } from './OrganizationDistributionChart'; export { OrganizationDistributionChart } from './OrganizationDistributionChart';
export type { OrganizationDistributionData } from './OrganizationDistributionChart'; export type { OrgDistributionData } from './OrganizationDistributionChart';
export { SessionActivityChart } from './SessionActivityChart'; export { RegistrationActivityChart } from './RegistrationActivityChart';
export type { SessionActivityData } from './SessionActivityChart'; export type { RegistrationActivityData } from './RegistrationActivityChart';
export { UserStatusChart } from './UserStatusChart'; export { UserStatusChart } from './UserStatusChart';
export type { UserStatusData } from './UserStatusChart'; export type { UserStatusData } from './UserStatusChart';

View File

@@ -0,0 +1,68 @@
/**
* Demo Mode Indicator
*
* Subtle floating badge to indicate demo mode is active
* Non-intrusive, doesn't cause layout shift
*/
'use client';
import { useState } from 'react';
import config from '@/config/app.config';
import { Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
export function DemoModeBanner() {
// Only show in demo mode
if (!config.demo.enabled) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<button
className="fixed bottom-4 right-4 z-50 inline-flex items-center gap-1.5 rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-lg transition-all hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label="Demo mode active"
>
<Sparkles className="h-3.5 w-3.5" />
<span>Demo Mode</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-80" side="top" align="end">
<div className="space-y-3">
<div className="space-y-1">
<h4 className="font-medium leading-none">Demo Mode Active</h4>
<p className="text-sm text-muted-foreground">
All API calls are mocked. No backend required.
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Demo Credentials (any password 12 chars works):
</p>
<div className="space-y-1.5">
<code className="block rounded bg-muted px-2 py-1.5 text-xs font-mono">
<span className="text-muted-foreground">user:</span>{' '}
<span className="font-semibold">{config.demo.credentials.user.email}</span>
<span className="text-muted-foreground mx-1">/</span>
<span className="font-semibold">{config.demo.credentials.user.password}</span>
</code>
<code className="block rounded bg-muted px-2 py-1.5 text-xs font-mono">
<span className="text-muted-foreground">admin:</span>{' '}
<span className="font-semibold">{config.demo.credentials.admin.email}</span>
<span className="text-muted-foreground mx-1">/</span>
<span className="font-semibold">{config.demo.credentials.admin.password}</span>
</code>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,5 @@
/**
* Demo components exports
*/
export { DemoModeBanner } from './DemoModeBanner';

View File

@@ -30,16 +30,18 @@ export function CodeBlock({ children, className, title }: CodeBlockProps) {
}; };
return ( return (
<div className="group relative my-6"> <div className="group relative my-6 rounded-lg border bg-[#282c34] text-slate-50">
{title && ( {title && (
<div className="flex items-center justify-between rounded-t-lg border border-b-0 bg-muted/50 px-4 py-2"> <div className="flex items-center justify-between rounded-t-lg border-b bg-muted/10 px-4 py-2">
<span className="text-xs font-medium text-muted-foreground">{title}</span> <span className="text-xs font-medium text-muted-foreground">{title}</span>
</div> </div>
)} )}
<div className={cn('relative', title && 'rounded-t-none')}> <div className={cn('relative', title && 'rounded-t-none')}>
<pre <pre
className={cn( className={cn(
'overflow-x-auto rounded-lg border bg-slate-950 p-4 font-mono text-sm', 'overflow-x-auto p-4 font-mono text-sm leading-relaxed',
// Force transparent background for hljs to avoid double background
'[&_.hljs]:!bg-transparent [&_code]:!bg-transparent',
title && 'rounded-t-none', title && 'rounded-t-none',
className className
)} )}
@@ -49,14 +51,14 @@ export function CodeBlock({ children, className, title }: CodeBlockProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="absolute right-2 top-2 h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100" className="absolute right-2 top-2 h-8 w-8 text-muted-foreground opacity-0 transition-all hover:bg-muted/20 hover:text-foreground group-hover:opacity-100"
onClick={handleCopy} onClick={handleCopy}
aria-label="Copy code" aria-label="Copy code"
> >
{copied ? ( {copied ? (
<Check className="h-4 w-4 text-green-500" /> <Check className="h-4 w-4 text-green-500" />
) : ( ) : (
<Copy className="h-4 w-4 text-muted-foreground" /> <Copy className="h-4 w-4" />
)} )}
</Button> </Button>
</div> </div>

View File

@@ -15,6 +15,18 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import 'highlight.js/styles/atom-one-dark.css'; import 'highlight.js/styles/atom-one-dark.css';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Info } from 'lucide-react';
interface MarkdownContentProps { interface MarkdownContentProps {
content: string; content: string;
@@ -23,19 +35,35 @@ interface MarkdownContentProps {
export function MarkdownContent({ content, className }: MarkdownContentProps) { export function MarkdownContent({ content, className }: MarkdownContentProps) {
return ( return (
<div className={cn('prose prose-neutral dark:prose-invert max-w-none', className)}> <div className={cn('max-w-none text-foreground', className)}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[ rehypePlugins={[
rehypeHighlight, rehypeHighlight,
rehypeSlug, rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }], [
rehypeAutolinkHeadings,
{
behavior: 'append',
properties: {
className: ['subtle-anchor'],
ariaHidden: true,
tabIndex: -1,
},
content: {
type: 'element',
tagName: 'span',
properties: { className: ['icon', 'icon-link'] },
children: [{ type: 'text', value: '#' }],
},
},
],
]} ]}
components={{ components={{
// Headings - improved spacing and visual hierarchy // Headings - improved spacing and visual hierarchy
h1: ({ children, ...props }) => ( h1: ({ children, ...props }) => (
<h1 <h1
className="scroll-mt-20 text-4xl font-bold tracking-tight mb-8 mt-12 first:mt-0 border-b-2 border-primary/20 pb-4 text-foreground" className="group scroll-mt-20 text-4xl font-bold tracking-tight mb-8 mt-12 first:mt-0 border-b-2 border-primary/20 pb-4 text-foreground flex items-center gap-2"
{...props} {...props}
> >
{children} {children}
@@ -43,7 +71,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
), ),
h2: ({ children, ...props }) => ( h2: ({ children, ...props }) => (
<h2 <h2
className="scroll-mt-20 text-3xl font-semibold tracking-tight mb-6 mt-12 first:mt-0 border-b border-border pb-3 text-foreground" className="group scroll-mt-20 text-3xl font-semibold tracking-tight mb-6 mt-12 first:mt-0 border-b border-border pb-3 text-foreground flex items-center gap-2"
{...props} {...props}
> >
{children} {children}
@@ -51,7 +79,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
), ),
h3: ({ children, ...props }) => ( h3: ({ children, ...props }) => (
<h3 <h3
className="scroll-mt-20 text-2xl font-semibold tracking-tight mb-4 mt-10 first:mt-0 text-foreground" className="group scroll-mt-20 text-2xl font-semibold tracking-tight mb-4 mt-10 first:mt-0 text-foreground flex items-center gap-2"
{...props} {...props}
> >
{children} {children}
@@ -59,12 +87,28 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
), ),
h4: ({ children, ...props }) => ( h4: ({ children, ...props }) => (
<h4 <h4
className="scroll-mt-20 text-xl font-semibold tracking-tight mb-3 mt-8 first:mt-0 text-foreground" className="group scroll-mt-20 text-xl font-semibold tracking-tight mb-3 mt-8 first:mt-0 text-foreground flex items-center gap-2"
{...props} {...props}
> >
{children} {children}
</h4> </h4>
), ),
h5: ({ children, ...props }) => (
<h5
className="group scroll-mt-20 text-lg font-semibold tracking-tight mb-3 mt-8 first:mt-0 text-foreground flex items-center gap-2"
{...props}
>
{children}
</h5>
),
h6: ({ children, ...props }) => (
<h6
className="group scroll-mt-20 text-base font-semibold tracking-tight mb-3 mt-8 first:mt-0 text-foreground flex items-center gap-2"
{...props}
>
{children}
</h6>
),
// Paragraphs and text - improved readability // Paragraphs and text - improved readability
p: ({ children, ...props }) => ( p: ({ children, ...props }) => (
@@ -84,15 +128,32 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
), ),
// Links - more prominent with better hover state // Links - more prominent with better hover state
a: ({ children, href, ...props }) => ( a: ({ children, href, className, ...props }) => {
<a // Check if this is an anchor link generated by rehype-autolink-headings
href={href} const isAnchor = className?.includes('subtle-anchor');
className="font-medium text-primary underline decoration-primary/30 underline-offset-4 hover:decoration-primary/60 hover:text-primary/90 transition-all"
{...props} if (isAnchor) {
> return (
{children} <a
</a> href={href}
), className={cn("opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary ml-2 no-underline", className)}
{...props}
>
{children}
</a>
);
}
return (
<a
href={href}
className={cn("font-medium text-primary underline decoration-primary/30 underline-offset-4 hover:decoration-primary/60 hover:text-primary/90 transition-all", className)}
{...props}
>
{children}
</a>
);
},
// Lists - improved spacing and hierarchy // Lists - improved spacing and hierarchy
ul: ({ children, ...props }) => ( ul: ({ children, ...props }) => (
@@ -127,12 +188,12 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
}) => { }) => {
if (inline) { if (inline) {
return ( return (
<code <Badge
className="relative rounded-md bg-primary/10 border border-primary/20 px-1.5 py-0.5 font-mono text-sm font-medium text-primary" variant="secondary"
{...props} className="font-mono text-sm font-medium px-1.5 py-0.5 h-auto rounded-md"
> >
{children} {children}
</code> </Badge>
); );
} }
return ( return (
@@ -143,58 +204,42 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
}, },
pre: ({ children, ...props }) => <CodeBlock {...props}>{children}</CodeBlock>, pre: ({ children, ...props }) => <CodeBlock {...props}>{children}</CodeBlock>,
// Blockquotes - enhanced callout styling // Blockquotes - enhanced callout styling using Alert
blockquote: ({ children, ...props }) => ( blockquote: ({ children }) => (
<blockquote <Alert className="my-8 border-l-4 border-l-primary/50 bg-primary/5">
className="my-8 border-l-4 border-primary/50 bg-primary/5 pl-6 pr-4 py-4 italic text-foreground/80 rounded-r-lg" <Info className="h-4 w-4" />
{...props} <AlertDescription className="italic text-foreground/80 ml-2">
> {children}
{children} </AlertDescription>
</blockquote> </Alert>
), ),
// Tables - improved styling with better borders and hover states // Tables - improved styling with better borders and hover states
table: ({ children, ...props }) => ( table: ({ children, ...props }) => (
<div className="my-8 w-full overflow-x-auto rounded-lg border"> <div className="my-8 w-full overflow-x-auto rounded-lg border">
<table className="w-full border-collapse text-sm" {...props}> <Table {...props}>{children}</Table>
{children}
</table>
</div> </div>
), ),
thead: ({ children, ...props }) => ( thead: ({ children, ...props }) => (
<thead className="bg-muted/80 border-b-2 border-border" {...props}> <TableHeader className="bg-muted/80" {...props}>
{children} {children}
</thead> </TableHeader>
),
tbody: ({ children, ...props }) => (
<tbody className="divide-y divide-border" {...props}>
{children}
</tbody>
),
tr: ({ children, ...props }) => (
<tr className="transition-colors hover:bg-muted/40" {...props}>
{children}
</tr>
), ),
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
th: ({ children, ...props }) => ( th: ({ children, ...props }) => (
<th <TableHead className="font-semibold text-foreground" {...props}>
className="px-5 py-3.5 text-left font-semibold text-foreground [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children} {children}
</th> </TableHead>
), ),
td: ({ children, ...props }) => ( td: ({ children, ...props }) => (
<td <TableCell className="text-foreground/80" {...props}>
className="px-5 py-3.5 text-foreground/80 [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children} {children}
</td> </TableCell>
), ),
// Horizontal rule - more prominent // Horizontal rule - more prominent
hr: ({ ...props }) => <hr className="my-12 border-t-2 border-border/50" {...props} />, hr: ({ ...props }) => <Separator className="my-12" {...props} />,
// Images - optimized with Next.js Image component // Images - optimized with Next.js Image component
img: ({ src, alt }) => { img: ({ src, alt }) => {

View File

@@ -27,8 +27,8 @@ export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProp
const [copiedRegular, setCopiedRegular] = useState(false); const [copiedRegular, setCopiedRegular] = useState(false);
const [copiedAdmin, setCopiedAdmin] = useState(false); const [copiedAdmin, setCopiedAdmin] = useState(false);
const regularCredentials = 'demo@example.com\nDemo123!'; const regularCredentials = 'demo@example.com\nDemoPass1234!';
const adminCredentials = 'admin@example.com\nAdmin123!'; const adminCredentials = 'admin@example.com\nAdminPass1234!';
const copyToClipboard = async (text: string, type: 'regular' | 'admin') => { const copyToClipboard = async (text: string, type: 'regular' | 'admin') => {
try { try {
@@ -83,7 +83,7 @@ export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProp
</p> </p>
<p className="flex items-center gap-2"> <p className="flex items-center gap-2">
<span className="text-xs font-sans text-muted-foreground/70">Password:</span> <span className="text-xs font-sans text-muted-foreground/70">Password:</span>
<span className="text-foreground">Demo123!</span> <span className="text-foreground">DemoPass1234!</span>
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -123,7 +123,7 @@ export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProp
</p> </p>
<p className="flex items-center gap-2"> <p className="flex items-center gap-2">
<span className="text-xs font-sans text-muted-foreground/70">Password:</span> <span className="text-xs font-sans text-muted-foreground/70">Password:</span>
<span className="text-foreground">Admin123!</span> <span className="text-foreground">AdminPass1234!</span>
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -141,12 +141,12 @@ export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProp
<DialogFooter> <DialogFooter>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full">
<Button asChild variant="default" className="w-full"> <Button asChild variant="default" className="w-full">
<Link href="/login?email=demo@example.com&password=Demo123!" onClick={onClose}> <Link href="/login?email=demo@example.com&password=DemoPass1234!" onClick={onClose}>
Login as User Login as User
</Link> </Link>
</Button> </Button>
<Button asChild variant="default" className="w-full"> <Button asChild variant="default" className="w-full">
<Link href="/login?email=admin@example.com&password=Admin123!" onClick={onClose}> <Link href="/login?email=admin@example.com&password=AdminPass1234!" onClick={onClose}>
Login as Admin Login as Admin
</Link> </Link>
</Button> </Button>

View File

@@ -0,0 +1,83 @@
/**
* MSW Provider Component
*
* Initializes Mock Service Worker for demo mode
* This component handles MSW setup in a Next.js-compatible way
*
* IMPORTANT: This is a client component that runs in the browser only
* SAFE: Will not interfere with tests or development mode
*/
'use client';
import { useEffect, useState } from 'react';
/**
* MSW initialization promise (cached)
* Ensures MSW is only initialized once
*/
let mswInitPromise: Promise<void> | null = null;
function initMSW(): Promise<void> {
// Return cached promise if already initialized
if (mswInitPromise) {
return mswInitPromise;
}
// Check if MSW should start
const shouldStart =
typeof window !== 'undefined' &&
process.env.NODE_ENV !== 'test' &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
!(window as any).__PLAYWRIGHT_TEST__ &&
process.env.NEXT_PUBLIC_DEMO_MODE === 'true';
if (!shouldStart) {
// Return resolved promise, no-op
mswInitPromise = Promise.resolve();
return mswInitPromise;
}
// Initialize MSW (lazy import to avoid loading in non-demo mode)
mswInitPromise = import('@/mocks')
.then(({ initMocks }) => initMocks())
.catch((error) => {
console.error('[MSW] Failed to initialize:', error);
// Reset promise so it can be retried
mswInitPromise = null;
throw error;
});
return mswInitPromise;
}
/**
* MSW Provider Component
*
* Wraps children and ensures MSW is initialized before rendering
* Uses React 19's `use()` hook for suspense-compatible async initialization
*/
export function MSWProvider({ children }: { children: React.ReactNode }) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// Initialize MSW on mount
initMSW()
.then(() => {
setIsReady(true);
})
.catch((error) => {
console.error('[MSW] Initialization failed:', error);
// Still render children even if MSW fails (graceful degradation)
setIsReady(true);
});
}, []);
// Wait for MSW to be ready before rendering children
// This prevents race conditions where API calls happen before MSW is ready
if (!isReady) {
return null; // or a loading spinner if you prefer
}
return <>{children}</>;
}

View File

@@ -71,6 +71,7 @@ const ENV = {
ENABLE_REGISTRATION: process.env.NEXT_PUBLIC_ENABLE_REGISTRATION, ENABLE_REGISTRATION: process.env.NEXT_PUBLIC_ENABLE_REGISTRATION,
ENABLE_SESSION_MANAGEMENT: process.env.NEXT_PUBLIC_ENABLE_SESSION_MANAGEMENT, ENABLE_SESSION_MANAGEMENT: process.env.NEXT_PUBLIC_ENABLE_SESSION_MANAGEMENT,
DEBUG_API: process.env.NEXT_PUBLIC_DEBUG_API, DEBUG_API: process.env.NEXT_PUBLIC_DEBUG_API,
DEMO_MODE: process.env.NEXT_PUBLIC_DEMO_MODE,
NODE_ENV: process.env.NODE_ENV || 'development', NODE_ENV: process.env.NODE_ENV || 'development',
} as const; } as const;
@@ -118,6 +119,16 @@ export const config = {
api: parseBool(ENV.DEBUG_API, false) && ENV.NODE_ENV === 'development', api: parseBool(ENV.DEBUG_API, false) && ENV.NODE_ENV === 'development',
}, },
demo: {
// Enable demo mode (uses Mock Service Worker instead of real backend)
enabled: parseBool(ENV.DEMO_MODE, false),
// Demo credentials
credentials: {
user: { email: 'demo@example.com', password: 'DemoPass1234!' },
admin: { email: 'admin@example.com', password: 'AdminPass1234!' },
},
},
env: { env: {
isDevelopment: ENV.NODE_ENV === 'development', isDevelopment: ENV.NODE_ENV === 'development',
isProduction: ENV.NODE_ENV === 'production', isProduction: ENV.NODE_ENV === 'production',

View File

@@ -2,44 +2,57 @@ import { apiClient } from './client';
import type { Options } from './generated/sdk.gen'; import type { Options } from './generated/sdk.gen';
export interface UserGrowthData { export interface UserGrowthData {
date: string; date: string;
total_users: number; total_users: number;
active_users: number; active_users: number;
} }
export interface OrgDistributionData { export interface OrgDistributionData {
name: string; name: string;
value: number; value: number;
}
export interface RegistrationActivityData {
date: string;
registrations: number;
} }
export interface UserStatusData { export interface UserStatusData {
name: string; name: string;
value: number; value: number;
} }
export interface AdminStatsResponse { export interface AdminStatsResponse {
user_growth: UserGrowthData[]; user_growth: UserGrowthData[];
organization_distribution: OrgDistributionData[]; organization_distribution: OrgDistributionData[];
user_status: UserStatusData[]; registration_activity: RegistrationActivityData[];
user_status: UserStatusData[];
} }
export type AdminStatsData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/admin/stats';
};
/** /**
* Admin: Get Dashboard Stats * Admin: Get Dashboard Stats
* *
* Get aggregated statistics for the admin dashboard (admin only) * Get aggregated statistics for the admin dashboard (admin only)
*/ */
export const getAdminStats = <ThrowOnError extends boolean = false>( export const getAdminStats = <ThrowOnError extends boolean = false>(
options?: Options<any, ThrowOnError> options?: Options<AdminStatsData, ThrowOnError>
) => { ) => {
return (options?.client ?? apiClient).get<AdminStatsResponse, any, ThrowOnError>({ return (options?.client ?? apiClient).get<AdminStatsResponse, unknown, ThrowOnError>({
responseType: 'json', responseType: 'json',
security: [ security: [
{ {
scheme: 'bearer', scheme: 'bearer',
type: 'http', type: 'http',
}, },
], ],
url: '/api/v1/admin/stats', url: '/api/v1/admin/stats',
...options, ...options,
}); });
}; };

View File

@@ -3,7 +3,7 @@
import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HealthCheckData, HealthCheckResponses, ListMySessionsData, ListMySessionsResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen'; import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetStatsData, AdminGetStatsResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HealthCheckData, HealthCheckResponses, ListMySessionsData, ListMySessionsResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
@@ -484,6 +484,25 @@ export const cleanupExpiredSessions = <ThrowOnError extends boolean = false>(opt
}); });
}; };
/**
* Admin: Get Dashboard Stats
*
* Get aggregated statistics for the admin dashboard (admin only)
*/
export const adminGetStats = <ThrowOnError extends boolean = false>(options?: Options<AdminGetStatsData, ThrowOnError>) => {
return (options?.client ?? client).get<AdminGetStatsResponses, unknown, ThrowOnError>({
responseType: 'json',
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/api/v1/admin/stats',
...options
});
};
/** /**
* Admin: List All Users * Admin: List All Users
* *

View File

@@ -93,6 +93,28 @@ export type AdminSessionResponse = {
is_active: boolean; is_active: boolean;
}; };
/**
* AdminStatsResponse
*/
export type AdminStatsResponse = {
/**
* User Growth
*/
user_growth: Array<UserGrowthData>;
/**
* Organization Distribution
*/
organization_distribution: Array<OrgDistributionData>;
/**
* Registration Activity
*/
registration_activity: Array<RegistrationActivityData>;
/**
* User Status
*/
user_status: Array<UserStatusData>;
};
/** /**
* Body_login_oauth * Body_login_oauth
*/ */
@@ -234,6 +256,20 @@ export type MessageResponse = {
message: string; message: string;
}; };
/**
* OrgDistributionData
*/
export type OrgDistributionData = {
/**
* Name
*/
name: string;
/**
* Value
*/
value: number;
};
/** /**
* OrganizationCreate * OrganizationCreate
* *
@@ -550,6 +586,20 @@ export type RefreshTokenRequest = {
refresh_token: string; refresh_token: string;
}; };
/**
* RegistrationActivityData
*/
export type RegistrationActivityData = {
/**
* Date
*/
date: string;
/**
* Registrations
*/
registrations: number;
};
/** /**
* SessionListResponse * SessionListResponse
* *
@@ -682,6 +732,28 @@ export type UserCreate = {
* Is Superuser * Is Superuser
*/ */
is_superuser?: boolean; is_superuser?: boolean;
/**
* Is Active
*/
is_active?: boolean;
};
/**
* UserGrowthData
*/
export type UserGrowthData = {
/**
* Date
*/
date: string;
/**
* Total Users
*/
total_users: number;
/**
* Active Users
*/
active_users: number;
}; };
/** /**
@@ -724,6 +796,24 @@ export type UserResponse = {
* Updated At * Updated At
*/ */
updated_at?: string | null; updated_at?: string | null;
/**
* Locale
*/
locale?: string | null;
};
/**
* UserStatusData
*/
export type UserStatusData = {
/**
* Name
*/
name: string;
/**
* Value
*/
value: number;
}; };
/** /**
@@ -752,6 +842,12 @@ export type UserUpdate = {
preferences?: { preferences?: {
[key: string]: unknown; [key: string]: unknown;
} | null; } | null;
/**
* Locale
*
* User's preferred locale (BCP 47 format: en, it, en-US, it-IT)
*/
locale?: string | null;
/** /**
* Is Active * Is Active
*/ */
@@ -1270,6 +1366,22 @@ export type CleanupExpiredSessionsResponses = {
export type CleanupExpiredSessionsResponse = CleanupExpiredSessionsResponses[keyof CleanupExpiredSessionsResponses]; export type CleanupExpiredSessionsResponse = CleanupExpiredSessionsResponses[keyof CleanupExpiredSessionsResponses];
export type AdminGetStatsData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/admin/stats';
};
export type AdminGetStatsResponses = {
/**
* Successful Response
*/
200: AdminStatsResponse;
};
export type AdminGetStatsResponse = AdminGetStatsResponses[keyof AdminGetStatsResponses];
export type AdminListUsersData = { export type AdminListUsersData = {
body?: never; body?: never;
path?: never; path?: never;

View File

@@ -1,39 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import createMiddleware from 'next-intl/middleware';
import { routing } from './lib/i18n/routing';
// Create next-intl middleware for locale handling
const intlMiddleware = createMiddleware(routing);
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Block access to /dev routes in production (handles both /dev and /[locale]/dev)
// Match: /dev, /en/dev, /it/dev, etc.
if (pathname === '/dev' || pathname.match(/^\/[a-z]{2}\/dev($|\/)/)) {
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction) {
// Return 404 in production
return new NextResponse(null, { status: 404 });
}
}
// Handle locale routing with next-intl
return intlMiddleware(request);
}
export const config = {
// Match all pathnames except for:
// - API routes (/api/*)
// - Static files (/_next/*, /favicon.ico, etc.)
// - Files in public folder (images, fonts, etc.)
matcher: [
// Match all pathnames except for
'/((?!api|_next|_vercel|.*\\..*).*)',
// However, match all pathnames within /api/
// that don't end with a file extension
'/api/(.*)',
],
};

View File

@@ -0,0 +1,94 @@
/**
* MSW Browser Setup
*
* Configures Mock Service Worker for browser environment.
* This intercepts network requests at the network layer, making it transparent to the app.
*/
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
/**
* Create MSW worker with all handlers
* This worker intercepts fetch/XHR requests in the browser
*/
export const worker = setupWorker(...handlers);
/**
* Check if MSW should be started
* Only runs when ALL conditions are met:
* - In browser (not SSR)
* - NOT in Jest test environment
* - NOT in Playwright E2E tests
* - Demo mode explicitly enabled
*/
function shouldStartMSW(): boolean {
if (typeof window === 'undefined') {
return false; // SSR, skip
}
// Skip Jest unit tests
if (process.env.NODE_ENV === 'test') {
return false;
}
// Skip Playwright E2E tests (uses your existing __PLAYWRIGHT_TEST__ flag)
if ((window as any).__PLAYWRIGHT_TEST__) {
return false;
}
// Only start if demo mode is explicitly enabled
return process.env.NEXT_PUBLIC_DEMO_MODE === 'true';
}
/**
* Start MSW for demo mode
* SAFE: Will not interfere with unit tests or E2E tests
*/
export async function startMockServiceWorker() {
if (!shouldStartMSW()) {
// Silently skip - this is normal for dev/test environments
return;
}
try {
await worker.start({
// Only intercept requests to the API, bypass everything else (Next.js routes, etc.)
onUnhandledRequest(request, print) {
// Ignore Next.js internal requests
const url = new URL(request.url);
if (
url.pathname.startsWith('/_next') ||
url.pathname.startsWith('/__next') ||
url.pathname.startsWith('/api/') ||
url.pathname === '/favicon.ico' ||
url.pathname.match(/\.(js|css|png|jpg|svg|woff|woff2)$/)
) {
return;
}
// Ignore locale routes (Next.js i18n)
if (url.pathname === '/en' || url.pathname === '/it' || url.pathname.startsWith('/en/') || url.pathname.startsWith('/it/')) {
return;
}
// Only warn about actual API requests we might have missed
if (url.hostname.includes('localhost') && url.port === '8000') {
print.warning();
}
},
serviceWorker: {
url: '/mockServiceWorker.js',
},
});
console.log('%c[MSW] Demo Mode Active', 'color: #00bfa5; font-weight: bold;');
console.log('[MSW] All API calls to localhost:8000 are mocked');
console.log('[MSW] Demo credentials:');
console.log(' Regular user: demo@example.com / DemoPass123');
console.log(' Admin user: admin@example.com / AdminPass123');
} catch (error) {
console.error('[MSW] Failed to start Mock Service Worker:', error);
console.error('[MSW] Demo mode will not work correctly');
}
}

View File

@@ -0,0 +1,166 @@
/**
* Mock Organization Data
*
* Sample organizations for demo mode, matching OpenAPI schemas
*/
import type { OrganizationResponse, OrganizationMemberResponse } from '@/lib/api/client';
/**
* Sample organizations
*/
export const sampleOrganizations: OrganizationResponse[] = [
{
id: 'org-1',
name: 'Acme Corporation',
slug: 'acme-corp',
description: 'Leading provider of innovative solutions',
is_active: true,
settings: {
theme: 'light',
notifications: true,
},
member_count: 12,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-15T10:00:00Z',
},
{
id: 'org-2',
name: 'Tech Innovators',
slug: 'tech-innovators',
description: 'Pioneering the future of technology',
is_active: true,
settings: {
theme: 'dark',
notifications: false,
},
member_count: 8,
created_at: '2024-02-01T00:00:00Z',
updated_at: '2024-03-10T14:30:00Z',
},
{
id: 'org-3',
name: 'Global Solutions Inc',
slug: 'global-solutions',
description: 'Worldwide consulting and services',
is_active: true,
settings: {},
member_count: 25,
created_at: '2023-12-01T00:00:00Z',
updated_at: '2024-01-20T09:00:00Z',
},
{
id: 'org-4',
name: 'Startup Ventures',
slug: 'startup-ventures',
description: 'Fast-growing startup company',
is_active: true,
settings: {
theme: 'auto',
},
member_count: 5,
created_at: '2024-03-15T00:00:00Z',
updated_at: '2024-03-20T11:00:00Z',
},
{
id: 'org-5',
name: 'Inactive Corp',
slug: 'inactive-corp',
description: 'Suspended organization',
is_active: false,
settings: {},
member_count: 3,
created_at: '2023-11-01T00:00:00Z',
updated_at: '2024-06-01T00:00:00Z',
},
];
/**
* Sample organization members
* Maps organization ID to its members
*/
export const organizationMembers: Record<string, OrganizationMemberResponse[]> = {
'org-1': [
{
// @ts-ignore
id: 'member-1',
user_id: 'demo-user-id-1',
user_email: 'demo@example.com',
user_first_name: 'Demo',
user_last_name: 'User',
role: 'member',
joined_at: '2024-01-15T10:00:00Z',
},
{
// @ts-ignore
id: 'member-2',
user_id: 'demo-admin-id-1',
user_email: 'admin@example.com',
user_first_name: 'Admin',
user_last_name: 'Demo',
role: 'owner',
joined_at: '2024-01-01T00:00:00Z',
},
{
// @ts-ignore
id: 'member-3',
user_id: 'user-3',
user_email: 'john.doe@example.com',
user_first_name: 'John',
user_last_name: 'Doe',
role: 'admin',
joined_at: '2024-02-01T12:00:00Z',
},
],
'org-2': [
{
// @ts-ignore
id: 'member-4',
user_id: 'demo-user-id-1',
user_email: 'demo@example.com',
user_first_name: 'Demo',
user_last_name: 'User',
role: 'owner',
joined_at: '2024-02-01T00:00:00Z',
},
{
// @ts-ignore
id: 'member-5',
user_id: 'user-4',
user_email: 'jane.smith@example.com',
user_first_name: 'Jane',
user_last_name: 'Smith',
role: 'member',
joined_at: '2024-03-10T08:30:00Z',
},
],
'org-3': [
{
// @ts-ignore
id: 'member-6',
user_id: 'user-4',
user_email: 'jane.smith@example.com',
user_first_name: 'Jane',
user_last_name: 'Smith',
role: 'owner',
joined_at: '2023-12-01T00:00:00Z',
},
],
};
/**
* Get organizations for a specific user
*/
export function getUserOrganizations(userId: string): OrganizationResponse[] {
return sampleOrganizations.filter((org) => {
const members = organizationMembers[org.id] || [];
return members.some((m) => m.user_id === userId);
});
}
/**
* Get members for a specific organization
*/
export function getOrganizationMembersList(orgId: string): OrganizationMemberResponse[] {
return organizationMembers[orgId] || [];
}

View File

@@ -0,0 +1,91 @@
/**
* Mock Admin Statistics Data
*
* Sample statistics for demo mode admin dashboard
*/
import type {
AdminStatsResponse,
UserGrowthData,
OrgDistributionData,
RegistrationActivityData,
UserStatusData,
} from '@/lib/api/client';
/**
* Generate user growth data for the last 30 days
*/
function generateUserGrowthData(): UserGrowthData[] {
const data: UserGrowthData[] = [];
const today = new Date();
for (let i = 29; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
// Simulate growth with some randomness
const baseTotal = 50 + Math.floor((29 - i) * 1.5);
const baseActive = Math.floor(baseTotal * (0.7 + Math.random() * 0.2));
data.push({
date: date.toISOString().split('T')[0],
total_users: baseTotal,
active_users: baseActive,
});
}
return data;
}
/**
* Organization distribution data
*/
const orgDistribution: OrgDistributionData[] = [
{ name: 'Acme Corporation', value: 12 },
{ name: 'Tech Innovators', value: 8 },
{ name: 'Global Solutions Inc', value: 25 },
{ name: 'Startup Ventures', value: 5 },
{ name: 'Inactive Corp', value: 3 },
];
/**
* Registration activity data (last 7 days)
*/
function generateRegistrationActivity(): RegistrationActivityData[] {
const data: RegistrationActivityData[] = [];
const today = new Date();
for (let i = 6; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
// Simulate registration activity with some randomness
const count = Math.floor(Math.random() * 5) + 1; // 1-5 registrations per day
data.push({
date: date.toISOString().split('T')[0],
// @ts-ignore
count,
});
}
return data;
}
/**
* User status distribution
*/
const userStatus: UserStatusData[] = [
{ name: 'Active', value: 89 },
{ name: 'Inactive', value: 11 },
];
/**
* Complete admin stats response
*/
export const adminStats: AdminStatsResponse = {
user_growth: generateUserGrowthData(),
organization_distribution: orgDistribution,
registration_activity: generateRegistrationActivity(),
user_status: userStatus,
};

View File

@@ -0,0 +1,134 @@
/**
* Mock User Data
*
* Sample users for demo mode, matching OpenAPI UserResponse schema
*/
import type { UserResponse } from '@/lib/api/client';
/**
* Demo user (regular user)
* Credentials: demo@example.com / DemoPass1234!
*/
export const demoUser: UserResponse = {
id: 'demo-user-id-1',
email: 'demo@example.com',
first_name: 'Demo',
last_name: 'User',
phone_number: null,
is_active: true,
is_superuser: false,
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-20T15:30:00Z',
};
/**
* Demo admin user (superuser)
* Credentials: admin@example.com / AdminPass1234!
*/
export const demoAdmin: UserResponse = {
id: 'demo-admin-id-1',
email: 'admin@example.com',
first_name: 'Admin',
last_name: 'Demo',
phone_number: '+1-555-0100',
is_active: true,
is_superuser: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-24T10:00:00Z',
};
/**
* Additional sample users for admin panel
*/
export const sampleUsers: UserResponse[] = [
demoUser,
demoAdmin,
{
id: 'user-3',
email: 'john.doe@example.com',
first_name: 'John',
last_name: 'Doe',
phone_number: '+1-555-0101',
is_active: true,
is_superuser: false,
created_at: '2024-02-01T12:00:00Z',
updated_at: '2024-02-05T14:30:00Z',
},
{
id: 'user-4',
email: 'jane.smith@example.com',
first_name: 'Jane',
last_name: 'Smith',
phone_number: null,
is_active: true,
is_superuser: false,
created_at: '2024-03-10T08:30:00Z',
updated_at: '2024-03-15T11:00:00Z',
},
{
id: 'user-5',
email: 'inactive@example.com',
first_name: 'Inactive',
last_name: 'User',
phone_number: null,
is_active: false,
is_superuser: false,
created_at: '2024-01-20T14:00:00Z',
updated_at: '2024-06-01T09:00:00Z',
},
];
/**
* In-memory store for current user state
* This simulates session state and allows profile updates
*/
export let currentUser: UserResponse | null = null;
/**
* Set the current logged-in user
*/
export function setCurrentUser(user: UserResponse | null) {
currentUser = user;
}
/**
* Update current user profile
*/
export function updateCurrentUser(updates: Partial<UserResponse>) {
if (currentUser) {
currentUser = {
...currentUser,
...updates,
updated_at: new Date().toISOString(),
};
}
}
/**
* Validate demo credentials
* In demo mode, we're lenient with passwords to improve UX
*/
export function validateCredentials(email: string, password: string): UserResponse | null {
// Demo user - accept documented password or any password >= 12 chars
if (email === 'demo@example.com') {
if (password === 'DemoPass1234!' || password.length >= 12) {
return demoUser;
}
}
// Demo admin - accept documented password or any password >= 12 chars
if (email === 'admin@example.com') {
if (password === 'AdminPass1234!' || password.length >= 12) {
return demoAdmin;
}
}
// Sample users - accept any valid password (it's a demo!)
const user = sampleUsers.find((u) => u.email === email);
if (user && password.length >= 12) {
return user;
}
return null;
}

View File

@@ -0,0 +1,583 @@
/**
* Auto-generated MSW Handlers
*
* ⚠️ DO NOT EDIT THIS FILE MANUALLY
*
* This file is automatically generated from the OpenAPI specification.
* To regenerate: npm run generate:api
*
* For custom handler behavior, use src/mocks/handlers/overrides.ts
*
* Generated: 2025-11-24T17:58:16.943Z
*/
import { http, HttpResponse, delay } from 'msw';
import {
validateCredentials,
setCurrentUser,
updateCurrentUser,
currentUser,
sampleUsers,
} from '../data/users';
import {
sampleOrganizations,
getUserOrganizations,
getOrganizationMembersList,
} from '../data/organizations';
import { adminStats } from '../data/stats';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
const NETWORK_DELAY = 300; // ms - simulate realistic network delay
/**
* Auto-generated request handlers
* Covers all endpoints defined in OpenAPI spec
*/
export const generatedHandlers = [
/**
* Register User
*/
http.post(`${API_BASE_URL}/api/v1/auth/register`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as any;
const newUser = {
id: `new-user-${Date.now()}`,
email: body.email,
first_name: body.first_name,
last_name: body.last_name || null,
phone_number: body.phone_number || null,
is_active: true,
is_superuser: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_login: null,
organization_count: 0,
};
setCurrentUser(newUser);
return HttpResponse.json({
user: newUser,
access_token: `demo-access-${Date.now()}`,
refresh_token: `demo-refresh-${Date.now()}`,
token_type: 'bearer',
expires_in: 900,
});
}),
/**
* Login
*/
http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as any;
const user = validateCredentials(body.email, body.password);
if (!user) {
return HttpResponse.json(
{ detail: 'Incorrect email or password' },
{ status: 401 }
);
}
const accessToken = `demo-access-${user.id}-${Date.now()}`;
const refreshToken = `demo-refresh-${user.id}-${Date.now()}`;
setCurrentUser(user);
return HttpResponse.json({
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'bearer',
expires_in: 900,
user: user,
});
}),
/**
* Refresh Token
*/
http.post(`${API_BASE_URL}/api/v1/auth/refresh`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
access_token: `demo-access-refreshed-${Date.now()}`,
refresh_token: `demo-refresh-refreshed-${Date.now()}`,
token_type: 'bearer',
expires_in: 900,
});
}),
/**
* Request Password Reset
*/
http.post(`${API_BASE_URL}/api/v1/auth/password-reset/request`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful',
});
}),
/**
* Confirm Password Reset
*/
http.post(`${API_BASE_URL}/api/v1/auth/password-reset/confirm`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful',
});
}),
/**
* Logout from Current Device
*/
http.post(`${API_BASE_URL}/api/v1/auth/logout`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful',
});
}),
/**
* Logout from All Devices
*/
http.post(`${API_BASE_URL}/api/v1/auth/logout-all`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful',
});
}),
/**
* List Users
*/
http.get(`${API_BASE_URL}/api/v1/users`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Get Current User
*/
http.get(`${API_BASE_URL}/api/v1/users/me`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!currentUser) {
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
}
return HttpResponse.json(currentUser);
}),
/**
* Update Current User
*/
http.patch(`${API_BASE_URL}/api/v1/users/me`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!currentUser) {
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
}
const body = (await request.json()) as any;
updateCurrentUser(body);
return HttpResponse.json(currentUser);
}),
/**
* Get User by ID
*/
http.get(`${API_BASE_URL}/api/v1/users/:user_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Update User
*/
http.patch(`${API_BASE_URL}/api/v1/users/:user_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Delete User
*/
http.delete(`${API_BASE_URL}/api/v1/users/:user_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Change Current User Password
*/
http.patch(`${API_BASE_URL}/api/v1/users/me/password`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* List My Active Sessions
*/
http.get(`${API_BASE_URL}/api/v1/sessions/me`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Revoke Specific Session
*/
http.delete(`${API_BASE_URL}/api/v1/sessions/:session_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Cleanup Expired Sessions
*/
http.delete(`${API_BASE_URL}/api/v1/sessions/me/expired`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Get Dashboard Stats
*/
http.get(`${API_BASE_URL}/api/v1/admin/stats`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!currentUser?.is_superuser) {
return HttpResponse.json({ detail: 'Admin access required' }, { status: 403 });
}
return HttpResponse.json(adminStats);
}),
/**
* Admin: List All Users
*/
http.get(`${API_BASE_URL}/api/v1/admin/users`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!currentUser?.is_superuser) {
return HttpResponse.json({ detail: 'Admin access required' }, { status: 403 });
}
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedUsers = sampleUsers.slice(start, end);
return HttpResponse.json({
data: paginatedUsers,
pagination: {
total: sampleUsers.length,
page,
page_size: pageSize,
total_pages: Math.ceil(sampleUsers.length / pageSize),
has_next: end < sampleUsers.length,
has_prev: page > 1,
},
});
}),
/**
* Admin: Create User
*/
http.post(`${API_BASE_URL}/api/v1/admin/users`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Get User Details
*/
http.get(`${API_BASE_URL}/api/v1/admin/users/:user_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Update User
*/
http.put(`${API_BASE_URL}/api/v1/admin/users/:user_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Delete User
*/
http.delete(`${API_BASE_URL}/api/v1/admin/users/:user_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Activate User
*/
http.post(`${API_BASE_URL}/api/v1/admin/users/:user_id/activate`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Deactivate User
*/
http.post(`${API_BASE_URL}/api/v1/admin/users/:user_id/deactivate`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Bulk User Action
*/
http.post(`${API_BASE_URL}/api/v1/admin/users/bulk-action`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: List Organizations
*/
http.get(`${API_BASE_URL}/api/v1/admin/organizations`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!currentUser?.is_superuser) {
return HttpResponse.json({ detail: 'Admin access required' }, { status: 403 });
}
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedOrgs = sampleOrganizations.slice(start, end);
return HttpResponse.json({
data: paginatedOrgs,
pagination: {
total: sampleOrganizations.length,
page,
page_size: pageSize,
total_pages: Math.ceil(sampleOrganizations.length / pageSize),
has_next: end < sampleOrganizations.length,
has_prev: page > 1,
},
});
}),
/**
* Admin: Create Organization
*/
http.post(`${API_BASE_URL}/api/v1/admin/organizations`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Get Organization Details
*/
http.get(`${API_BASE_URL}/api/v1/admin/organizations/:org_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Update Organization
*/
http.put(`${API_BASE_URL}/api/v1/admin/organizations/:org_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Delete Organization
*/
http.delete(`${API_BASE_URL}/api/v1/admin/organizations/:org_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: List Organization Members
*/
http.get(`${API_BASE_URL}/api/v1/admin/organizations/:org_id/members`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Add Member to Organization
*/
http.post(`${API_BASE_URL}/api/v1/admin/organizations/:org_id/members`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: Remove Member from Organization
*/
http.delete(`${API_BASE_URL}/api/v1/admin/organizations/:org_id/members/:user_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Admin: List All Sessions
*/
http.get(`${API_BASE_URL}/api/v1/admin/sessions`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Get My Organizations
*/
http.get(`${API_BASE_URL}/api/v1/organizations/me`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Get Organization Details
*/
http.get(`${API_BASE_URL}/api/v1/organizations/:organization_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Update Organization
*/
http.put(`${API_BASE_URL}/api/v1/organizations/:organization_id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
/**
* Get Organization Members
*/
http.get(`${API_BASE_URL}/api/v1/organizations/:organization_id/members`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
return HttpResponse.json({
success: true,
message: 'Operation successful'
});
}),
];

View File

@@ -0,0 +1,21 @@
/**
* MSW Handlers Index
*
* Combines auto-generated handlers with custom overrides.
*
* Architecture:
* - generated.ts: Auto-generated from OpenAPI spec (DO NOT EDIT)
* - overrides.ts: Custom handler logic (EDIT AS NEEDED)
*
* Overrides take precedence over generated handlers.
*/
import { generatedHandlers } from './generated';
import { overrideHandlers } from './overrides';
/**
* All request handlers for MSW
*
* Order matters: overrides come first to take precedence
*/
export const handlers = [...overrideHandlers, ...generatedHandlers];

View File

@@ -0,0 +1,104 @@
/**
* MSW Handler Overrides
*
* Custom handlers that override or extend auto-generated ones.
* Use this file for complex logic that can't be auto-generated.
*
* Examples:
* - Complex validation logic
* - Stateful interactions
* - Error simulation scenarios
* - Special edge cases
*/
import { http, HttpResponse, delay } from 'msw';
import { generateMockToken } from '../utils/tokens';
import { validateCredentials, setCurrentUser, currentUser } from '../data/users';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
const NETWORK_DELAY = 300; // ms - simulate realistic network delay
/**
* Custom handler overrides
*
* These handlers take precedence over generated ones.
* Add custom implementations here as needed.
*/
export const overrideHandlers = [
/**
* Login Override
* Custom handler to return proper JWT tokens and user data
*/
http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as any;
const user = validateCredentials(body.email, body.password);
if (!user) {
return HttpResponse.json({ detail: 'Incorrect email or password' }, { status: 401 });
}
setCurrentUser(user);
return HttpResponse.json({
access_token: generateMockToken('access', user.id),
refresh_token: generateMockToken('refresh', user.id),
token_type: 'bearer',
expires_in: 900,
user: user,
});
}),
/**
* Register Override
* Custom handler to return proper JWT tokens and user data
*/
http.post(`${API_BASE_URL}/api/v1/auth/register`, async ({ request }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as any;
const newUser = {
id: `new-user-${Date.now()}`,
email: body.email,
first_name: body.first_name,
last_name: body.last_name || null,
phone_number: body.phone_number || null,
is_active: true,
is_superuser: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_login: null,
organization_count: 0,
};
setCurrentUser(newUser);
return HttpResponse.json({
user: newUser,
access_token: generateMockToken('access', newUser.id),
refresh_token: generateMockToken('refresh', newUser.id),
token_type: 'bearer',
expires_in: 900,
});
}),
/**
* Refresh Token Override
* Custom handler to return proper JWT tokens
*/
http.post(`${API_BASE_URL}/api/v1/auth/refresh`, async ({ request }) => {
await delay(NETWORK_DELAY);
// Use current user's ID if available, otherwise generate a generic token
const userId = currentUser?.id || 'refreshed-user';
return HttpResponse.json({
access_token: generateMockToken('access', userId),
refresh_token: generateMockToken('refresh', userId),
token_type: 'bearer',
expires_in: 900,
});
}),
];

View File

@@ -0,0 +1,20 @@
/**
* Mock Service Worker (MSW) Setup
*
* Initializes MSW for demo mode when NEXT_PUBLIC_DEMO_MODE=true
* SAFE: Will not run during tests or development mode
*
* Usage:
* - Development (default): Uses real backend at localhost:8000
* - Demo mode: Set NEXT_PUBLIC_DEMO_MODE=true to use MSW
* - Tests: MSW never initializes (Jest uses existing mocks, Playwright uses page.route())
*/
export { startMockServiceWorker as initMocks } from './browser';
export { handlers } from './handlers';
export { worker } from './browser';
// Export mock data for testing purposes
export * from './data/users';
export * from './data/organizations';
export * from './data/stats';

View File

@@ -0,0 +1,28 @@
/**
* MSW Token Generation Utilities
*
* Helper functions for generating mock JWT tokens in demo mode.
* Tokens follow proper JWT format to pass client-side validation.
*/
/**
* Generate a mock JWT-like token for demo mode
* Format: header.payload.signature (3 parts separated by dots)
*
* @param type - Token type ('access' or 'refresh')
* @param userId - User ID to include in the token payload
* @returns JWT-formatted token string
*/
export function generateMockToken(type: 'access' | 'refresh', userId: string): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = btoa(
JSON.stringify({
sub: userId,
type: type,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (type === 'access' ? 900 : 2592000),
})
);
const signature = btoa(`demo-${type}-${userId}-${Date.now()}`);
return `${header}.${payload}.${signature}`;
}

View File

@@ -5,28 +5,15 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import AdminPage from '@/app/[locale]/admin/page'; import AdminPage from '@/app/[locale]/admin/page';
import { useAdminStats } from '@/lib/api/hooks/useAdmin'; import { getAdminStats } from '@/lib/api/admin';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock the API client
jest.mock('@/lib/api/admin');
// Mock the useAdminStats hook // Mock the useAdminStats hook
jest.mock('@/lib/api/hooks/useAdmin'); jest.mock('@/lib/api/hooks/useAdmin', () => ({
useAdminStats: () => ({
// Mock chart components
jest.mock('@/components/charts', () => ({
UserGrowthChart: () => <div data-testid="user-growth-chart">User Growth Chart</div>,
OrganizationDistributionChart: () => (
<div data-testid="org-distribution-chart">Org Distribution Chart</div>
),
SessionActivityChart: () => (
<div data-testid="session-activity-chart">Session Activity Chart</div>
),
UserStatusChart: () => <div data-testid="user-status-chart">User Status Chart</div>,
}));
const mockUseAdminStats = useAdminStats as jest.MockedFunction<typeof useAdminStats>;
// Helper function to render with default mocked stats
function renderWithMockedStats() {
mockUseAdminStats.mockReturnValue({
data: { data: {
totalUsers: 100, totalUsers: 100,
activeUsers: 80, activeUsers: 80,
@@ -36,9 +23,46 @@ function renderWithMockedStats() {
isLoading: false, isLoading: false,
isError: false, isError: false,
error: null, error: null,
}),
}));
// Mock chart components
jest.mock('@/components/charts', () => ({
UserGrowthChart: () => <div data-testid="user-growth-chart">User Growth Chart</div>,
OrganizationDistributionChart: () => (
<div data-testid="org-distribution-chart">Org Distribution Chart</div>
),
RegistrationActivityChart: () => (
<div data-testid="registration-activity-chart">Registration Activity Chart</div>
),
UserStatusChart: () => <div data-testid="user-status-chart">User Status Chart</div>,
}));
const mockGetAdminStats = getAdminStats as jest.MockedFunction<typeof getAdminStats>;
// Helper function to render with default mocked stats
function renderWithMockedStats() {
mockGetAdminStats.mockResolvedValue({
data: {
user_growth: [],
organization_distribution: [],
user_status: [],
},
} as any); } as any);
return render(<AdminPage />); const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<AdminPage />
</QueryClientProvider>
);
} }
describe('AdminPage', () => { describe('AdminPage', () => {
@@ -117,7 +141,7 @@ describe('AdminPage', () => {
expect(screen.getByTestId('user-growth-chart')).toBeInTheDocument(); expect(screen.getByTestId('user-growth-chart')).toBeInTheDocument();
expect(screen.getByTestId('org-distribution-chart')).toBeInTheDocument(); expect(screen.getByTestId('org-distribution-chart')).toBeInTheDocument();
expect(screen.getByTestId('session-activity-chart')).toBeInTheDocument(); expect(screen.getByTestId('registration-activity-chart')).toBeInTheDocument();
expect(screen.getByTestId('user-status-chart')).toBeInTheDocument(); expect(screen.getByTestId('user-status-chart')).toBeInTheDocument();
}); });
}); });

View File

@@ -105,7 +105,7 @@ describe('DemoTourPage', () => {
// Check for credentials // Check for credentials
const authCards = screen.getAllByText(/demo@example\.com/i); const authCards = screen.getAllByText(/demo@example\.com/i);
expect(authCards.length).toBeGreaterThan(0); expect(authCards.length).toBeGreaterThan(0);
const demo123 = screen.getAllByText(/Demo123!/i); const demo123 = screen.getAllByText(/DemoPass1234!/i);
expect(demo123.length).toBeGreaterThan(0); expect(demo123.length).toBeGreaterThan(0);
}); });
@@ -130,7 +130,7 @@ describe('DemoTourPage', () => {
// Check for admin credentials // Check for admin credentials
expect(screen.getByText(/admin@example\.com/i)).toBeInTheDocument(); expect(screen.getByText(/admin@example\.com/i)).toBeInTheDocument();
expect(screen.getByText(/Admin123!/i)).toBeInTheDocument(); expect(screen.getByText(/AdminPass1234!/i)).toBeInTheDocument();
}); });
it('shows Login Required badge for authenticated demos', () => { it('shows Login Required badge for authenticated demos', () => {

View File

@@ -47,6 +47,34 @@ jest.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {}, vscDarkPlus: {},
})); }));
// Mock auth hooks
jest.mock('@/lib/api/hooks/useAuth', () => ({
useIsAuthenticated: jest.fn(() => false),
useLogout: jest.fn(() => ({
mutate: jest.fn(),
})),
}));
// Mock Theme components
jest.mock('@/components/theme', () => ({
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>,
}));
// Mock LocaleSwitcher
jest.mock('@/components/i18n', () => ({
LocaleSwitcher: () => <div data-testid="locale-switcher">Locale Switcher</div>,
}));
// Mock DemoCredentialsModal
jest.mock('@/components/home/DemoCredentialsModal', () => ({
DemoCredentialsModal: ({ open, onClose }: any) =>
open ? (
<div data-testid="demo-modal">
<button onClick={onClose}>Close</button>
</div>
) : null,
}));
describe('HomePage', () => { describe('HomePage', () => {
describe('Page Structure', () => { describe('Page Structure', () => {
it('renders without crashing', () => { it('renders without crashing', () => {
@@ -60,7 +88,6 @@ describe('HomePage', () => {
render(<Home />); render(<Home />);
const header = screen.getByRole('banner'); const header = screen.getByRole('banner');
expect(within(header).getByText('PragmaStack')).toBeInTheDocument(); expect(within(header).getByText('PragmaStack')).toBeInTheDocument();
expect(within(header).getByText('Template')).toBeInTheDocument();
}); });
it('renders footer with copyright', () => { it('renders footer with copyright', () => {
@@ -79,30 +106,26 @@ describe('HomePage', () => {
it('renders production-ready messaging', () => { it('renders production-ready messaging', () => {
render(<Home />); render(<Home />);
expect(screen.getByText(/Production-ready FastAPI/i)).toBeInTheDocument(); expect(screen.getByText(/Opinionated, secure, and production-ready/i)).toBeInTheDocument();
}); });
it('renders test coverage stats', () => { it('renders badges', () => {
render(<Home />); render(<Home />);
const coverageTexts = screen.getAllByText('97%'); expect(screen.getByText('Comprehensive Tests')).toBeInTheDocument();
expect(coverageTexts.length).toBeGreaterThan(0); expect(screen.getByText('Pragmatic by Design')).toBeInTheDocument();
expect(screen.getAllByText(/Test Coverage/i)[0]).toBeInTheDocument();
const testCountTexts = screen.getAllByText('743');
expect(testCountTexts.length).toBeGreaterThan(0);
expect(screen.getAllByText(/Passing Tests/i)[0]).toBeInTheDocument();
}); });
}); });
describe('Context Section', () => { describe('Context Section', () => {
it('renders what you get message', () => { it('renders what you get message', () => {
render(<Home />); render(<Home />);
expect(screen.getByText(/What You Get Out of the Box/i)).toBeInTheDocument(); expect(screen.getByText(/Stop Reinventing the Wheel/i)).toBeInTheDocument();
}); });
it('renders key features', () => { it('renders key features', () => {
render(<Home />); render(<Home />);
expect(screen.getAllByText(/Clone & Deploy in < 5 minutes/i)[0]).toBeInTheDocument(); expect(screen.getAllByText(/Clone & Deploy in < 5 minutes/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/97% Test Coverage \(743 tests\)/i)[0]).toBeInTheDocument(); expect(screen.getAllByText(/Comprehensive Test Suite/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/12\+ Documentation Guides/i)[0]).toBeInTheDocument(); expect(screen.getAllByText(/12\+ Documentation Guides/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/Zero Commercial Dependencies/i)[0]).toBeInTheDocument(); expect(screen.getAllByText(/Zero Commercial Dependencies/i)[0]).toBeInTheDocument();
}); });
@@ -167,7 +190,7 @@ describe('HomePage', () => {
describe('Tech Stack Section', () => { describe('Tech Stack Section', () => {
it('renders tech stack heading', () => { it('renders tech stack heading', () => {
render(<Home />); render(<Home />);
expect(screen.getByText(/Modern, Type-Safe, Production-Grade Stack/i)).toBeInTheDocument(); expect(screen.getByText(/A Stack You Can Trust/i)).toBeInTheDocument();
}); });
it('renders all technologies', () => { it('renders all technologies', () => {
@@ -186,7 +209,7 @@ describe('HomePage', () => {
describe('Philosophy Section', () => { describe('Philosophy Section', () => {
it('renders why this template exists', () => { it('renders why this template exists', () => {
render(<Home />); render(<Home />);
expect(screen.getByText(/Why This Template Exists/i)).toBeInTheDocument(); expect(screen.getByText(/Why PragmaStack\?/i)).toBeInTheDocument();
}); });
it('renders what you wont find section', () => { it('renders what you wont find section', () => {
@@ -198,7 +221,7 @@ describe('HomePage', () => {
it('renders what you will find section', () => { it('renders what you will find section', () => {
render(<Home />); render(<Home />);
expect(screen.getByText(/What You Will Find/i)).toBeInTheDocument(); expect(screen.getByText(/What You Will Find/i)).toBeInTheDocument();
expect(screen.getByText(/Production patterns that actually work/i)).toBeInTheDocument(); expect(screen.getByText(/Pragmatic Speed: Ship features, not config/i)).toBeInTheDocument();
}); });
}); });

View File

@@ -32,11 +32,15 @@ jest.mock('@/lib/api/hooks/useAuth', () => ({
useLogin: () => mockUseLogin(), useLogin: () => mockUseLogin(),
})); }));
// Mock router
// Mock router // Mock router
jest.mock('next/navigation', () => ({ jest.mock('next/navigation', () => ({
useRouter: () => ({ useRouter: () => ({
push: jest.fn(), push: jest.fn(),
}), }),
useSearchParams: () => ({
get: jest.fn(),
}),
})); }));
// Mock auth store // Mock auth store

View File

@@ -4,7 +4,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { OrganizationDistributionChart } from '@/components/charts/OrganizationDistributionChart'; import { OrganizationDistributionChart } from '@/components/charts/OrganizationDistributionChart';
import type { OrganizationDistributionData } from '@/components/charts/OrganizationDistributionChart'; import type { OrgDistributionData } from '@/components/charts/OrganizationDistributionChart';
// Mock recharts to avoid rendering issues in tests // Mock recharts to avoid rendering issues in tests
jest.mock('recharts', () => { jest.mock('recharts', () => {
@@ -18,10 +18,10 @@ jest.mock('recharts', () => {
}); });
describe('OrganizationDistributionChart', () => { describe('OrganizationDistributionChart', () => {
const mockData: OrganizationDistributionData[] = [ const mockData: OrgDistributionData[] = [
{ name: 'Engineering', members: 45, activeMembers: 42 }, { name: 'Engineering', value: 45 },
{ name: 'Marketing', members: 28, activeMembers: 25 }, { name: 'Marketing', value: 28 },
{ name: 'Sales', members: 35, activeMembers: 33 }, { name: 'Sales', value: 35 },
]; ];
it('renders chart card with title and description', () => { it('renders chart card with title and description', () => {
@@ -41,7 +41,7 @@ describe('OrganizationDistributionChart', () => {
render(<OrganizationDistributionChart />); render(<OrganizationDistributionChart />);
expect(screen.getByText('Organization Distribution')).toBeInTheDocument(); expect(screen.getByText('Organization Distribution')).toBeInTheDocument();
expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); expect(screen.getByText('No organization data available')).toBeInTheDocument();
}); });
it('shows loading state', () => { it('shows loading state', () => {
@@ -67,6 +67,6 @@ describe('OrganizationDistributionChart', () => {
render(<OrganizationDistributionChart data={[]} />); render(<OrganizationDistributionChart data={[]} />);
expect(screen.getByText('Organization Distribution')).toBeInTheDocument(); expect(screen.getByText('Organization Distribution')).toBeInTheDocument();
expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); expect(screen.getByText('No organization data available')).toBeInTheDocument();
}); });
}); });

View File

@@ -0,0 +1,87 @@
/**
* Tests for RegistrationActivityChart Component
*/
import { render, screen } from '@testing-library/react';
import { RegistrationActivityChart } from '@/components/charts/RegistrationActivityChart';
import type { RegistrationActivityData } from '@/components/charts/RegistrationActivityChart';
// Mock recharts to avoid rendering issues in tests
jest.mock('recharts', () => {
const OriginalModule = jest.requireActual('recharts');
return {
...OriginalModule,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
};
});
describe('RegistrationActivityChart', () => {
const mockData: RegistrationActivityData[] = [
{ date: 'Jan 1', registrations: 5 },
{ date: 'Jan 2', registrations: 8 },
{ date: 'Jan 3', registrations: 3 },
];
it('renders chart card with title and description', () => {
render(<RegistrationActivityChart data={mockData} />);
expect(screen.getByText('User Registration Activity')).toBeInTheDocument();
expect(screen.getByText('New user registrations over the last 14 days')).toBeInTheDocument();
});
it('renders chart with provided data', () => {
render(<RegistrationActivityChart data={mockData} />);
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
});
it('shows empty state when no data is provided', () => {
render(<RegistrationActivityChart />);
expect(screen.getByText('User Registration Activity')).toBeInTheDocument();
expect(screen.getByText('No registration data available')).toBeInTheDocument();
expect(screen.queryByTestId('responsive-container')).not.toBeInTheDocument();
});
it('shows empty state when data array is empty', () => {
render(<RegistrationActivityChart data={[]} />);
expect(screen.getByText('User Registration Activity')).toBeInTheDocument();
expect(screen.getByText('No registration data available')).toBeInTheDocument();
expect(screen.queryByTestId('responsive-container')).not.toBeInTheDocument();
});
it('shows empty state when data has no registrations', () => {
const emptyData = [
{ date: 'Jan 1', registrations: 0 },
{ date: 'Jan 2', registrations: 0 },
];
render(<RegistrationActivityChart data={emptyData} />);
expect(screen.getByText('User Registration Activity')).toBeInTheDocument();
expect(screen.getByText('No registration data available')).toBeInTheDocument();
expect(screen.queryByTestId('responsive-container')).not.toBeInTheDocument();
});
it('shows loading state', () => {
render(<RegistrationActivityChart data={mockData} loading />);
expect(screen.getByText('User Registration Activity')).toBeInTheDocument();
// Chart should not be visible when loading
expect(screen.queryByTestId('responsive-container')).not.toBeInTheDocument();
expect(screen.queryByText('No registration data available')).not.toBeInTheDocument();
});
it('shows error state', () => {
render(<RegistrationActivityChart data={mockData} error="Failed to load chart data" />);
expect(screen.getByText('User Registration Activity')).toBeInTheDocument();
expect(screen.getByText('Failed to load chart data')).toBeInTheDocument();
// Chart should not be visible when error
expect(screen.queryByTestId('responsive-container')).not.toBeInTheDocument();
});
});

View File

@@ -19,9 +19,9 @@ jest.mock('recharts', () => {
describe('UserGrowthChart', () => { describe('UserGrowthChart', () => {
const mockData: UserGrowthData[] = [ const mockData: UserGrowthData[] = [
{ date: 'Jan 1', totalUsers: 100, activeUsers: 80 }, { date: 'Jan 1', total_users: 100, active_users: 80 },
{ date: 'Jan 2', totalUsers: 105, activeUsers: 85 }, { date: 'Jan 2', total_users: 105, active_users: 85 },
{ date: 'Jan 3', totalUsers: 110, activeUsers: 90 }, { date: 'Jan 3', total_users: 110, active_users: 90 },
]; ];
it('renders chart card with title and description', () => { it('renders chart card with title and description', () => {
@@ -41,7 +41,7 @@ describe('UserGrowthChart', () => {
render(<UserGrowthChart />); render(<UserGrowthChart />);
expect(screen.getByText('User Growth')).toBeInTheDocument(); expect(screen.getByText('User Growth')).toBeInTheDocument();
expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); expect(screen.getByText('No user growth data available')).toBeInTheDocument();
}); });
it('shows loading state', () => { it('shows loading state', () => {
@@ -67,6 +67,6 @@ describe('UserGrowthChart', () => {
render(<UserGrowthChart data={[]} />); render(<UserGrowthChart data={[]} />);
expect(screen.getByText('User Growth')).toBeInTheDocument(); expect(screen.getByText('User Growth')).toBeInTheDocument();
expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); expect(screen.getByText('No user growth data available')).toBeInTheDocument();
}); });
}); });

View File

@@ -60,7 +60,7 @@ describe('UserStatusChart', () => {
render(<UserStatusChart />); render(<UserStatusChart />);
expect(screen.getByText('User Status Distribution')).toBeInTheDocument(); expect(screen.getByText('User Status Distribution')).toBeInTheDocument();
expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); expect(screen.getByText('No user status data available')).toBeInTheDocument();
}); });
it('shows loading state', () => { it('shows loading state', () => {
@@ -86,7 +86,7 @@ describe('UserStatusChart', () => {
render(<UserStatusChart data={[]} />); render(<UserStatusChart data={[]} />);
expect(screen.getByText('User Status Distribution')).toBeInTheDocument(); expect(screen.getByText('User Status Distribution')).toBeInTheDocument();
expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); expect(screen.getByText('No user status data available')).toBeInTheDocument();
}); });
describe('renderLabel function', () => { describe('renderLabel function', () => {

View File

@@ -48,7 +48,7 @@ describe('DemoCredentialsModal', () => {
expect(screen.getByText('Regular User')).toBeInTheDocument(); expect(screen.getByText('Regular User')).toBeInTheDocument();
expect(screen.getByText('demo@example.com')).toBeInTheDocument(); expect(screen.getByText('demo@example.com')).toBeInTheDocument();
expect(screen.getByText('Demo123!')).toBeInTheDocument(); expect(screen.getByText('DemoPass1234!')).toBeInTheDocument();
expect(screen.getByText(/User settings & profile/i)).toBeInTheDocument(); expect(screen.getByText(/User settings & profile/i)).toBeInTheDocument();
}); });
@@ -57,7 +57,7 @@ describe('DemoCredentialsModal', () => {
expect(screen.getByText('Admin User (Superuser)')).toBeInTheDocument(); expect(screen.getByText('Admin User (Superuser)')).toBeInTheDocument();
expect(screen.getByText('admin@example.com')).toBeInTheDocument(); expect(screen.getByText('admin@example.com')).toBeInTheDocument();
expect(screen.getByText('Admin123!')).toBeInTheDocument(); expect(screen.getByText('AdminPass1234!')).toBeInTheDocument();
expect(screen.getByText(/Full admin dashboard/i)).toBeInTheDocument(); expect(screen.getByText(/Full admin dashboard/i)).toBeInTheDocument();
}); });
@@ -70,7 +70,7 @@ describe('DemoCredentialsModal', () => {
fireEvent.click(regularCopyButton!); fireEvent.click(regularCopyButton!);
await waitFor(() => { await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('demo@example.com\nDemo123!'); expect(navigator.clipboard.writeText).toHaveBeenCalledWith('demo@example.com\nDemoPass1234!');
const copiedButtons = screen.getAllByRole('button'); const copiedButtons = screen.getAllByRole('button');
const copiedButton = copiedButtons.find((btn) => btn.textContent?.includes('Copied!')); const copiedButton = copiedButtons.find((btn) => btn.textContent?.includes('Copied!'));
expect(copiedButton).toBeInTheDocument(); expect(copiedButton).toBeInTheDocument();
@@ -86,7 +86,7 @@ describe('DemoCredentialsModal', () => {
fireEvent.click(adminCopyButton!); fireEvent.click(adminCopyButton!);
await waitFor(() => { await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('admin@example.com\nAdmin123!'); expect(navigator.clipboard.writeText).toHaveBeenCalledWith('admin@example.com\nAdminPass1234!');
const copiedButtons = screen.getAllByRole('button'); const copiedButtons = screen.getAllByRole('button');
const copiedButton = copiedButtons.find((btn) => btn.textContent?.includes('Copied!')); const copiedButton = copiedButtons.find((btn) => btn.textContent?.includes('Copied!'));
expect(copiedButton).toBeInTheDocument(); expect(copiedButton).toBeInTheDocument();
@@ -156,10 +156,16 @@ describe('DemoCredentialsModal', () => {
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />); render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
const loginAsUserLink = screen.getByRole('link', { name: /login as user/i }); const loginAsUserLink = screen.getByRole('link', { name: /login as user/i });
expect(loginAsUserLink).toHaveAttribute('href', '/login'); expect(loginAsUserLink).toHaveAttribute(
'href',
'/login?email=demo@example.com&password=DemoPass1234!'
);
const loginAsAdminLink = screen.getByRole('link', { name: /login as admin/i }); const loginAsAdminLink = screen.getByRole('link', { name: /login as admin/i });
expect(loginAsAdminLink).toHaveAttribute('href', '/login'); expect(loginAsAdminLink).toHaveAttribute(
'href',
'/login?email=admin@example.com&password=AdminPass1234!'
);
}); });
it('calls onClose when login link is clicked', () => { it('calls onClose when login link is clicked', () => {

View File

@@ -27,6 +27,24 @@ jest.mock('@/components/home/DemoCredentialsModal', () => ({
) : null, ) : null,
})); }));
// Mock auth hooks
jest.mock('@/lib/api/hooks/useAuth', () => ({
useIsAuthenticated: jest.fn(() => false),
useLogout: jest.fn(() => ({
mutate: jest.fn(),
})),
}));
// Mock Theme components
jest.mock('@/components/theme', () => ({
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>,
}));
// Mock LocaleSwitcher
jest.mock('@/components/i18n', () => ({
LocaleSwitcher: () => <div data-testid="locale-switcher">Locale Switcher</div>,
}));
describe('Header', () => { describe('Header', () => {
it('renders logo', () => { it('renders logo', () => {
render( render(
@@ -38,7 +56,6 @@ describe('Header', () => {
); );
expect(screen.getByText('PragmaStack')).toBeInTheDocument(); expect(screen.getByText('PragmaStack')).toBeInTheDocument();
expect(screen.getByText('Template')).toBeInTheDocument();
}); });
it('logo links to homepage', () => { it('logo links to homepage', () => {
@@ -50,7 +67,7 @@ describe('Header', () => {
/> />
); );
const logoLink = screen.getByRole('link', { name: /pragmastack template/i }); const logoLink = screen.getByRole('link', { name: /PragmaStack/i });
expect(logoLink).toHaveAttribute('href', '/'); expect(logoLink).toHaveAttribute('href', '/');
}); });

View File

@@ -47,8 +47,8 @@ describe('HeroSection', () => {
); );
expect(screen.getByText('MIT Licensed')).toBeInTheDocument(); expect(screen.getByText('MIT Licensed')).toBeInTheDocument();
expect(screen.getAllByText('97% Test Coverage')[0]).toBeInTheDocument(); expect(screen.getByText('Comprehensive Tests')).toBeInTheDocument();
expect(screen.getByText('Production Ready')).toBeInTheDocument(); expect(screen.getByText('Pragmatic by Design')).toBeInTheDocument();
}); });
it('renders main headline', () => { it('renders main headline', () => {
@@ -60,8 +60,8 @@ describe('HeroSection', () => {
/> />
); );
expect(screen.getAllByText(/Everything You Need to Build/i)[0]).toBeInTheDocument(); expect(screen.getByText(/The Pragmatic/i)).toBeInTheDocument();
expect(screen.getAllByText(/Modern Web Applications/i)[0]).toBeInTheDocument(); expect(screen.getByText(/Full-Stack Template/i)).toBeInTheDocument();
}); });
it('renders subheadline with key messaging', () => { it('renders subheadline with key messaging', () => {
@@ -73,7 +73,7 @@ describe('HeroSection', () => {
/> />
); );
expect(screen.getByText(/Production-ready FastAPI \+ Next.js template/i)).toBeInTheDocument(); expect(screen.getByText(/Opinionated, secure, and production-ready/i)).toBeInTheDocument();
expect(screen.getByText(/Start building features on day one/i)).toBeInTheDocument(); expect(screen.getByText(/Start building features on day one/i)).toBeInTheDocument();
}); });
@@ -118,26 +118,6 @@ describe('HeroSection', () => {
expect(componentsLink).toHaveAttribute('href', '/dev'); expect(componentsLink).toHaveAttribute('href', '/dev');
}); });
it('displays test coverage stats', () => {
render(
<HeroSection
onOpenDemoModal={function (): void {
throw new Error('Function not implemented.');
}}
/>
);
const coverageTexts = screen.getAllByText('97%');
expect(coverageTexts.length).toBeGreaterThan(0);
const testCountTexts = screen.getAllByText('743');
expect(testCountTexts.length).toBeGreaterThan(0);
expect(screen.getAllByText(/Passing Tests/i)[0]).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText(/Flaky Tests/i)).toBeInTheDocument();
});
it('calls onOpenDemoModal when Try Live Demo button is clicked', () => { it('calls onOpenDemoModal when Try Live Demo button is clicked', () => {
const mockOnOpenDemoModal = jest.fn(); const mockOnOpenDemoModal = jest.fn();
render(<HeroSection onOpenDemoModal={mockOnOpenDemoModal} />); render(<HeroSection onOpenDemoModal={mockOnOpenDemoModal} />);

View File

@@ -35,21 +35,25 @@ describe('StatsSection', () => {
it('renders all stat cards', () => { it('renders all stat cards', () => {
render(<StatsSection />); render(<StatsSection />);
expect(screen.getByText('Test Coverage')).toBeInTheDocument(); expect(screen.getByText('Open Source')).toBeInTheDocument();
expect(screen.getByText('Passing Tests')).toBeInTheDocument(); expect(screen.getByText('Type Safe')).toBeInTheDocument();
expect(screen.getByText('Flaky Tests')).toBeInTheDocument(); expect(screen.getByText('Doc Guides')).toBeInTheDocument();
expect(screen.getByText('API Endpoints')).toBeInTheDocument(); expect(screen.getByText('Magic')).toBeInTheDocument();
}); });
it('displays stat descriptions', () => { it('displays stat descriptions', () => {
render(<StatsSection />); render(<StatsSection />);
expect( expect(
screen.getByText(/Comprehensive testing across backend and frontend/i) screen.getByText(/MIT Licensed. No hidden costs or vendor lock-in/i)
).toBeInTheDocument();
expect(
screen.getByText(/End-to-end type safety with TypeScript & Pydantic/i)
).toBeInTheDocument();
expect(screen.getByText(/Comprehensive documentation for every feature/i)).toBeInTheDocument();
expect(
screen.getByText(/Explicit is better than implicit. No hidden logic/i)
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(/Backend, frontend unit, and E2E tests/i)).toBeInTheDocument();
expect(screen.getByText(/Production-stable test suite/i)).toBeInTheDocument();
expect(screen.getByText(/Fully documented with OpenAPI/i)).toBeInTheDocument();
}); });
it('renders animated counters with correct suffixes', () => { it('renders animated counters with correct suffixes', () => {
@@ -69,7 +73,7 @@ describe('StatsSection', () => {
// After animation, we should see the final values // After animation, we should see the final values
// The component should eventually show the stat values // The component should eventually show the stat values
const statsSection = screen.getByText('Test Coverage').parentElement; const statsSection = screen.getByText('Open Source').parentElement;
expect(statsSection).toBeInTheDocument(); expect(statsSection).toBeInTheDocument();
}); });
@@ -78,17 +82,17 @@ describe('StatsSection', () => {
// Icons are rendered via lucide-react components // Icons are rendered via lucide-react components
// We can verify the stat cards are rendered with proper structure // We can verify the stat cards are rendered with proper structure
const testCoverageCard = screen.getByText('Test Coverage').closest('div'); const openSourceCard = screen.getByText('Open Source').closest('div');
expect(testCoverageCard).toBeInTheDocument(); expect(openSourceCard).toBeInTheDocument();
const passingTestsCard = screen.getByText('Passing Tests').closest('div'); const typeSafeCard = screen.getByText('Type Safe').closest('div');
expect(passingTestsCard).toBeInTheDocument(); expect(typeSafeCard).toBeInTheDocument();
const flakyTestsCard = screen.getByText('Flaky Tests').closest('div'); const docGuidesCard = screen.getByText('Doc Guides').closest('div');
expect(flakyTestsCard).toBeInTheDocument(); expect(docGuidesCard).toBeInTheDocument();
const apiEndpointsCard = screen.getByText('API Endpoints').closest('div'); const magicCard = screen.getByText('Magic').closest('div');
expect(apiEndpointsCard).toBeInTheDocument(); expect(magicCard).toBeInTheDocument();
}); });
describe('Accessibility', () => { describe('Accessibility', () => {
@@ -102,7 +106,7 @@ describe('StatsSection', () => {
it('has descriptive labels for stats', () => { it('has descriptive labels for stats', () => {
render(<StatsSection />); render(<StatsSection />);
const statLabels = ['Test Coverage', 'Passing Tests', 'Flaky Tests', 'API Endpoints']; const statLabels = ['Open Source', 'Type Safe', 'Doc Guides', 'Magic'];
statLabels.forEach((label) => { statLabels.forEach((label) => {
expect(screen.getByText(label)).toBeInTheDocument(); expect(screen.getByText(label)).toBeInTheDocument();