Add demo mode support with MSW integration and documentation

- Integrated Mock Service Worker (MSW) for frontend-only demo mode, allowing API call interception without requiring a backend.
- Added `DemoModeBanner` component to indicate active demo mode and display demo credentials.
- Enhanced configuration with `DEMO_MODE` flag and demo credentials for user and admin access.
- Updated ESLint configuration to exclude MSW-related files from linting and coverage.
- Created comprehensive `DEMO_MODE.md` documentation for setup and usage guidelines, including deployment instructions and troubleshooting.
- Updated package dependencies to include MSW and related libraries.
This commit is contained in:
Felipe Cardoso
2025-11-24 18:42:05 +01:00
parent 8659e884e9
commit 487c8a3863
22 changed files with 3138 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
/**
* MSW Browser Setup
*
* Configures Mock Service Worker for browser environment.
* This intercepts network requests at the network layer, making it transparent to the app.
*/
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
/**
* Create MSW worker with all handlers
* This worker intercepts fetch/XHR requests in the browser
*/
export const worker = setupWorker(...handlers);
/**
* Check if MSW should be started
* Only runs when ALL conditions are met:
* - In browser (not SSR)
* - NOT in Jest test environment
* - NOT in Playwright E2E tests
* - Demo mode explicitly enabled
*/
function shouldStartMSW(): boolean {
if (typeof window === 'undefined') {
return false; // SSR, skip
}
// Skip Jest unit tests
if (process.env.NODE_ENV === 'test') {
return false;
}
// Skip Playwright E2E tests (uses your existing __PLAYWRIGHT_TEST__ flag)
if ((window as any).__PLAYWRIGHT_TEST__) {
return false;
}
// Only start if demo mode is explicitly enabled
return process.env.NEXT_PUBLIC_DEMO_MODE === 'true';
}
/**
* Start MSW for demo mode
* SAFE: Will not interfere with unit tests or E2E tests
*/
export async function startMockServiceWorker() {
if (!shouldStartMSW()) {
// Silently skip - this is normal for dev/test environments
return;
}
try {
await worker.start({
onUnhandledRequest: 'warn', // Warn about unmocked requests
serviceWorker: {
url: '/mockServiceWorker.js',
},
});
console.log('%c[MSW] Demo Mode Active', 'color: #00bfa5; font-weight: bold;');
console.log('[MSW] All API calls are mocked (no backend required)');
console.log('[MSW] Demo credentials:');
console.log(' Regular user: demo@example.com / DemoPass123');
console.log(' Admin user: admin@example.com / AdminPass123');
} catch (error) {
console.error('[MSW] Failed to start Mock Service Worker:', error);
console.error('[MSW] Demo mode will not work correctly');
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,492 @@
/**
* 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,
},
});
}),
];

View File

@@ -0,0 +1,324 @@
/**
* 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);
}),
];

View File

@@ -0,0 +1,16 @@
/**
* MSW Handlers Index
*
* Exports all request handlers for Mock Service Worker
* Organized by domain: auth, users, admin
*/
import { authHandlers } from './auth';
import { userHandlers } from './users';
import { adminHandlers } from './admin';
/**
* All request handlers for MSW
* Order matters: more specific handlers should come first
*/
export const handlers = [...authHandlers, ...userHandlers, ...adminHandlers];

View File

@@ -0,0 +1,301 @@
/**
* 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);
}),
];

View File

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