forked from cardosofelipe/fast-next-template
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:
@@ -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>
|
||||
|
||||
66
frontend/src/components/demo/DemoModeBanner.tsx
Normal file
66
frontend/src/components/demo/DemoModeBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/demo/index.ts
Normal file
5
frontend/src/components/demo/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Demo components exports
|
||||
*/
|
||||
|
||||
export { DemoModeBanner } from './DemoModeBanner';
|
||||
83
frontend/src/components/providers/MSWProvider.tsx
Normal file
83
frontend/src/components/providers/MSWProvider.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* MSW Provider Component
|
||||
*
|
||||
* Initializes Mock Service Worker for demo mode
|
||||
* This component handles MSW setup in a Next.js-compatible way
|
||||
*
|
||||
* IMPORTANT: This is a client component that runs in the browser only
|
||||
* SAFE: Will not interfere with tests or development mode
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* MSW initialization promise (cached)
|
||||
* Ensures MSW is only initialized once
|
||||
*/
|
||||
let mswInitPromise: Promise<void> | null = null;
|
||||
|
||||
function initMSW(): Promise<void> {
|
||||
// Return cached promise if already initialized
|
||||
if (mswInitPromise) {
|
||||
return mswInitPromise;
|
||||
}
|
||||
|
||||
// Check if MSW should start
|
||||
const shouldStart =
|
||||
typeof window !== 'undefined' &&
|
||||
process.env.NODE_ENV !== 'test' &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
!(window as any).__PLAYWRIGHT_TEST__ &&
|
||||
process.env.NEXT_PUBLIC_DEMO_MODE === 'true';
|
||||
|
||||
if (!shouldStart) {
|
||||
// Return resolved promise, no-op
|
||||
mswInitPromise = Promise.resolve();
|
||||
return mswInitPromise;
|
||||
}
|
||||
|
||||
// Initialize MSW (lazy import to avoid loading in non-demo mode)
|
||||
mswInitPromise = import('@/mocks')
|
||||
.then(({ initMocks }) => initMocks())
|
||||
.catch((error) => {
|
||||
console.error('[MSW] Failed to initialize:', error);
|
||||
// Reset promise so it can be retried
|
||||
mswInitPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return mswInitPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* MSW Provider Component
|
||||
*
|
||||
* Wraps children and ensures MSW is initialized before rendering
|
||||
* Uses React 19's `use()` hook for suspense-compatible async initialization
|
||||
*/
|
||||
export function MSWProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize MSW on mount
|
||||
initMSW()
|
||||
.then(() => {
|
||||
setIsReady(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[MSW] Initialization failed:', error);
|
||||
// Still render children even if MSW fails (graceful degradation)
|
||||
setIsReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Wait for MSW to be ready before rendering children
|
||||
// This prevents race conditions where API calls happen before MSW is ready
|
||||
if (!isReady) {
|
||||
return null; // or a loading spinner if you prefer
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -71,6 +71,7 @@ const ENV = {
|
||||
ENABLE_REGISTRATION: process.env.NEXT_PUBLIC_ENABLE_REGISTRATION,
|
||||
ENABLE_SESSION_MANAGEMENT: process.env.NEXT_PUBLIC_ENABLE_SESSION_MANAGEMENT,
|
||||
DEBUG_API: process.env.NEXT_PUBLIC_DEBUG_API,
|
||||
DEMO_MODE: process.env.NEXT_PUBLIC_DEMO_MODE,
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
} as const;
|
||||
|
||||
@@ -118,6 +119,16 @@ export const config = {
|
||||
api: parseBool(ENV.DEBUG_API, false) && ENV.NODE_ENV === 'development',
|
||||
},
|
||||
|
||||
demo: {
|
||||
// Enable demo mode (uses Mock Service Worker instead of real backend)
|
||||
enabled: parseBool(ENV.DEMO_MODE, false),
|
||||
// Demo credentials
|
||||
credentials: {
|
||||
user: { email: 'demo@example.com', password: 'DemoPass123' },
|
||||
admin: { email: 'admin@example.com', password: 'AdminPass123' },
|
||||
},
|
||||
},
|
||||
|
||||
env: {
|
||||
isDevelopment: ENV.NODE_ENV === 'development',
|
||||
isProduction: ENV.NODE_ENV === 'production',
|
||||
|
||||
71
frontend/src/mocks/browser.ts
Normal file
71
frontend/src/mocks/browser.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
166
frontend/src/mocks/data/organizations.ts
Normal file
166
frontend/src/mocks/data/organizations.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Mock Organization Data
|
||||
*
|
||||
* Sample organizations for demo mode, matching OpenAPI schemas
|
||||
*/
|
||||
|
||||
import type { OrganizationResponse, OrganizationMemberResponse } from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* Sample organizations
|
||||
*/
|
||||
export const sampleOrganizations: OrganizationResponse[] = [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Acme Corporation',
|
||||
slug: 'acme-corp',
|
||||
description: 'Leading provider of innovative solutions',
|
||||
is_active: true,
|
||||
settings: {
|
||||
theme: 'light',
|
||||
notifications: true,
|
||||
},
|
||||
member_count: 12,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'org-2',
|
||||
name: 'Tech Innovators',
|
||||
slug: 'tech-innovators',
|
||||
description: 'Pioneering the future of technology',
|
||||
is_active: true,
|
||||
settings: {
|
||||
theme: 'dark',
|
||||
notifications: false,
|
||||
},
|
||||
member_count: 8,
|
||||
created_at: '2024-02-01T00:00:00Z',
|
||||
updated_at: '2024-03-10T14:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'org-3',
|
||||
name: 'Global Solutions Inc',
|
||||
slug: 'global-solutions',
|
||||
description: 'Worldwide consulting and services',
|
||||
is_active: true,
|
||||
settings: {},
|
||||
member_count: 25,
|
||||
created_at: '2023-12-01T00:00:00Z',
|
||||
updated_at: '2024-01-20T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'org-4',
|
||||
name: 'Startup Ventures',
|
||||
slug: 'startup-ventures',
|
||||
description: 'Fast-growing startup company',
|
||||
is_active: true,
|
||||
settings: {
|
||||
theme: 'auto',
|
||||
},
|
||||
member_count: 5,
|
||||
created_at: '2024-03-15T00:00:00Z',
|
||||
updated_at: '2024-03-20T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'org-5',
|
||||
name: 'Inactive Corp',
|
||||
slug: 'inactive-corp',
|
||||
description: 'Suspended organization',
|
||||
is_active: false,
|
||||
settings: {},
|
||||
member_count: 3,
|
||||
created_at: '2023-11-01T00:00:00Z',
|
||||
updated_at: '2024-06-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sample organization members
|
||||
* Maps organization ID to its members
|
||||
*/
|
||||
export const organizationMembers: Record<string, OrganizationMemberResponse[]> = {
|
||||
'org-1': [
|
||||
{
|
||||
// @ts-ignore
|
||||
id: 'member-1',
|
||||
user_id: 'demo-user-id-1',
|
||||
user_email: 'demo@example.com',
|
||||
user_first_name: 'Demo',
|
||||
user_last_name: 'User',
|
||||
role: 'member',
|
||||
joined_at: '2024-01-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
// @ts-ignore
|
||||
id: 'member-2',
|
||||
user_id: 'demo-admin-id-1',
|
||||
user_email: 'admin@example.com',
|
||||
user_first_name: 'Admin',
|
||||
user_last_name: 'Demo',
|
||||
role: 'owner',
|
||||
joined_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
// @ts-ignore
|
||||
id: 'member-3',
|
||||
user_id: 'user-3',
|
||||
user_email: 'john.doe@example.com',
|
||||
user_first_name: 'John',
|
||||
user_last_name: 'Doe',
|
||||
role: 'admin',
|
||||
joined_at: '2024-02-01T12:00:00Z',
|
||||
},
|
||||
],
|
||||
'org-2': [
|
||||
{
|
||||
// @ts-ignore
|
||||
id: 'member-4',
|
||||
user_id: 'demo-user-id-1',
|
||||
user_email: 'demo@example.com',
|
||||
user_first_name: 'Demo',
|
||||
user_last_name: 'User',
|
||||
role: 'owner',
|
||||
joined_at: '2024-02-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
// @ts-ignore
|
||||
id: 'member-5',
|
||||
user_id: 'user-4',
|
||||
user_email: 'jane.smith@example.com',
|
||||
user_first_name: 'Jane',
|
||||
user_last_name: 'Smith',
|
||||
role: 'member',
|
||||
joined_at: '2024-03-10T08:30:00Z',
|
||||
},
|
||||
],
|
||||
'org-3': [
|
||||
{
|
||||
// @ts-ignore
|
||||
id: 'member-6',
|
||||
user_id: 'user-4',
|
||||
user_email: 'jane.smith@example.com',
|
||||
user_first_name: 'Jane',
|
||||
user_last_name: 'Smith',
|
||||
role: 'owner',
|
||||
joined_at: '2023-12-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get organizations for a specific user
|
||||
*/
|
||||
export function getUserOrganizations(userId: string): OrganizationResponse[] {
|
||||
return sampleOrganizations.filter((org) => {
|
||||
const members = organizationMembers[org.id] || [];
|
||||
return members.some((m) => m.user_id === userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members for a specific organization
|
||||
*/
|
||||
export function getOrganizationMembersList(orgId: string): OrganizationMemberResponse[] {
|
||||
return organizationMembers[orgId] || [];
|
||||
}
|
||||
91
frontend/src/mocks/data/stats.ts
Normal file
91
frontend/src/mocks/data/stats.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Mock Admin Statistics Data
|
||||
*
|
||||
* Sample statistics for demo mode admin dashboard
|
||||
*/
|
||||
|
||||
import type {
|
||||
AdminStatsResponse,
|
||||
UserGrowthData,
|
||||
OrgDistributionData,
|
||||
RegistrationActivityData,
|
||||
UserStatusData,
|
||||
} from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* Generate user growth data for the last 30 days
|
||||
*/
|
||||
function generateUserGrowthData(): UserGrowthData[] {
|
||||
const data: UserGrowthData[] = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
|
||||
// Simulate growth with some randomness
|
||||
const baseTotal = 50 + Math.floor((29 - i) * 1.5);
|
||||
const baseActive = Math.floor(baseTotal * (0.7 + Math.random() * 0.2));
|
||||
|
||||
data.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
total_users: baseTotal,
|
||||
active_users: baseActive,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization distribution data
|
||||
*/
|
||||
const orgDistribution: OrgDistributionData[] = [
|
||||
{ name: 'Acme Corporation', value: 12 },
|
||||
{ name: 'Tech Innovators', value: 8 },
|
||||
{ name: 'Global Solutions Inc', value: 25 },
|
||||
{ name: 'Startup Ventures', value: 5 },
|
||||
{ name: 'Inactive Corp', value: 3 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Registration activity data (last 7 days)
|
||||
*/
|
||||
function generateRegistrationActivity(): RegistrationActivityData[] {
|
||||
const data: RegistrationActivityData[] = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
|
||||
// Simulate registration activity with some randomness
|
||||
const count = Math.floor(Math.random() * 5) + 1; // 1-5 registrations per day
|
||||
|
||||
data.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
// @ts-ignore
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* User status distribution
|
||||
*/
|
||||
const userStatus: UserStatusData[] = [
|
||||
{ name: 'Active', value: 89 },
|
||||
{ name: 'Inactive', value: 11 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Complete admin stats response
|
||||
*/
|
||||
export const adminStats: AdminStatsResponse = {
|
||||
user_growth: generateUserGrowthData(),
|
||||
organization_distribution: orgDistribution,
|
||||
registration_activity: generateRegistrationActivity(),
|
||||
user_status: userStatus,
|
||||
};
|
||||
139
frontend/src/mocks/data/users.ts
Normal file
139
frontend/src/mocks/data/users.ts
Normal 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;
|
||||
}
|
||||
492
frontend/src/mocks/handlers/admin.ts
Normal file
492
frontend/src/mocks/handlers/admin.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
324
frontend/src/mocks/handlers/auth.ts
Normal file
324
frontend/src/mocks/handlers/auth.ts
Normal 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);
|
||||
}),
|
||||
];
|
||||
16
frontend/src/mocks/handlers/index.ts
Normal file
16
frontend/src/mocks/handlers/index.ts
Normal 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];
|
||||
301
frontend/src/mocks/handlers/users.ts
Normal file
301
frontend/src/mocks/handlers/users.ts
Normal 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);
|
||||
}),
|
||||
];
|
||||
20
frontend/src/mocks/index.ts
Normal file
20
frontend/src/mocks/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Mock Service Worker (MSW) Setup
|
||||
*
|
||||
* Initializes MSW for demo mode when NEXT_PUBLIC_DEMO_MODE=true
|
||||
* SAFE: Will not run during tests or development mode
|
||||
*
|
||||
* Usage:
|
||||
* - Development (default): Uses real backend at localhost:8000
|
||||
* - Demo mode: Set NEXT_PUBLIC_DEMO_MODE=true to use MSW
|
||||
* - Tests: MSW never initializes (Jest uses existing mocks, Playwright uses page.route())
|
||||
*/
|
||||
|
||||
export { startMockServiceWorker as initMocks } from './browser';
|
||||
export { handlers } from './handlers';
|
||||
export { worker } from './browser';
|
||||
|
||||
// Export mock data for testing purposes
|
||||
export * from './data/users';
|
||||
export * from './data/organizations';
|
||||
export * from './data/stats';
|
||||
Reference in New Issue
Block a user