Compare commits
13 Commits
2e4700ae9b
...
3bf28aa121
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf28aa121 | ||
|
|
cda9810a7e | ||
|
|
d47bd34a92 | ||
|
|
5b0ae54365 | ||
|
|
372af25aaa | ||
|
|
d0b717a128 | ||
|
|
9d40aece30 | ||
|
|
487c8a3863 | ||
|
|
8659e884e9 | ||
|
|
a05def5906 | ||
|
|
9f655913b1 | ||
|
|
13abd159fa | ||
|
|
acfe59c8b3 |
14
CLAUDE.md
14
CLAUDE.md
@@ -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
|
||||
- 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
|
||||
|
||||
**Prefer specialized tools over bash:**
|
||||
|
||||
32
README.md
32
README.md
@@ -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
|
||||
|
||||
### Backend
|
||||
|
||||
@@ -7,7 +7,7 @@ for managing the application.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -94,6 +94,11 @@ class OrgDistributionData(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
class RegistrationActivityData(BaseModel):
|
||||
date: str
|
||||
registrations: int
|
||||
|
||||
|
||||
class UserStatusData(BaseModel):
|
||||
name: str
|
||||
value: int
|
||||
@@ -102,9 +107,63 @@ class UserStatusData(BaseModel):
|
||||
class AdminStatsResponse(BaseModel):
|
||||
user_growth: list[UserGrowthData]
|
||||
organization_distribution: list[OrgDistributionData]
|
||||
registration_activity: list[RegistrationActivityData]
|
||||
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(
|
||||
"/stats",
|
||||
response_model=AdminStatsResponse,
|
||||
@@ -116,75 +175,94 @@ async def admin_get_stats(
|
||||
admin: User = Depends(require_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""Get admin dashboard statistics."""
|
||||
# 1. User Growth (Last 30 days)
|
||||
# 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 admin dashboard statistics with real data from database."""
|
||||
from app.core.config import settings
|
||||
|
||||
# Get all users created in last 30 days
|
||||
query = (
|
||||
select(User).where(User.created_at >= thirty_days_ago).order_by(User.created_at)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
recent_users = result.scalars().all()
|
||||
# Check if we have any data
|
||||
total_users_query = select(func.count()).select_from(User)
|
||||
total_users = (await db.execute(total_users_query)).scalar() or 0
|
||||
|
||||
# Get total count before 30 days
|
||||
count_query = (
|
||||
select(func.count()).select_from(User).where(User.created_at < thirty_days_ago)
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
base_count = count_result.scalar() or 0
|
||||
# If database is essentially empty (only admin user), return demo data
|
||||
if total_users <= 1 and settings.DEMO_MODE:
|
||||
logger.info("Returning demo stats data (empty database in demo mode)")
|
||||
return _generate_demo_stats()
|
||||
|
||||
# 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 = []
|
||||
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):
|
||||
date = datetime.utcnow() - timedelta(days=i)
|
||||
date_str = date.strftime("%b %d")
|
||||
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)
|
||||
|
||||
day_data = daily_counts.get(date_str, {"total": 0, "active": 0})
|
||||
current_total += day_data["total"]
|
||||
# Count all users created before end of this day
|
||||
# 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(
|
||||
UserGrowthData(
|
||||
date=date_str,
|
||||
total_users=current_total,
|
||||
active_users=int(
|
||||
current_total * 0.8
|
||||
), # Mocking active ratio for demo visual appeal if real data lacks history
|
||||
date=date.strftime("%b %d"),
|
||||
total_users=total_users_on_date,
|
||||
active_users=active_users_on_date,
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Organization Distribution
|
||||
# Get top 5 organizations by member count
|
||||
# 2. Organization Distribution - Top 6 organizations by member count
|
||||
org_query = (
|
||||
select(Organization.name, func.count(UserOrganization.user_id).label("count"))
|
||||
.join(UserOrganization, Organization.id == UserOrganization.organization_id)
|
||||
.group_by(Organization.name)
|
||||
.order_by(func.count(UserOrganization.user_id).desc())
|
||||
.limit(5)
|
||||
.limit(6)
|
||||
)
|
||||
result = await db.execute(org_query)
|
||||
org_dist = [
|
||||
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)
|
||||
inactive_query = (
|
||||
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
|
||||
inactive_count = (await db.execute(inactive_query)).scalar() or 0
|
||||
|
||||
logger.info(
|
||||
f"User status counts - Active: {active_count}, Inactive: {inactive_count}"
|
||||
)
|
||||
|
||||
user_status = [
|
||||
UserStatusData(name="Active", value=active_count),
|
||||
UserStatusData(name="Inactive", value=inactive_count),
|
||||
@@ -201,6 +283,7 @@ async def admin_get_stats(
|
||||
return AdminStatsResponse(
|
||||
user_growth=user_growth,
|
||||
organization_distribution=org_dist,
|
||||
registration_activity=registration_activity,
|
||||
user_status=user_status,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,9 +24,24 @@
|
||||
"name": "Umbrella Corporation",
|
||||
"slug": "umbrella",
|
||||
"description": "Our business is life itself."
|
||||
},
|
||||
{
|
||||
"name": "Massive Dynamic",
|
||||
"slug": "massive-dynamic",
|
||||
"description": "What don't we do?"
|
||||
}
|
||||
],
|
||||
"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",
|
||||
"password": "Demo123!",
|
||||
@@ -57,6 +72,16 @@
|
||||
"role": "member",
|
||||
"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",
|
||||
"password": "Demo123!",
|
||||
@@ -77,6 +102,26 @@
|
||||
"role": "member",
|
||||
"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",
|
||||
"password": "Demo123!",
|
||||
@@ -87,6 +132,26 @@
|
||||
"role": "member",
|
||||
"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",
|
||||
"password": "Demo123!",
|
||||
@@ -97,6 +162,26 @@
|
||||
"role": "admin",
|
||||
"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",
|
||||
"password": "Demo123!",
|
||||
@@ -117,6 +202,76 @@
|
||||
"role": "member",
|
||||
"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",
|
||||
"password": "Demo123!",
|
||||
@@ -146,6 +301,66 @@
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, text
|
||||
@@ -37,7 +37,7 @@ async def init_db() -> User | None:
|
||||
|
||||
default_password = "AdminPassword123!"
|
||||
if settings.DEMO_MODE:
|
||||
default_password = "Admin123!"
|
||||
default_password = "AdminPass1234!"
|
||||
|
||||
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)
|
||||
# This makes the charts look more realistic
|
||||
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
|
||||
random_time = random_time.replace(
|
||||
hour=random.randint(0, 23), # 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(
|
||||
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(
|
||||
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
|
||||
|
||||
@@ -172,7 +172,7 @@ class TestProjectConfiguration:
|
||||
def test_project_name_default(self):
|
||||
"""Test that project name is set correctly"""
|
||||
settings = Settings(SECRET_KEY="a" * 32)
|
||||
assert settings.PROJECT_NAME == "App"
|
||||
assert settings.PROJECT_NAME == "PragmaStack"
|
||||
|
||||
def test_api_version_string(self):
|
||||
"""Test that API version string is correct"""
|
||||
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -41,3 +41,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
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
543
frontend/docs/DEMO_MODE.md
Normal 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
|
||||
402
frontend/docs/MSW_AUTO_GENERATION.md
Normal file
402
frontend/docs/MSW_AUTO_GENERATION.md
Normal 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! 🚀
|
||||
@@ -17,6 +17,8 @@ export default [
|
||||
'dist/**',
|
||||
'coverage/**',
|
||||
'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.tsx',
|
||||
'next-env.d.ts', // Auto-generated by Next.js
|
||||
|
||||
@@ -37,6 +37,9 @@ const customJestConfig = {
|
||||
'!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/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: {
|
||||
global: {
|
||||
|
||||
916
frontend/package-lock.json
generated
916
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -85,10 +85,17 @@
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"lighthouse": "^12.8.2",
|
||||
"msw": "^2.12.3",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
336
frontend/public/mockServiceWorker.js
Normal file
336
frontend/public/mockServiceWorker.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -71,6 +71,14 @@ for file in "$OUTPUT_DIR"/**/*.ts "$OUTPUT_DIR"/*.ts; do
|
||||
done
|
||||
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
|
||||
rm /tmp/openapi.json
|
||||
|
||||
@@ -80,8 +88,13 @@ echo -e "${YELLOW}📝 Generated files:${NC}"
|
||||
echo -e " - $OUTPUT_DIR/index.ts"
|
||||
echo -e " - $OUTPUT_DIR/schemas/"
|
||||
echo -e " - $OUTPUT_DIR/services/"
|
||||
echo -e " - src/mocks/handlers/generated.ts (MSW handlers)"
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 Next steps:${NC}"
|
||||
echo -e " Import in your code:"
|
||||
echo -e " ${GREEN}import { ApiClient } from '@/lib/api/generated';${NC}"
|
||||
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 ""
|
||||
|
||||
369
frontend/scripts/generate-msw-handlers.ts
Normal file
369
frontend/scripts/generate-msw-handlers.ts
Normal 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();
|
||||
110
frontend/scripts/sync-msw-with-openapi.md
Normal file
110
frontend/scripts/sync-msw-with-openapi.md
Normal 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
|
||||
@@ -11,7 +11,7 @@ import { DashboardStats } from '@/components/admin';
|
||||
import {
|
||||
UserGrowthChart,
|
||||
OrganizationDistributionChart,
|
||||
SessionActivityChart,
|
||||
RegistrationActivityChart,
|
||||
UserStatusChart,
|
||||
} from '@/components/charts';
|
||||
import { Users, Building2, Settings } from 'lucide-react';
|
||||
@@ -19,12 +19,40 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getAdminStats } from '@/lib/api/admin';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { data: stats, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'stats'],
|
||||
console.log('[AdminPage] Component rendering');
|
||||
|
||||
const {
|
||||
data: stats,
|
||||
isLoading,
|
||||
error,
|
||||
status,
|
||||
fetchStatus,
|
||||
} = useQuery({
|
||||
queryKey: ['admin', 'analytics'], // Changed from 'stats' to avoid collision with useAdminStats hook
|
||||
queryFn: async () => {
|
||||
const response = await getAdminStats();
|
||||
return response.data;
|
||||
console.log('[AdminPage] QueryFn executing - fetching stats...');
|
||||
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 (
|
||||
@@ -90,7 +118,11 @@ export default function AdminPage() {
|
||||
loading={isLoading}
|
||||
error={error ? (error as Error).message : null}
|
||||
/>
|
||||
<SessionActivityChart />
|
||||
<RegistrationActivityChart
|
||||
data={stats?.registration_activity}
|
||||
loading={isLoading}
|
||||
error={error ? (error as Error).message : null}
|
||||
/>
|
||||
<OrganizationDistributionChart
|
||||
data={stats?.organization_distribution}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -52,7 +52,7 @@ const demoCategories = [
|
||||
features: ['Login & logout', 'Registration', 'Password reset', 'Session tokens'],
|
||||
credentials: {
|
||||
email: 'demo@example.com',
|
||||
password: 'Demo123!',
|
||||
password: 'DemoPass1234!',
|
||||
role: 'Regular User',
|
||||
},
|
||||
},
|
||||
@@ -64,7 +64,7 @@ const demoCategories = [
|
||||
features: ['Profile editing', 'Password changes', 'Active sessions', 'Preferences'],
|
||||
credentials: {
|
||||
email: 'demo@example.com',
|
||||
password: 'Demo123!',
|
||||
password: 'DemoPass1234!',
|
||||
role: 'Regular User',
|
||||
},
|
||||
},
|
||||
@@ -76,7 +76,7 @@ const demoCategories = [
|
||||
features: ['User management', 'Analytics charts', 'Bulk operations', 'Organization control'],
|
||||
credentials: {
|
||||
email: 'admin@example.com',
|
||||
password: 'Admin123!',
|
||||
password: 'AdminPass1234!',
|
||||
role: 'Admin',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import '../globals.css';
|
||||
import { Providers } from '../providers';
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
import { AuthInitializer } from '@/components/auth';
|
||||
import { MSWProvider } from '@/components/providers/MSWProvider';
|
||||
import { DemoModeBanner } from '@/components/demo';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
@@ -82,10 +84,13 @@ export default async function LocaleLayout({
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<AuthProvider>
|
||||
<AuthInitializer />
|
||||
<Providers>{children}</Providers>
|
||||
</AuthProvider>
|
||||
<MSWProvider>
|
||||
<DemoModeBanner />
|
||||
<AuthProvider>
|
||||
<AuthInitializer />
|
||||
<Providers>{children}</Providers>
|
||||
</AuthProvider>
|
||||
</MSWProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,48 +5,65 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { ChartCard } from './ChartCard';
|
||||
import { CHART_PALETTES } from '@/lib/chart-colors';
|
||||
|
||||
export interface OrganizationDistributionData {
|
||||
export interface OrgDistributionData {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface OrganizationDistributionChartProps {
|
||||
data?: OrganizationDistributionData[];
|
||||
data?: OrgDistributionData[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Generate mock data for development/demo
|
||||
function generateMockData(): OrganizationDistributionData[] {
|
||||
return [
|
||||
{ name: 'Engineering', value: 45 },
|
||||
{ name: 'Marketing', value: 28 },
|
||||
{ name: 'Sales', value: 35 },
|
||||
{ name: 'Operations', value: 22 },
|
||||
{ name: 'HR', value: 15 },
|
||||
{ name: 'Finance', value: 18 },
|
||||
];
|
||||
}
|
||||
// Custom tooltip with proper theme colors
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'hsl(var(--popover) / 0.95)',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
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({
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
}: 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 (
|
||||
<ChartCard
|
||||
@@ -55,42 +72,31 @@ export function OrganizationDistributionChart({
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--border))"
|
||||
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
|
||||
tickLine={{ stroke: 'hsl(var(--border))' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--border))"
|
||||
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
|
||||
tickLine={{ stroke: 'hsl(var(--border))' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
}}
|
||||
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
paddingTop: '20px',
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
name="Total Members"
|
||||
fill={CHART_PALETTES.bar[0]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
{!hasData && !loading && !error ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
<p>No organization data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={rawData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" style={{ stroke: 'var(--muted)', opacity: 0.2 }} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }}
|
||||
/>
|
||||
<YAxis style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={CHART_PALETTES.bar[0]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
activeBar={{ fill: CHART_PALETTES.bar[0] }}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
117
frontend/src/components/charts/RegistrationActivityChart.tsx
Normal file
117
frontend/src/components/charts/RegistrationActivityChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,19 +5,18 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { ChartCard } from './ChartCard';
|
||||
import { CHART_PALETTES } from '@/lib/chart-colors';
|
||||
import {
|
||||
LineChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { ChartCard } from './ChartCard';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import { CHART_PALETTES } from '@/lib/chart-colors';
|
||||
|
||||
export interface UserGrowthData {
|
||||
date: string;
|
||||
@@ -25,32 +24,61 @@ export interface UserGrowthData {
|
||||
active_users: number;
|
||||
}
|
||||
|
||||
interface UserGrowthChartProps {
|
||||
export interface UserGrowthChartProps {
|
||||
data?: UserGrowthData[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Generate mock data for development/demo
|
||||
function generateMockData(): UserGrowthData[] {
|
||||
const data: UserGrowthData[] = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = subDays(today, i);
|
||||
const baseUsers = 100 + i * 3;
|
||||
data.push({
|
||||
date: format(date, 'MMM d'),
|
||||
total_users: baseUsers + Math.floor(Math.random() * 10),
|
||||
active_users: Math.floor(baseUsers * 0.8) + Math.floor(Math.random() * 5),
|
||||
});
|
||||
// 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' }}
|
||||
>
|
||||
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 data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
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 (
|
||||
<ChartCard
|
||||
@@ -59,54 +87,51 @@ export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps)
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--border))"
|
||||
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
|
||||
tickLine={{ stroke: 'hsl(var(--border))' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--border))"
|
||||
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
|
||||
tickLine={{ stroke: 'hsl(var(--border))' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
}}
|
||||
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
paddingTop: '20px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total_users"
|
||||
name="Total Users"
|
||||
stroke={CHART_PALETTES.line[0]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="active_users"
|
||||
name="Active Users"
|
||||
stroke={CHART_PALETTES.line[1]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{!hasData && !loading && !error ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
<p>No user growth data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={rawData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<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' }}
|
||||
label={{
|
||||
value: 'Users',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fill: 'var(--muted-foreground)', textAnchor: 'middle' },
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
paddingTop: '20px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total_users"
|
||||
name="Total Users"
|
||||
stroke={CHART_PALETTES.line[0]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="active_users"
|
||||
name="Active Users"
|
||||
stroke={CHART_PALETTES.line[1]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,16 +21,6 @@ interface UserStatusChartProps {
|
||||
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
|
||||
const renderLabel = (entry: { percent: number; name: string }) => {
|
||||
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) {
|
||||
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
|
||||
const chartData = rawData.map((item, index) => ({
|
||||
@@ -53,41 +45,47 @@ export function UserStatusChart({ data, loading, error }: UserStatusChartProps)
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
}}
|
||||
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
wrapperStyle={{
|
||||
paddingTop: '20px',
|
||||
color: 'hsl(var(--foreground))',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{!hasData && !loading && !error ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
<p>No user status data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
}}
|
||||
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
wrapperStyle={{
|
||||
paddingTop: '20px',
|
||||
color: 'hsl(var(--foreground))',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ export { ChartCard } from './ChartCard';
|
||||
export { UserGrowthChart } from './UserGrowthChart';
|
||||
export type { UserGrowthData } from './UserGrowthChart';
|
||||
export { OrganizationDistributionChart } from './OrganizationDistributionChart';
|
||||
export type { OrganizationDistributionData } from './OrganizationDistributionChart';
|
||||
export { SessionActivityChart } from './SessionActivityChart';
|
||||
export type { SessionActivityData } from './SessionActivityChart';
|
||||
export type { OrgDistributionData } from './OrganizationDistributionChart';
|
||||
export { RegistrationActivityChart } from './RegistrationActivityChart';
|
||||
export type { RegistrationActivityData } from './RegistrationActivityChart';
|
||||
export { UserStatusChart } from './UserStatusChart';
|
||||
export type { UserStatusData } from './UserStatusChart';
|
||||
|
||||
68
frontend/src/components/demo/DemoModeBanner.tsx
Normal file
68
frontend/src/components/demo/DemoModeBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/demo/index.ts
Normal file
5
frontend/src/components/demo/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Demo components exports
|
||||
*/
|
||||
|
||||
export { DemoModeBanner } from './DemoModeBanner';
|
||||
@@ -30,16 +30,18 @@ export function CodeBlock({ children, className, title }: CodeBlockProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative my-6">
|
||||
<div className="group relative my-6 rounded-lg border bg-[#282c34] text-slate-50">
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('relative', title && 'rounded-t-none')}>
|
||||
<pre
|
||||
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',
|
||||
className
|
||||
)}
|
||||
@@ -49,14 +51,14 @@ export function CodeBlock({ children, className, title }: CodeBlockProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,18 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { cn } from '@/lib/utils';
|
||||
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 {
|
||||
content: string;
|
||||
@@ -23,19 +35,35 @@ interface MarkdownContentProps {
|
||||
|
||||
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={cn('prose prose-neutral dark:prose-invert max-w-none', className)}>
|
||||
<div className={cn('max-w-none text-foreground', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeHighlight,
|
||||
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={{
|
||||
// Headings - improved spacing and visual hierarchy
|
||||
h1: ({ children, ...props }) => (
|
||||
<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}
|
||||
>
|
||||
{children}
|
||||
@@ -43,7 +71,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<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}
|
||||
>
|
||||
{children}
|
||||
@@ -51,7 +79,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<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}
|
||||
>
|
||||
{children}
|
||||
@@ -59,12 +87,28 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<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}
|
||||
>
|
||||
{children}
|
||||
</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
|
||||
p: ({ children, ...props }) => (
|
||||
@@ -84,15 +128,32 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
),
|
||||
|
||||
// Links - more prominent with better hover state
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="font-medium text-primary underline decoration-primary/30 underline-offset-4 hover:decoration-primary/60 hover:text-primary/90 transition-all"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
a: ({ children, href, className, ...props }) => {
|
||||
// Check if this is an anchor link generated by rehype-autolink-headings
|
||||
const isAnchor = className?.includes('subtle-anchor');
|
||||
|
||||
if (isAnchor) {
|
||||
return (
|
||||
<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
|
||||
ul: ({ children, ...props }) => (
|
||||
@@ -127,12 +188,12 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
}) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
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"
|
||||
{...props}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="font-mono text-sm font-medium px-1.5 py-0.5 h-auto rounded-md"
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -143,58 +204,42 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
},
|
||||
pre: ({ children, ...props }) => <CodeBlock {...props}>{children}</CodeBlock>,
|
||||
|
||||
// Blockquotes - enhanced callout styling
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote
|
||||
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"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
// Blockquotes - enhanced callout styling using Alert
|
||||
blockquote: ({ children }) => (
|
||||
<Alert className="my-8 border-l-4 border-l-primary/50 bg-primary/5">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="italic text-foreground/80 ml-2">
|
||||
{children}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
|
||||
// Tables - improved styling with better borders and hover states
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-8 w-full overflow-x-auto rounded-lg border">
|
||||
<table className="w-full border-collapse text-sm" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
<Table {...props}>{children}</Table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children, ...props }) => (
|
||||
<thead className="bg-muted/80 border-b-2 border-border" {...props}>
|
||||
<TableHeader className="bg-muted/80" {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
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>
|
||||
</TableHeader>
|
||||
),
|
||||
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
|
||||
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="px-5 py-3.5 text-left font-semibold text-foreground [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
<TableHead className="font-semibold text-foreground" {...props}>
|
||||
{children}
|
||||
</th>
|
||||
</TableHead>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="px-5 py-3.5 text-foreground/80 [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
<TableCell className="text-foreground/80" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
</TableCell>
|
||||
),
|
||||
|
||||
// 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
|
||||
img: ({ src, alt }) => {
|
||||
|
||||
@@ -27,8 +27,8 @@ export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProp
|
||||
const [copiedRegular, setCopiedRegular] = useState(false);
|
||||
const [copiedAdmin, setCopiedAdmin] = useState(false);
|
||||
|
||||
const regularCredentials = 'demo@example.com\nDemo123!';
|
||||
const adminCredentials = 'admin@example.com\nAdmin123!';
|
||||
const regularCredentials = 'demo@example.com\nDemoPass1234!';
|
||||
const adminCredentials = 'admin@example.com\nAdminPass1234!';
|
||||
|
||||
const copyToClipboard = async (text: string, type: 'regular' | 'admin') => {
|
||||
try {
|
||||
@@ -83,7 +83,7 @@ export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProp
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -123,7 +123,7 @@ export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProp
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -141,12 +141,12 @@ export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProp
|
||||
<DialogFooter>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 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
|
||||
</Link>
|
||||
</Button>
|
||||
<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
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
83
frontend/src/components/providers/MSWProvider.tsx
Normal file
83
frontend/src/components/providers/MSWProvider.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -71,6 +71,7 @@ const ENV = {
|
||||
ENABLE_REGISTRATION: process.env.NEXT_PUBLIC_ENABLE_REGISTRATION,
|
||||
ENABLE_SESSION_MANAGEMENT: process.env.NEXT_PUBLIC_ENABLE_SESSION_MANAGEMENT,
|
||||
DEBUG_API: process.env.NEXT_PUBLIC_DEBUG_API,
|
||||
DEMO_MODE: process.env.NEXT_PUBLIC_DEMO_MODE,
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
} as const;
|
||||
|
||||
@@ -118,6 +119,16 @@ export const config = {
|
||||
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: {
|
||||
isDevelopment: ENV.NODE_ENV === 'development',
|
||||
isProduction: ENV.NODE_ENV === 'production',
|
||||
|
||||
@@ -2,44 +2,57 @@ import { apiClient } from './client';
|
||||
import type { Options } from './generated/sdk.gen';
|
||||
|
||||
export interface UserGrowthData {
|
||||
date: string;
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
date: string;
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
}
|
||||
|
||||
export interface OrgDistributionData {
|
||||
name: string;
|
||||
value: number;
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface RegistrationActivityData {
|
||||
date: string;
|
||||
registrations: number;
|
||||
}
|
||||
|
||||
export interface UserStatusData {
|
||||
name: string;
|
||||
value: number;
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AdminStatsResponse {
|
||||
user_growth: UserGrowthData[];
|
||||
organization_distribution: OrgDistributionData[];
|
||||
user_status: UserStatusData[];
|
||||
user_growth: UserGrowthData[];
|
||||
organization_distribution: OrgDistributionData[];
|
||||
registration_activity: RegistrationActivityData[];
|
||||
user_status: UserStatusData[];
|
||||
}
|
||||
|
||||
export type AdminStatsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/admin/stats';
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin: Get Dashboard Stats
|
||||
*
|
||||
* Get aggregated statistics for the admin dashboard (admin only)
|
||||
*/
|
||||
export const getAdminStats = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<any, ThrowOnError>
|
||||
options?: Options<AdminStatsData, ThrowOnError>
|
||||
) => {
|
||||
return (options?.client ?? apiClient).get<AdminStatsResponse, any, ThrowOnError>({
|
||||
responseType: 'json',
|
||||
security: [
|
||||
{
|
||||
scheme: 'bearer',
|
||||
type: 'http',
|
||||
},
|
||||
],
|
||||
url: '/api/v1/admin/stats',
|
||||
...options,
|
||||
});
|
||||
return (options?.client ?? apiClient).get<AdminStatsResponse, unknown, ThrowOnError>({
|
||||
responseType: 'json',
|
||||
security: [
|
||||
{
|
||||
scheme: 'bearer',
|
||||
type: 'http',
|
||||
},
|
||||
],
|
||||
url: '/api/v1/admin/stats',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client';
|
||||
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> & {
|
||||
/**
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -93,6 +93,28 @@ export type AdminSessionResponse = {
|
||||
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
|
||||
*/
|
||||
@@ -234,6 +256,20 @@ export type MessageResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* OrgDistributionData
|
||||
*/
|
||||
export type OrgDistributionData = {
|
||||
/**
|
||||
* Name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Value
|
||||
*/
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* OrganizationCreate
|
||||
*
|
||||
@@ -550,6 +586,20 @@ export type RefreshTokenRequest = {
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* RegistrationActivityData
|
||||
*/
|
||||
export type RegistrationActivityData = {
|
||||
/**
|
||||
* Date
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* Registrations
|
||||
*/
|
||||
registrations: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* SessionListResponse
|
||||
*
|
||||
@@ -682,6 +732,28 @@ export type UserCreate = {
|
||||
* Is Superuser
|
||||
*/
|
||||
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?: string | null;
|
||||
/**
|
||||
* Locale
|
||||
*/
|
||||
locale?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* UserStatusData
|
||||
*/
|
||||
export type UserStatusData = {
|
||||
/**
|
||||
* Name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Value
|
||||
*/
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -752,6 +842,12 @@ export type UserUpdate = {
|
||||
preferences?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Locale
|
||||
*
|
||||
* User's preferred locale (BCP 47 format: en, it, en-US, it-IT)
|
||||
*/
|
||||
locale?: string | null;
|
||||
/**
|
||||
* Is Active
|
||||
*/
|
||||
@@ -1270,6 +1366,22 @@ export type 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 = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -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/(.*)',
|
||||
],
|
||||
};
|
||||
94
frontend/src/mocks/browser.ts
Normal file
94
frontend/src/mocks/browser.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
166
frontend/src/mocks/data/organizations.ts
Normal file
166
frontend/src/mocks/data/organizations.ts
Normal 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] || [];
|
||||
}
|
||||
91
frontend/src/mocks/data/stats.ts
Normal file
91
frontend/src/mocks/data/stats.ts
Normal 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,
|
||||
};
|
||||
134
frontend/src/mocks/data/users.ts
Normal file
134
frontend/src/mocks/data/users.ts
Normal 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;
|
||||
}
|
||||
583
frontend/src/mocks/handlers/generated.ts
Normal file
583
frontend/src/mocks/handlers/generated.ts
Normal 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'
|
||||
});
|
||||
}),
|
||||
];
|
||||
21
frontend/src/mocks/handlers/index.ts
Normal file
21
frontend/src/mocks/handlers/index.ts
Normal 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];
|
||||
104
frontend/src/mocks/handlers/overrides.ts
Normal file
104
frontend/src/mocks/handlers/overrides.ts
Normal 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,
|
||||
});
|
||||
}),
|
||||
];
|
||||
20
frontend/src/mocks/index.ts
Normal file
20
frontend/src/mocks/index.ts
Normal 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';
|
||||
28
frontend/src/mocks/utils/tokens.ts
Normal file
28
frontend/src/mocks/utils/tokens.ts
Normal 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}`;
|
||||
}
|
||||
@@ -5,28 +5,15 @@
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
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
|
||||
jest.mock('@/lib/api/hooks/useAdmin');
|
||||
|
||||
// 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({
|
||||
jest.mock('@/lib/api/hooks/useAdmin', () => ({
|
||||
useAdminStats: () => ({
|
||||
data: {
|
||||
totalUsers: 100,
|
||||
activeUsers: 80,
|
||||
@@ -36,9 +23,46 @@ function renderWithMockedStats() {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
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);
|
||||
|
||||
return render(<AdminPage />);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AdminPage />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AdminPage', () => {
|
||||
@@ -117,7 +141,7 @@ describe('AdminPage', () => {
|
||||
|
||||
expect(screen.getByTestId('user-growth-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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('DemoTourPage', () => {
|
||||
// Check for credentials
|
||||
const authCards = screen.getAllByText(/demo@example\.com/i);
|
||||
expect(authCards.length).toBeGreaterThan(0);
|
||||
const demo123 = screen.getAllByText(/Demo123!/i);
|
||||
const demo123 = screen.getAllByText(/DemoPass1234!/i);
|
||||
expect(demo123.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('DemoTourPage', () => {
|
||||
|
||||
// Check for admin credentials
|
||||
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', () => {
|
||||
|
||||
@@ -47,6 +47,34 @@ jest.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
|
||||
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('Page Structure', () => {
|
||||
it('renders without crashing', () => {
|
||||
@@ -60,7 +88,6 @@ describe('HomePage', () => {
|
||||
render(<Home />);
|
||||
const header = screen.getByRole('banner');
|
||||
expect(within(header).getByText('PragmaStack')).toBeInTheDocument();
|
||||
expect(within(header).getByText('Template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer with copyright', () => {
|
||||
@@ -79,30 +106,26 @@ describe('HomePage', () => {
|
||||
|
||||
it('renders production-ready messaging', () => {
|
||||
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 />);
|
||||
const coverageTexts = screen.getAllByText('97%');
|
||||
expect(coverageTexts.length).toBeGreaterThan(0);
|
||||
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();
|
||||
expect(screen.getByText('Comprehensive Tests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pragmatic by Design')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context Section', () => {
|
||||
it('renders what you get message', () => {
|
||||
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', () => {
|
||||
render(<Home />);
|
||||
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(/Zero Commercial Dependencies/i)[0]).toBeInTheDocument();
|
||||
});
|
||||
@@ -167,7 +190,7 @@ describe('HomePage', () => {
|
||||
describe('Tech Stack Section', () => {
|
||||
it('renders tech stack heading', () => {
|
||||
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', () => {
|
||||
@@ -186,7 +209,7 @@ describe('HomePage', () => {
|
||||
describe('Philosophy Section', () => {
|
||||
it('renders why this template exists', () => {
|
||||
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', () => {
|
||||
@@ -198,7 +221,7 @@ describe('HomePage', () => {
|
||||
it('renders what you will find section', () => {
|
||||
render(<Home />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,11 +32,15 @@ jest.mock('@/lib/api/hooks/useAuth', () => ({
|
||||
useLogin: () => mockUseLogin(),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
// Mock router
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: jest.fn(),
|
||||
}),
|
||||
useSearchParams: () => ({
|
||||
get: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
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
|
||||
jest.mock('recharts', () => {
|
||||
@@ -18,10 +18,10 @@ jest.mock('recharts', () => {
|
||||
});
|
||||
|
||||
describe('OrganizationDistributionChart', () => {
|
||||
const mockData: OrganizationDistributionData[] = [
|
||||
{ name: 'Engineering', members: 45, activeMembers: 42 },
|
||||
{ name: 'Marketing', members: 28, activeMembers: 25 },
|
||||
{ name: 'Sales', members: 35, activeMembers: 33 },
|
||||
const mockData: OrgDistributionData[] = [
|
||||
{ name: 'Engineering', value: 45 },
|
||||
{ name: 'Marketing', value: 28 },
|
||||
{ name: 'Sales', value: 35 },
|
||||
];
|
||||
|
||||
it('renders chart card with title and description', () => {
|
||||
@@ -41,7 +41,7 @@ describe('OrganizationDistributionChart', () => {
|
||||
render(<OrganizationDistributionChart />);
|
||||
|
||||
expect(screen.getByText('Organization Distribution')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
|
||||
expect(screen.getByText('No organization data available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
@@ -67,6 +67,6 @@ describe('OrganizationDistributionChart', () => {
|
||||
render(<OrganizationDistributionChart data={[]} />);
|
||||
|
||||
expect(screen.getByText('Organization Distribution')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
|
||||
expect(screen.getByText('No organization data available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,9 @@ jest.mock('recharts', () => {
|
||||
|
||||
describe('UserGrowthChart', () => {
|
||||
const mockData: UserGrowthData[] = [
|
||||
{ date: 'Jan 1', totalUsers: 100, activeUsers: 80 },
|
||||
{ date: 'Jan 2', totalUsers: 105, activeUsers: 85 },
|
||||
{ date: 'Jan 3', totalUsers: 110, activeUsers: 90 },
|
||||
{ date: 'Jan 1', total_users: 100, active_users: 80 },
|
||||
{ date: 'Jan 2', total_users: 105, active_users: 85 },
|
||||
{ date: 'Jan 3', total_users: 110, active_users: 90 },
|
||||
];
|
||||
|
||||
it('renders chart card with title and description', () => {
|
||||
@@ -41,7 +41,7 @@ describe('UserGrowthChart', () => {
|
||||
render(<UserGrowthChart />);
|
||||
|
||||
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', () => {
|
||||
@@ -67,6 +67,6 @@ describe('UserGrowthChart', () => {
|
||||
render(<UserGrowthChart data={[]} />);
|
||||
|
||||
expect(screen.getByText('User Growth')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
|
||||
expect(screen.getByText('No user growth data available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('UserStatusChart', () => {
|
||||
render(<UserStatusChart />);
|
||||
|
||||
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', () => {
|
||||
@@ -86,7 +86,7 @@ describe('UserStatusChart', () => {
|
||||
render(<UserStatusChart data={[]} />);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('DemoCredentialsModal', () => {
|
||||
|
||||
expect(screen.getByText('Regular User')).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();
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('DemoCredentialsModal', () => {
|
||||
|
||||
expect(screen.getByText('Admin User (Superuser)')).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();
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('DemoCredentialsModal', () => {
|
||||
fireEvent.click(regularCopyButton!);
|
||||
|
||||
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 copiedButton = copiedButtons.find((btn) => btn.textContent?.includes('Copied!'));
|
||||
expect(copiedButton).toBeInTheDocument();
|
||||
@@ -86,7 +86,7 @@ describe('DemoCredentialsModal', () => {
|
||||
fireEvent.click(adminCopyButton!);
|
||||
|
||||
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 copiedButton = copiedButtons.find((btn) => btn.textContent?.includes('Copied!'));
|
||||
expect(copiedButton).toBeInTheDocument();
|
||||
@@ -156,10 +156,16 @@ describe('DemoCredentialsModal', () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
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 });
|
||||
expect(loginAsAdminLink).toHaveAttribute('href', '/login');
|
||||
expect(loginAsAdminLink).toHaveAttribute(
|
||||
'href',
|
||||
'/login?email=admin@example.com&password=AdminPass1234!'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onClose when login link is clicked', () => {
|
||||
|
||||
@@ -27,6 +27,24 @@ jest.mock('@/components/home/DemoCredentialsModal', () => ({
|
||||
) : 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', () => {
|
||||
it('renders logo', () => {
|
||||
render(
|
||||
@@ -38,7 +56,6 @@ describe('Header', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('PragmaStack')).toBeInTheDocument();
|
||||
expect(screen.getByText('Template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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', '/');
|
||||
});
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ describe('HeroSection', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('MIT Licensed')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('97% Test Coverage')[0]).toBeInTheDocument();
|
||||
expect(screen.getByText('Production Ready')).toBeInTheDocument();
|
||||
expect(screen.getByText('Comprehensive Tests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pragmatic by Design')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main headline', () => {
|
||||
@@ -60,8 +60,8 @@ describe('HeroSection', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/Everything You Need to Build/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Modern Web Applications/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getByText(/The Pragmatic/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Full-Stack Template/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -118,26 +118,6 @@ describe('HeroSection', () => {
|
||||
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', () => {
|
||||
const mockOnOpenDemoModal = jest.fn();
|
||||
render(<HeroSection onOpenDemoModal={mockOnOpenDemoModal} />);
|
||||
|
||||
@@ -35,21 +35,25 @@ describe('StatsSection', () => {
|
||||
it('renders all stat cards', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
expect(screen.getByText('Test Coverage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Passing Tests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Flaky Tests')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Endpoints')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open Source')).toBeInTheDocument();
|
||||
expect(screen.getByText('Type Safe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Doc Guides')).toBeInTheDocument();
|
||||
expect(screen.getByText('Magic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays stat descriptions', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
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();
|
||||
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', () => {
|
||||
@@ -69,7 +73,7 @@ describe('StatsSection', () => {
|
||||
|
||||
// After animation, we should see the final 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();
|
||||
});
|
||||
|
||||
@@ -78,17 +82,17 @@ describe('StatsSection', () => {
|
||||
|
||||
// Icons are rendered via lucide-react components
|
||||
// We can verify the stat cards are rendered with proper structure
|
||||
const testCoverageCard = screen.getByText('Test Coverage').closest('div');
|
||||
expect(testCoverageCard).toBeInTheDocument();
|
||||
const openSourceCard = screen.getByText('Open Source').closest('div');
|
||||
expect(openSourceCard).toBeInTheDocument();
|
||||
|
||||
const passingTestsCard = screen.getByText('Passing Tests').closest('div');
|
||||
expect(passingTestsCard).toBeInTheDocument();
|
||||
const typeSafeCard = screen.getByText('Type Safe').closest('div');
|
||||
expect(typeSafeCard).toBeInTheDocument();
|
||||
|
||||
const flakyTestsCard = screen.getByText('Flaky Tests').closest('div');
|
||||
expect(flakyTestsCard).toBeInTheDocument();
|
||||
const docGuidesCard = screen.getByText('Doc Guides').closest('div');
|
||||
expect(docGuidesCard).toBeInTheDocument();
|
||||
|
||||
const apiEndpointsCard = screen.getByText('API Endpoints').closest('div');
|
||||
expect(apiEndpointsCard).toBeInTheDocument();
|
||||
const magicCard = screen.getByText('Magic').closest('div');
|
||||
expect(magicCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
@@ -102,7 +106,7 @@ describe('StatsSection', () => {
|
||||
it('has descriptive labels for stats', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
const statLabels = ['Test Coverage', 'Passing Tests', 'Flaky Tests', 'API Endpoints'];
|
||||
const statLabels = ['Open Source', 'Type Safe', 'Doc Guides', 'Magic'];
|
||||
|
||||
statLabels.forEach((label) => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user