forked from cardosofelipe/fast-next-template
Remove MSW handlers and update demo credentials for improved standardization
- Deleted `admin.ts`, `auth.ts`, and `users.ts` MSW handler files to streamline demo mode setup. - Updated demo credentials logic in `DemoCredentialsModal` and `DemoModeBanner` for stronger password requirements (≥12 characters). - Refined documentation in `CLAUDE.md` to align with new credential standards and auto-generated MSW workflows.
This commit is contained in:
@@ -177,7 +177,10 @@ with patch.object(session, 'commit', side_effect=mock_commit):
|
||||
- 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
|
||||
- Demo credentials:
|
||||
- **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)
|
||||
|
||||
@@ -32,6 +32,16 @@
|
||||
}
|
||||
],
|
||||
"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!",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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! 🚀
|
||||
505
frontend/package-lock.json
generated
505
frontend/package-lock.json
generated
@@ -74,6 +74,7 @@
|
||||
"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"
|
||||
@@ -816,6 +817,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
@@ -8451,6 +8894,48 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -17115,6 +17600,26 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.6",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
|
||||
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"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"
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ export { ChartCard } from './ChartCard';
|
||||
export { UserGrowthChart } from './UserGrowthChart';
|
||||
export type { UserGrowthData } from './UserGrowthChart';
|
||||
export { OrganizationDistributionChart } from './OrganizationDistributionChart';
|
||||
export type { OrganizationDistributionData } from './OrganizationDistributionChart';
|
||||
export type { OrgDistributionData } from './OrganizationDistributionChart';
|
||||
export { RegistrationActivityChart } from './RegistrationActivityChart';
|
||||
export type { RegistrationActivityData } from './RegistrationActivityChart';
|
||||
export { UserStatusChart } from './UserStatusChart';
|
||||
|
||||
@@ -44,7 +44,7 @@ export function DemoModeBanner() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Demo Credentials (any password ≥8 chars works):
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -124,8 +124,8 @@ export const config = {
|
||||
enabled: parseBool(ENV.DEMO_MODE, false),
|
||||
// Demo credentials
|
||||
credentials: {
|
||||
user: { email: 'demo@example.com', password: 'DemoPass123' },
|
||||
admin: { email: 'admin@example.com', password: 'AdminPass123' },
|
||||
user: { email: 'demo@example.com', password: 'DemoPass1234!' },
|
||||
admin: { email: 'admin@example.com', password: 'AdminPass1234!' },
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { UserResponse } from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* Demo user (regular user)
|
||||
* Credentials: demo@example.com / DemoPass123
|
||||
* Credentials: demo@example.com / DemoPass1234!
|
||||
*/
|
||||
export const demoUser: UserResponse = {
|
||||
id: 'demo-user-id-1',
|
||||
@@ -20,13 +20,11 @@ export const demoUser: UserResponse = {
|
||||
is_superuser: false,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
updated_at: '2024-01-20T15:30:00Z',
|
||||
last_login: '2025-01-24T08:00:00Z',
|
||||
organization_count: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo admin user (superuser)
|
||||
* Credentials: admin@example.com / AdminPass123
|
||||
* Credentials: admin@example.com / AdminPass1234!
|
||||
*/
|
||||
export const demoAdmin: UserResponse = {
|
||||
id: 'demo-admin-id-1',
|
||||
@@ -38,8 +36,6 @@ export const demoAdmin: UserResponse = {
|
||||
is_superuser: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-24T10:00:00Z',
|
||||
last_login: '2025-01-24T09:00:00Z',
|
||||
organization_count: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -58,8 +54,6 @@ export const sampleUsers: UserResponse[] = [
|
||||
is_superuser: false,
|
||||
created_at: '2024-02-01T12:00:00Z',
|
||||
updated_at: '2024-02-05T14:30:00Z',
|
||||
last_login: '2025-01-23T16:45:00Z',
|
||||
organization_count: 1,
|
||||
},
|
||||
{
|
||||
id: 'user-4',
|
||||
@@ -71,8 +65,6 @@ export const sampleUsers: UserResponse[] = [
|
||||
is_superuser: false,
|
||||
created_at: '2024-03-10T08:30:00Z',
|
||||
updated_at: '2024-03-15T11:00:00Z',
|
||||
last_login: '2025-01-22T10:20:00Z',
|
||||
organization_count: 3,
|
||||
},
|
||||
{
|
||||
id: 'user-5',
|
||||
@@ -84,8 +76,6 @@ export const sampleUsers: UserResponse[] = [
|
||||
is_superuser: false,
|
||||
created_at: '2024-01-20T14:00:00Z',
|
||||
updated_at: '2024-06-01T09:00:00Z',
|
||||
last_login: '2024-06-01T09:00:00Z',
|
||||
organization_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -120,23 +110,23 @@ export function updateCurrentUser(updates: Partial<UserResponse>) {
|
||||
* 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 >= 8 chars
|
||||
// Demo user - accept documented password or any password >= 12 chars
|
||||
if (email === 'demo@example.com') {
|
||||
if (password === 'DemoPass123' || password.length >= 8) {
|
||||
if (password === 'DemoPass1234!' || password.length >= 12) {
|
||||
return demoUser;
|
||||
}
|
||||
}
|
||||
|
||||
// Demo admin - accept documented password or any password >= 8 chars
|
||||
// Demo admin - accept documented password or any password >= 12 chars
|
||||
if (email === 'admin@example.com') {
|
||||
if (password === 'AdminPass123' || password.length >= 8) {
|
||||
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 >= 8) {
|
||||
if (user && password.length >= 12) {
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
/**
|
||||
* MSW Admin Endpoint Handlers
|
||||
*
|
||||
* Handles admin dashboard, user management, org management
|
||||
* Only accessible to superusers (is_superuser = true)
|
||||
*/
|
||||
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import type {
|
||||
UserResponse,
|
||||
OrganizationResponse,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
AdminStatsResponse,
|
||||
BulkUserAction,
|
||||
BulkActionResult,
|
||||
} from '@/lib/api/client';
|
||||
import { currentUser, sampleUsers } from '../data/users';
|
||||
import { sampleOrganizations, 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 = 200;
|
||||
|
||||
/**
|
||||
* Check if request is from a superuser
|
||||
*/
|
||||
function isSuperuser(request: Request): boolean {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentUser?.is_superuser === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin endpoint handlers
|
||||
*/
|
||||
export const adminHandlers = [
|
||||
/**
|
||||
* GET /api/v1/admin/stats - Get dashboard statistics
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/admin/stats`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json(adminStats);
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/users - List all users (paginated)
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/admin/users`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
|
||||
const search = url.searchParams.get('search') || '';
|
||||
const isActive = url.searchParams.get('is_active');
|
||||
|
||||
// Filter users
|
||||
let filteredUsers = [...sampleUsers];
|
||||
|
||||
if (search) {
|
||||
filteredUsers = filteredUsers.filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.first_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.last_name?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isActive !== null) {
|
||||
const activeFilter = isActive === 'true';
|
||||
filteredUsers = filteredUsers.filter((u) => u.is_active === activeFilter);
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
const paginatedUsers = filteredUsers.slice(start, end);
|
||||
|
||||
return HttpResponse.json({
|
||||
data: paginatedUsers,
|
||||
pagination: {
|
||||
total: filteredUsers.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: Math.ceil(filteredUsers.length / pageSize),
|
||||
has_next: end < filteredUsers.length,
|
||||
has_prev: page > 1,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/users/:id - Get user by ID
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/admin/users/:id`, async ({ request, params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
const user = sampleUsers.find((u) => u.id === id);
|
||||
|
||||
if (!user) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json(user);
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/users - Create new user
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/admin/users`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as UserCreate;
|
||||
|
||||
// Check if email exists
|
||||
if (sampleUsers.some((u) => u.email === body.email)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User with this email already exists',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create user (in-memory, will be lost on reload)
|
||||
const newUser: UserResponse = {
|
||||
id: `user-new-${Date.now()}`,
|
||||
email: body.email,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name || null,
|
||||
phone_number: body.phone_number || null,
|
||||
is_active: body.is_active !== false,
|
||||
is_superuser: body.is_superuser === true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
last_login: null,
|
||||
organization_count: 0,
|
||||
};
|
||||
|
||||
sampleUsers.push(newUser);
|
||||
|
||||
return HttpResponse.json(newUser, { status: 201 });
|
||||
}),
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/users/:id - Update user
|
||||
*/
|
||||
http.patch(`${API_BASE_URL}/api/v1/admin/users/:id`, async ({ request, params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
const userIndex = sampleUsers.findIndex((u) => u.id === id);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as UserUpdate;
|
||||
|
||||
// Update user
|
||||
sampleUsers[userIndex] = {
|
||||
...sampleUsers[userIndex],
|
||||
...body,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return HttpResponse.json(sampleUsers[userIndex]);
|
||||
}),
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/users/:id - Delete user
|
||||
*/
|
||||
http.delete(`${API_BASE_URL}/api/v1/admin/users/:id`, async ({ request, params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
const userIndex = sampleUsers.findIndex((u) => u.id === id);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
sampleUsers.splice(userIndex, 1);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: 'User deleted successfully',
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/users/bulk - Bulk user action
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/admin/users/bulk`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as BulkUserAction;
|
||||
const { action, user_ids } = body;
|
||||
|
||||
let affected = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const userId of user_ids) {
|
||||
const userIndex = sampleUsers.findIndex((u) => u.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
switch (action) {
|
||||
case 'activate':
|
||||
sampleUsers[userIndex].is_active = true;
|
||||
affected++;
|
||||
break;
|
||||
case 'deactivate':
|
||||
sampleUsers[userIndex].is_active = false;
|
||||
affected++;
|
||||
break;
|
||||
case 'delete':
|
||||
sampleUsers.splice(userIndex, 1);
|
||||
affected++;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
const result: BulkActionResult = {
|
||||
success: failed === 0,
|
||||
affected_count: affected,
|
||||
failed_count: failed,
|
||||
message: `${action} completed: ${affected} users affected`,
|
||||
failed_ids: [],
|
||||
};
|
||||
|
||||
return HttpResponse.json(result);
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/organizations - List all organizations (paginated)
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/admin/organizations`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
|
||||
const search = url.searchParams.get('search') || '';
|
||||
|
||||
// Filter organizations
|
||||
let filteredOrgs = [...sampleOrganizations];
|
||||
|
||||
if (search) {
|
||||
filteredOrgs = filteredOrgs.filter(
|
||||
(o) =>
|
||||
o.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
o.slug.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
const paginatedOrgs = filteredOrgs.slice(start, end);
|
||||
|
||||
return HttpResponse.json({
|
||||
data: paginatedOrgs,
|
||||
pagination: {
|
||||
total: filteredOrgs.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: Math.ceil(filteredOrgs.length / pageSize),
|
||||
has_next: end < filteredOrgs.length,
|
||||
has_prev: page > 1,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/:id - Get organization by ID
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/admin/organizations/:id`, async ({ request, params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
const org = sampleOrganizations.find((o) => o.id === id);
|
||||
|
||||
if (!org) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Organization not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json(org);
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/:id/members - Get organization members
|
||||
*/
|
||||
http.get(
|
||||
`${API_BASE_URL}/api/v1/admin/organizations/:id/members`,
|
||||
async ({ request, params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params as { id: string };
|
||||
const members = getOrganizationMembersList(id);
|
||||
|
||||
// Parse pagination params
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('page_size') || '20');
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
const paginatedMembers = members.slice(start, end);
|
||||
|
||||
return HttpResponse.json({
|
||||
data: paginatedMembers,
|
||||
pagination: {
|
||||
total: members.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: Math.ceil(members.length / pageSize),
|
||||
has_next: end < members.length,
|
||||
has_prev: page > 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
),
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/sessions - Get all sessions (admin view)
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/admin/sessions`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isSuperuser(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Admin access required',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Mock session data
|
||||
const sessions = [
|
||||
{
|
||||
id: 'session-1',
|
||||
user_id: 'demo-user-id-1',
|
||||
user_email: 'demo@example.com',
|
||||
user_full_name: 'Demo User',
|
||||
device_name: 'Chrome on macOS',
|
||||
device_id: 'device-1',
|
||||
ip_address: '192.168.1.100',
|
||||
location_city: 'San Francisco',
|
||||
location_country: 'United States',
|
||||
last_used_at: new Date().toISOString(),
|
||||
created_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
is_active: true,
|
||||
},
|
||||
];
|
||||
|
||||
return HttpResponse.json({
|
||||
data: sessions,
|
||||
pagination: {
|
||||
total: sessions.length,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -1,324 +0,0 @@
|
||||
/**
|
||||
* MSW Auth Endpoint Handlers
|
||||
*
|
||||
* Mirrors backend auth endpoints for demo mode
|
||||
* Consistent with E2E test mocks in e2e/helpers/auth.ts
|
||||
*/
|
||||
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import type {
|
||||
LoginRequest,
|
||||
TokenResponse,
|
||||
UserCreate,
|
||||
RegisterResponse,
|
||||
RefreshTokenRequest,
|
||||
LogoutRequest,
|
||||
MessageResponse,
|
||||
PasswordResetRequest,
|
||||
PasswordResetConfirm,
|
||||
} from '@/lib/api/client';
|
||||
import {
|
||||
validateCredentials,
|
||||
setCurrentUser,
|
||||
currentUser,
|
||||
demoUser,
|
||||
demoAdmin,
|
||||
sampleUsers,
|
||||
} from '../data/users';
|
||||
|
||||
// API base URL (same as app config)
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
// Simulate network delay (realistic UX)
|
||||
const NETWORK_DELAY = 300;
|
||||
|
||||
// In-memory session store (resets on page reload, which is fine for demo)
|
||||
let activeTokens = new Set<string>();
|
||||
|
||||
/**
|
||||
* Auth endpoint handlers
|
||||
*/
|
||||
export const authHandlers = [
|
||||
/**
|
||||
* POST /api/v1/auth/register - Register new user
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/auth/register`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = (await request.json()) as UserCreate;
|
||||
|
||||
// Validate required fields
|
||||
if (!body.email || !body.password || !body.first_name) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Missing required fields',
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingUser = sampleUsers.find((u) => u.email === body.email);
|
||||
if (existingUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User with this email already exists',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create new user (in real app, this would be persisted)
|
||||
const newUser: RegisterResponse['user'] = {
|
||||
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,
|
||||
};
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = `demo-access-${Date.now()}`;
|
||||
const refreshToken = `demo-refresh-${Date.now()}`;
|
||||
activeTokens.add(accessToken);
|
||||
activeTokens.add(refreshToken);
|
||||
|
||||
// Set as current user
|
||||
setCurrentUser(newUser);
|
||||
|
||||
const response: RegisterResponse = {
|
||||
user: newUser,
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: 'bearer',
|
||||
expires_in: 900, // 15 minutes
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/login - Login with email and password
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = (await request.json()) as LoginRequest;
|
||||
|
||||
// Validate credentials
|
||||
const user = validateCredentials(body.email, body.password);
|
||||
|
||||
if (!user) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Incorrect email or password',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.is_active) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Account is deactivated',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = `demo-access-${user.id}-${Date.now()}`;
|
||||
const refreshToken = `demo-refresh-${user.id}-${Date.now()}`;
|
||||
activeTokens.add(accessToken);
|
||||
activeTokens.add(refreshToken);
|
||||
|
||||
// Update last login
|
||||
const updatedUser = {
|
||||
...user,
|
||||
last_login: new Date().toISOString(),
|
||||
};
|
||||
setCurrentUser(updatedUser);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: 'bearer',
|
||||
expires_in: 900, // 15 minutes
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/refresh - Refresh access token
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/auth/refresh`, async ({ request }) => {
|
||||
await delay(100); // Fast refresh
|
||||
|
||||
const body = (await request.json()) as RefreshTokenRequest;
|
||||
|
||||
// Validate refresh token
|
||||
if (!body.refresh_token || !activeTokens.has(body.refresh_token)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Invalid or expired refresh token',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
const newAccessToken = `demo-access-refreshed-${Date.now()}`;
|
||||
const newRefreshToken = `demo-refresh-refreshed-${Date.now()}`;
|
||||
|
||||
// Remove old tokens, add new ones
|
||||
activeTokens.delete(body.refresh_token);
|
||||
activeTokens.add(newAccessToken);
|
||||
activeTokens.add(newRefreshToken);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: newAccessToken,
|
||||
refresh_token: newRefreshToken,
|
||||
token_type: 'bearer',
|
||||
expires_in: 900,
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/logout - Logout (revoke tokens)
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/auth/logout`, async ({ request }) => {
|
||||
await delay(100);
|
||||
|
||||
const body = (await request.json()) as LogoutRequest;
|
||||
|
||||
// Remove token from active set
|
||||
if (body.refresh_token) {
|
||||
activeTokens.delete(body.refresh_token);
|
||||
}
|
||||
|
||||
// Clear current user
|
||||
setCurrentUser(null);
|
||||
|
||||
const response: MessageResponse = {
|
||||
success: true,
|
||||
message: 'Logged out successfully',
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/logout-all - Logout from all devices
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/auth/logout-all`, async () => {
|
||||
await delay(100);
|
||||
|
||||
// Clear all tokens
|
||||
activeTokens.clear();
|
||||
setCurrentUser(null);
|
||||
|
||||
const response: MessageResponse = {
|
||||
success: true,
|
||||
message: 'Logged out from all devices',
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/password-reset - Request password reset
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/auth/password-reset`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = (await request.json()) as PasswordResetRequest;
|
||||
|
||||
// In demo mode, always return success (don't reveal if email exists)
|
||||
const response: MessageResponse = {
|
||||
success: true,
|
||||
message: 'If an account exists with that email, you will receive a password reset link.',
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/password-reset/confirm - Confirm password reset
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/auth/password-reset/confirm`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = (await request.json()) as PasswordResetConfirm;
|
||||
|
||||
// Validate token (in demo, accept any token that looks valid)
|
||||
if (!body.token || body.token.length < 10) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Invalid or expired reset token',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password requirements
|
||||
if (!body.new_password || body.new_password.length < 8) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Password must be at least 8 characters',
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const response: MessageResponse = {
|
||||
success: true,
|
||||
message: 'Password reset successfully',
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/change-password - Change password (authenticated)
|
||||
*/
|
||||
http.post(`${API_BASE_URL}/api/v1/auth/change-password`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
// Check if user is authenticated
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const response: MessageResponse = {
|
||||
success: true,
|
||||
message: 'Password changed successfully',
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
];
|
||||
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* MSW Handlers Index
|
||||
*
|
||||
* Exports all request handlers for Mock Service Worker
|
||||
* Organized by domain: auth, users, admin
|
||||
* 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 { authHandlers } from './auth';
|
||||
import { userHandlers } from './users';
|
||||
import { adminHandlers } from './admin';
|
||||
import { generatedHandlers } from './generated';
|
||||
import { overrideHandlers } from './overrides';
|
||||
|
||||
/**
|
||||
* All request handlers for MSW
|
||||
* Order matters: more specific handlers should come first
|
||||
*
|
||||
* Order matters: overrides come first to take precedence
|
||||
*/
|
||||
export const handlers = [...authHandlers, ...userHandlers, ...adminHandlers];
|
||||
export const handlers = [...overrideHandlers, ...generatedHandlers];
|
||||
|
||||
38
frontend/src/mocks/handlers/overrides.ts
Normal file
38
frontend/src/mocks/handlers/overrides.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
/**
|
||||
* Custom handler overrides
|
||||
*
|
||||
* These handlers take precedence over generated ones.
|
||||
* Add custom implementations here as needed.
|
||||
*/
|
||||
export const overrideHandlers = [
|
||||
// Example: Custom error simulation for testing
|
||||
// http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request }) => {
|
||||
// // Simulate rate limiting 10% of the time
|
||||
// if (Math.random() < 0.1) {
|
||||
// return HttpResponse.json(
|
||||
// { detail: 'Too many login attempts' },
|
||||
// { status: 429 }
|
||||
// );
|
||||
// }
|
||||
// // Otherwise, use generated handler (by not returning anything)
|
||||
// }),
|
||||
|
||||
// Add your custom handlers here...
|
||||
];
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* MSW User Endpoint Handlers
|
||||
*
|
||||
* Handles user profile, organizations, and session management
|
||||
*/
|
||||
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import type {
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
OrganizationResponse,
|
||||
SessionResponse,
|
||||
MessageResponse,
|
||||
} from '@/lib/api/client';
|
||||
import { currentUser, updateCurrentUser, sampleUsers } from '../data/users';
|
||||
import { getUserOrganizations } from '../data/organizations';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
const NETWORK_DELAY = 200;
|
||||
|
||||
// In-memory session store for demo
|
||||
const mockSessions: SessionResponse[] = [
|
||||
{
|
||||
id: 'session-1',
|
||||
user_id: 'demo-user-id-1',
|
||||
device_name: 'Chrome on macOS',
|
||||
device_id: 'device-1',
|
||||
ip_address: '192.168.1.100',
|
||||
location_city: 'San Francisco',
|
||||
location_country: 'United States',
|
||||
last_used_at: new Date().toISOString(),
|
||||
created_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days ago
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days from now
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
id: 'session-2',
|
||||
user_id: 'demo-user-id-1',
|
||||
device_name: 'Safari on iPhone',
|
||||
device_id: 'device-2',
|
||||
ip_address: '192.168.1.101',
|
||||
location_city: 'San Francisco',
|
||||
location_country: 'United States',
|
||||
last_used_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 1 day ago
|
||||
created_at: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
expires_at: new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
is_active: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if request is authenticated
|
||||
*/
|
||||
function isAuthenticated(request: Request): boolean {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
return Boolean(authHeader && authHeader.startsWith('Bearer '));
|
||||
}
|
||||
|
||||
/**
|
||||
* User endpoint handlers
|
||||
*/
|
||||
export const userHandlers = [
|
||||
/**
|
||||
* GET /api/v1/users/me - Get current user profile
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/users/me`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isAuthenticated(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json(currentUser);
|
||||
}),
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/users/me - Update current user profile
|
||||
*/
|
||||
http.patch(`${API_BASE_URL}/api/v1/users/me`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isAuthenticated(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as UserUpdate;
|
||||
|
||||
// Update user profile
|
||||
updateCurrentUser(body);
|
||||
|
||||
return HttpResponse.json(currentUser);
|
||||
}),
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/users/me - Delete current user account
|
||||
*/
|
||||
http.delete(`${API_BASE_URL}/api/v1/users/me`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isAuthenticated(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response: MessageResponse = {
|
||||
success: true,
|
||||
message: 'Account deleted successfully',
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/:id - Get user by ID (public profile)
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/users/:id`, async ({ request, params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isAuthenticated(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
const user = sampleUsers.find((u) => u.id === id);
|
||||
|
||||
if (!user) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'User not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json(user);
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/users - List users (paginated)
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/users`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isAuthenticated(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('page_size') || '20');
|
||||
|
||||
// Simple pagination
|
||||
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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/me - Get current user's organizations
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/organizations/me`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isAuthenticated(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json([], { status: 200 });
|
||||
}
|
||||
|
||||
const organizations = getUserOrganizations(currentUser.id);
|
||||
return HttpResponse.json(organizations);
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /api/v1/sessions - Get current user's sessions
|
||||
*/
|
||||
http.get(`${API_BASE_URL}/api/v1/sessions`, async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isAuthenticated(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({ sessions: [] });
|
||||
}
|
||||
|
||||
// Filter sessions for current user
|
||||
const userSessions = mockSessions.filter((s) => s.user_id === currentUser.id);
|
||||
|
||||
return HttpResponse.json({
|
||||
sessions: userSessions,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/sessions/:id - Revoke a session
|
||||
*/
|
||||
http.delete(`${API_BASE_URL}/api/v1/sessions/:id`, async ({ request, params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
if (!isAuthenticated(request)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Not authenticated',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
|
||||
// Find session
|
||||
const sessionIndex = mockSessions.findIndex((s) => s.id === id);
|
||||
if (sessionIndex === -1) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
detail: 'Session not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Remove session
|
||||
mockSessions.splice(sessionIndex, 1);
|
||||
|
||||
const response: MessageResponse = {
|
||||
success: true,
|
||||
message: 'Session revoked successfully',
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
];
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,7 +18,7 @@ jest.mock('recharts', () => {
|
||||
});
|
||||
|
||||
describe('OrganizationDistributionChart', () => {
|
||||
const mockData: OrganizationDistributionData[] = [
|
||||
const mockData: OrgDistributionData[] = [
|
||||
{ name: 'Engineering', value: 45 },
|
||||
{ name: 'Marketing', value: 28 },
|
||||
{ name: 'Sales', value: 35 },
|
||||
|
||||
@@ -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();
|
||||
@@ -158,13 +158,13 @@ describe('DemoCredentialsModal', () => {
|
||||
const loginAsUserLink = screen.getByRole('link', { name: /login as user/i });
|
||||
expect(loginAsUserLink).toHaveAttribute(
|
||||
'href',
|
||||
'/login?email=demo@example.com&password=Demo123!'
|
||||
'/login?email=demo@example.com&password=DemoPass1234!'
|
||||
);
|
||||
|
||||
const loginAsAdminLink = screen.getByRole('link', { name: /login as admin/i });
|
||||
expect(loginAsAdminLink).toHaveAttribute(
|
||||
'href',
|
||||
'/login?email=admin@example.com&password=Admin123!'
|
||||
'/login?email=admin@example.com&password=AdminPass1234!'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user