Remove MSW handlers and update demo credentials for improved standardization

- Deleted `admin.ts`, `auth.ts`, and `users.ts` MSW handler files to streamline demo mode setup.
- Updated demo credentials logic in `DemoCredentialsModal` and `DemoModeBanner` for stronger password requirements (≥12 characters).
- Refined documentation in `CLAUDE.md` to align with new credential standards and auto-generated MSW workflows.
This commit is contained in:
Felipe Cardoso
2025-11-24 19:20:28 +01:00
parent 372af25aaa
commit 5b0ae54365
25 changed files with 1499 additions and 1167 deletions

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ export function DemoModeBanner() {
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Demo Credentials (any password 8 chars works):
Demo Credentials (any password 12 chars works):
</p>
<div className="space-y-1.5">
<code className="block rounded bg-muted px-2 py-1.5 text-xs font-mono">

View File

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

View File

@@ -124,8 +124,8 @@ export const config = {
enabled: parseBool(ENV.DEMO_MODE, false),
// Demo credentials
credentials: {
user: { email: 'demo@example.com', password: 'DemoPass123' },
admin: { email: 'admin@example.com', password: 'AdminPass123' },
user: { email: 'demo@example.com', password: 'DemoPass1234!' },
admin: { email: 'admin@example.com', password: 'AdminPass1234!' },
},
},

View File

@@ -8,7 +8,7 @@ import type { UserResponse } from '@/lib/api/client';
/**
* Demo user (regular user)
* Credentials: demo@example.com / DemoPass123
* Credentials: demo@example.com / DemoPass1234!
*/
export const demoUser: UserResponse = {
id: 'demo-user-id-1',
@@ -20,13 +20,11 @@ export const demoUser: UserResponse = {
is_superuser: false,
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-20T15:30:00Z',
last_login: '2025-01-24T08:00:00Z',
organization_count: 2,
};
/**
* Demo admin user (superuser)
* Credentials: admin@example.com / AdminPass123
* Credentials: admin@example.com / AdminPass1234!
*/
export const demoAdmin: UserResponse = {
id: 'demo-admin-id-1',
@@ -38,8 +36,6 @@ export const demoAdmin: UserResponse = {
is_superuser: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-24T10:00:00Z',
last_login: '2025-01-24T09:00:00Z',
organization_count: 1,
};
/**
@@ -58,8 +54,6 @@ export const sampleUsers: UserResponse[] = [
is_superuser: false,
created_at: '2024-02-01T12:00:00Z',
updated_at: '2024-02-05T14:30:00Z',
last_login: '2025-01-23T16:45:00Z',
organization_count: 1,
},
{
id: 'user-4',
@@ -71,8 +65,6 @@ export const sampleUsers: UserResponse[] = [
is_superuser: false,
created_at: '2024-03-10T08:30:00Z',
updated_at: '2024-03-15T11:00:00Z',
last_login: '2025-01-22T10:20:00Z',
organization_count: 3,
},
{
id: 'user-5',
@@ -84,8 +76,6 @@ export const sampleUsers: UserResponse[] = [
is_superuser: false,
created_at: '2024-01-20T14:00:00Z',
updated_at: '2024-06-01T09:00:00Z',
last_login: '2024-06-01T09:00:00Z',
organization_count: 0,
},
];
@@ -120,23 +110,23 @@ export function updateCurrentUser(updates: Partial<UserResponse>) {
* In demo mode, we're lenient with passwords to improve UX
*/
export function validateCredentials(email: string, password: string): UserResponse | null {
// Demo user - accept documented password or any password >= 8 chars
// Demo user - accept documented password or any password >= 12 chars
if (email === 'demo@example.com') {
if (password === 'DemoPass123' || password.length >= 8) {
if (password === 'DemoPass1234!' || password.length >= 12) {
return demoUser;
}
}
// Demo admin - accept documented password or any password >= 8 chars
// Demo admin - accept documented password or any password >= 12 chars
if (email === 'admin@example.com') {
if (password === 'AdminPass123' || password.length >= 8) {
if (password === 'AdminPass1234!' || password.length >= 12) {
return demoAdmin;
}
}
// Sample users - accept any valid password (it's a demo!)
const user = sampleUsers.find((u) => u.email === email);
if (user && password.length >= 8) {
if (user && password.length >= 12) {
return user;
}

View File

@@ -1,492 +0,0 @@
/**
* MSW Admin Endpoint Handlers
*
* Handles admin dashboard, user management, org management
* Only accessible to superusers (is_superuser = true)
*/
import { http, HttpResponse, delay } from 'msw';
import type {
UserResponse,
OrganizationResponse,
UserCreate,
UserUpdate,
OrganizationCreate,
OrganizationUpdate,
AdminStatsResponse,
BulkUserAction,
BulkActionResult,
} from '@/lib/api/client';
import { currentUser, sampleUsers } from '../data/users';
import { sampleOrganizations, getOrganizationMembersList } from '../data/organizations';
import { adminStats } from '../data/stats';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
const NETWORK_DELAY = 200;
/**
* Check if request is from a superuser
*/
function isSuperuser(request: Request): boolean {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false;
}
return currentUser?.is_superuser === true;
}
/**
* Admin endpoint handlers
*/
export const adminHandlers = [
/**
* GET /api/v1/admin/stats - Get dashboard statistics
*/
http.get(`${API_BASE_URL}/api/v1/admin/stats`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
return HttpResponse.json(adminStats);
}),
/**
* GET /api/v1/admin/users - List all users (paginated)
*/
http.get(`${API_BASE_URL}/api/v1/admin/users`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
// Parse query params
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
const search = url.searchParams.get('search') || '';
const isActive = url.searchParams.get('is_active');
// Filter users
let filteredUsers = [...sampleUsers];
if (search) {
filteredUsers = filteredUsers.filter(
(u) =>
u.email.toLowerCase().includes(search.toLowerCase()) ||
u.first_name.toLowerCase().includes(search.toLowerCase()) ||
u.last_name?.toLowerCase().includes(search.toLowerCase())
);
}
if (isActive !== null) {
const activeFilter = isActive === 'true';
filteredUsers = filteredUsers.filter((u) => u.is_active === activeFilter);
}
// Paginate
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedUsers = filteredUsers.slice(start, end);
return HttpResponse.json({
data: paginatedUsers,
pagination: {
total: filteredUsers.length,
page,
page_size: pageSize,
total_pages: Math.ceil(filteredUsers.length / pageSize),
has_next: end < filteredUsers.length,
has_prev: page > 1,
},
});
}),
/**
* GET /api/v1/admin/users/:id - Get user by ID
*/
http.get(`${API_BASE_URL}/api/v1/admin/users/:id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
const { id } = params;
const user = sampleUsers.find((u) => u.id === id);
if (!user) {
return HttpResponse.json(
{
detail: 'User not found',
},
{ status: 404 }
);
}
return HttpResponse.json(user);
}),
/**
* POST /api/v1/admin/users - Create new user
*/
http.post(`${API_BASE_URL}/api/v1/admin/users`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
const body = (await request.json()) as UserCreate;
// Check if email exists
if (sampleUsers.some((u) => u.email === body.email)) {
return HttpResponse.json(
{
detail: 'User with this email already exists',
},
{ status: 400 }
);
}
// Create user (in-memory, will be lost on reload)
const newUser: UserResponse = {
id: `user-new-${Date.now()}`,
email: body.email,
first_name: body.first_name,
last_name: body.last_name || null,
phone_number: body.phone_number || null,
is_active: body.is_active !== false,
is_superuser: body.is_superuser === true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_login: null,
organization_count: 0,
};
sampleUsers.push(newUser);
return HttpResponse.json(newUser, { status: 201 });
}),
/**
* PATCH /api/v1/admin/users/:id - Update user
*/
http.patch(`${API_BASE_URL}/api/v1/admin/users/:id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
const { id } = params;
const userIndex = sampleUsers.findIndex((u) => u.id === id);
if (userIndex === -1) {
return HttpResponse.json(
{
detail: 'User not found',
},
{ status: 404 }
);
}
const body = (await request.json()) as UserUpdate;
// Update user
sampleUsers[userIndex] = {
...sampleUsers[userIndex],
...body,
updated_at: new Date().toISOString(),
};
return HttpResponse.json(sampleUsers[userIndex]);
}),
/**
* DELETE /api/v1/admin/users/:id - Delete user
*/
http.delete(`${API_BASE_URL}/api/v1/admin/users/:id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
const { id } = params;
const userIndex = sampleUsers.findIndex((u) => u.id === id);
if (userIndex === -1) {
return HttpResponse.json(
{
detail: 'User not found',
},
{ status: 404 }
);
}
sampleUsers.splice(userIndex, 1);
return HttpResponse.json({
success: true,
message: 'User deleted successfully',
});
}),
/**
* POST /api/v1/admin/users/bulk - Bulk user action
*/
http.post(`${API_BASE_URL}/api/v1/admin/users/bulk`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
const body = (await request.json()) as BulkUserAction;
const { action, user_ids } = body;
let affected = 0;
let failed = 0;
for (const userId of user_ids) {
const userIndex = sampleUsers.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
switch (action) {
case 'activate':
sampleUsers[userIndex].is_active = true;
affected++;
break;
case 'deactivate':
sampleUsers[userIndex].is_active = false;
affected++;
break;
case 'delete':
sampleUsers.splice(userIndex, 1);
affected++;
break;
}
} else {
failed++;
}
}
const result: BulkActionResult = {
success: failed === 0,
affected_count: affected,
failed_count: failed,
message: `${action} completed: ${affected} users affected`,
failed_ids: [],
};
return HttpResponse.json(result);
}),
/**
* GET /api/v1/admin/organizations - List all organizations (paginated)
*/
http.get(`${API_BASE_URL}/api/v1/admin/organizations`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
// Parse query params
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
const search = url.searchParams.get('search') || '';
// Filter organizations
let filteredOrgs = [...sampleOrganizations];
if (search) {
filteredOrgs = filteredOrgs.filter(
(o) =>
o.name.toLowerCase().includes(search.toLowerCase()) ||
o.slug.toLowerCase().includes(search.toLowerCase())
);
}
// Paginate
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedOrgs = filteredOrgs.slice(start, end);
return HttpResponse.json({
data: paginatedOrgs,
pagination: {
total: filteredOrgs.length,
page,
page_size: pageSize,
total_pages: Math.ceil(filteredOrgs.length / pageSize),
has_next: end < filteredOrgs.length,
has_prev: page > 1,
},
});
}),
/**
* GET /api/v1/admin/organizations/:id - Get organization by ID
*/
http.get(`${API_BASE_URL}/api/v1/admin/organizations/:id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
const { id } = params;
const org = sampleOrganizations.find((o) => o.id === id);
if (!org) {
return HttpResponse.json(
{
detail: 'Organization not found',
},
{ status: 404 }
);
}
return HttpResponse.json(org);
}),
/**
* GET /api/v1/admin/organizations/:id/members - Get organization members
*/
http.get(
`${API_BASE_URL}/api/v1/admin/organizations/:id/members`,
async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
const { id } = params as { id: string };
const members = getOrganizationMembersList(id);
// Parse pagination params
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '20');
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedMembers = members.slice(start, end);
return HttpResponse.json({
data: paginatedMembers,
pagination: {
total: members.length,
page,
page_size: pageSize,
total_pages: Math.ceil(members.length / pageSize),
has_next: end < members.length,
has_prev: page > 1,
},
});
}
),
/**
* GET /api/v1/admin/sessions - Get all sessions (admin view)
*/
http.get(`${API_BASE_URL}/api/v1/admin/sessions`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isSuperuser(request)) {
return HttpResponse.json(
{
detail: 'Admin access required',
},
{ status: 403 }
);
}
// Mock session data
const sessions = [
{
id: 'session-1',
user_id: 'demo-user-id-1',
user_email: 'demo@example.com',
user_full_name: 'Demo User',
device_name: 'Chrome on macOS',
device_id: 'device-1',
ip_address: '192.168.1.100',
location_city: 'San Francisco',
location_country: 'United States',
last_used_at: new Date().toISOString(),
created_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
is_active: true,
},
];
return HttpResponse.json({
data: sessions,
pagination: {
total: sessions.length,
page: 1,
page_size: 100,
total_pages: 1,
has_next: false,
has_prev: false,
},
});
}),
];

View File

@@ -1,324 +0,0 @@
/**
* MSW Auth Endpoint Handlers
*
* Mirrors backend auth endpoints for demo mode
* Consistent with E2E test mocks in e2e/helpers/auth.ts
*/
import { http, HttpResponse, delay } from 'msw';
import type {
LoginRequest,
TokenResponse,
UserCreate,
RegisterResponse,
RefreshTokenRequest,
LogoutRequest,
MessageResponse,
PasswordResetRequest,
PasswordResetConfirm,
} from '@/lib/api/client';
import {
validateCredentials,
setCurrentUser,
currentUser,
demoUser,
demoAdmin,
sampleUsers,
} from '../data/users';
// API base URL (same as app config)
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
// Simulate network delay (realistic UX)
const NETWORK_DELAY = 300;
// In-memory session store (resets on page reload, which is fine for demo)
let activeTokens = new Set<string>();
/**
* Auth endpoint handlers
*/
export const authHandlers = [
/**
* POST /api/v1/auth/register - Register new user
*/
http.post(`${API_BASE_URL}/api/v1/auth/register`, async ({ request }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as UserCreate;
// Validate required fields
if (!body.email || !body.password || !body.first_name) {
return HttpResponse.json(
{
detail: 'Missing required fields',
},
{ status: 422 }
);
}
// Check if email already exists
const existingUser = sampleUsers.find((u) => u.email === body.email);
if (existingUser) {
return HttpResponse.json(
{
detail: 'User with this email already exists',
},
{ status: 400 }
);
}
// Create new user (in real app, this would be persisted)
const newUser: RegisterResponse['user'] = {
id: `new-user-${Date.now()}`,
email: body.email,
first_name: body.first_name,
last_name: body.last_name || null,
phone_number: body.phone_number || null,
is_active: true,
is_superuser: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_login: null,
organization_count: 0,
};
// Generate tokens
const accessToken = `demo-access-${Date.now()}`;
const refreshToken = `demo-refresh-${Date.now()}`;
activeTokens.add(accessToken);
activeTokens.add(refreshToken);
// Set as current user
setCurrentUser(newUser);
const response: RegisterResponse = {
user: newUser,
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'bearer',
expires_in: 900, // 15 minutes
};
return HttpResponse.json(response);
}),
/**
* POST /api/v1/auth/login - Login with email and password
*/
http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as LoginRequest;
// Validate credentials
const user = validateCredentials(body.email, body.password);
if (!user) {
return HttpResponse.json(
{
detail: 'Incorrect email or password',
},
{ status: 401 }
);
}
// Check if user is active
if (!user.is_active) {
return HttpResponse.json(
{
detail: 'Account is deactivated',
},
{ status: 403 }
);
}
// Generate tokens
const accessToken = `demo-access-${user.id}-${Date.now()}`;
const refreshToken = `demo-refresh-${user.id}-${Date.now()}`;
activeTokens.add(accessToken);
activeTokens.add(refreshToken);
// Update last login
const updatedUser = {
...user,
last_login: new Date().toISOString(),
};
setCurrentUser(updatedUser);
const response: TokenResponse = {
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'bearer',
expires_in: 900, // 15 minutes
};
return HttpResponse.json(response);
}),
/**
* POST /api/v1/auth/refresh - Refresh access token
*/
http.post(`${API_BASE_URL}/api/v1/auth/refresh`, async ({ request }) => {
await delay(100); // Fast refresh
const body = (await request.json()) as RefreshTokenRequest;
// Validate refresh token
if (!body.refresh_token || !activeTokens.has(body.refresh_token)) {
return HttpResponse.json(
{
detail: 'Invalid or expired refresh token',
},
{ status: 401 }
);
}
// Generate new tokens
const newAccessToken = `demo-access-refreshed-${Date.now()}`;
const newRefreshToken = `demo-refresh-refreshed-${Date.now()}`;
// Remove old tokens, add new ones
activeTokens.delete(body.refresh_token);
activeTokens.add(newAccessToken);
activeTokens.add(newRefreshToken);
const response: TokenResponse = {
access_token: newAccessToken,
refresh_token: newRefreshToken,
token_type: 'bearer',
expires_in: 900,
};
return HttpResponse.json(response);
}),
/**
* POST /api/v1/auth/logout - Logout (revoke tokens)
*/
http.post(`${API_BASE_URL}/api/v1/auth/logout`, async ({ request }) => {
await delay(100);
const body = (await request.json()) as LogoutRequest;
// Remove token from active set
if (body.refresh_token) {
activeTokens.delete(body.refresh_token);
}
// Clear current user
setCurrentUser(null);
const response: MessageResponse = {
success: true,
message: 'Logged out successfully',
};
return HttpResponse.json(response);
}),
/**
* POST /api/v1/auth/logout-all - Logout from all devices
*/
http.post(`${API_BASE_URL}/api/v1/auth/logout-all`, async () => {
await delay(100);
// Clear all tokens
activeTokens.clear();
setCurrentUser(null);
const response: MessageResponse = {
success: true,
message: 'Logged out from all devices',
};
return HttpResponse.json(response);
}),
/**
* POST /api/v1/auth/password-reset - Request password reset
*/
http.post(`${API_BASE_URL}/api/v1/auth/password-reset`, async ({ request }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as PasswordResetRequest;
// In demo mode, always return success (don't reveal if email exists)
const response: MessageResponse = {
success: true,
message: 'If an account exists with that email, you will receive a password reset link.',
};
return HttpResponse.json(response);
}),
/**
* POST /api/v1/auth/password-reset/confirm - Confirm password reset
*/
http.post(`${API_BASE_URL}/api/v1/auth/password-reset/confirm`, async ({ request }) => {
await delay(NETWORK_DELAY);
const body = (await request.json()) as PasswordResetConfirm;
// Validate token (in demo, accept any token that looks valid)
if (!body.token || body.token.length < 10) {
return HttpResponse.json(
{
detail: 'Invalid or expired reset token',
},
{ status: 400 }
);
}
// Validate password requirements
if (!body.new_password || body.new_password.length < 8) {
return HttpResponse.json(
{
detail: 'Password must be at least 8 characters',
},
{ status: 422 }
);
}
const response: MessageResponse = {
success: true,
message: 'Password reset successfully',
};
return HttpResponse.json(response);
}),
/**
* POST /api/v1/auth/change-password - Change password (authenticated)
*/
http.post(`${API_BASE_URL}/api/v1/auth/change-password`, async ({ request }) => {
await delay(NETWORK_DELAY);
// Check if user is authenticated
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
if (!currentUser) {
return HttpResponse.json(
{
detail: 'User not found',
},
{ status: 404 }
);
}
const response: MessageResponse = {
success: true,
message: 'Password changed successfully',
};
return HttpResponse.json(response);
}),
];

View File

@@ -1,16 +1,21 @@
/**
* MSW Handlers Index
*
* Exports all request handlers for Mock Service Worker
* Organized by domain: auth, users, admin
* Combines auto-generated handlers with custom overrides.
*
* Architecture:
* - generated.ts: Auto-generated from OpenAPI spec (DO NOT EDIT)
* - overrides.ts: Custom handler logic (EDIT AS NEEDED)
*
* Overrides take precedence over generated handlers.
*/
import { authHandlers } from './auth';
import { userHandlers } from './users';
import { adminHandlers } from './admin';
import { generatedHandlers } from './generated';
import { overrideHandlers } from './overrides';
/**
* All request handlers for MSW
* Order matters: more specific handlers should come first
*
* Order matters: overrides come first to take precedence
*/
export const handlers = [...authHandlers, ...userHandlers, ...adminHandlers];
export const handlers = [...overrideHandlers, ...generatedHandlers];

View File

@@ -0,0 +1,38 @@
/**
* MSW Handler Overrides
*
* Custom handlers that override or extend auto-generated ones.
* Use this file for complex logic that can't be auto-generated.
*
* Examples:
* - Complex validation logic
* - Stateful interactions
* - Error simulation scenarios
* - Special edge cases
*/
import { http, HttpResponse, delay } from 'msw';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
/**
* Custom handler overrides
*
* These handlers take precedence over generated ones.
* Add custom implementations here as needed.
*/
export const overrideHandlers = [
// Example: Custom error simulation for testing
// http.post(`${API_BASE_URL}/api/v1/auth/login`, async ({ request }) => {
// // Simulate rate limiting 10% of the time
// if (Math.random() < 0.1) {
// return HttpResponse.json(
// { detail: 'Too many login attempts' },
// { status: 429 }
// );
// }
// // Otherwise, use generated handler (by not returning anything)
// }),
// Add your custom handlers here...
];

View File

@@ -1,301 +0,0 @@
/**
* MSW User Endpoint Handlers
*
* Handles user profile, organizations, and session management
*/
import { http, HttpResponse, delay } from 'msw';
import type {
UserResponse,
UserUpdate,
OrganizationResponse,
SessionResponse,
MessageResponse,
} from '@/lib/api/client';
import { currentUser, updateCurrentUser, sampleUsers } from '../data/users';
import { getUserOrganizations } from '../data/organizations';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
const NETWORK_DELAY = 200;
// In-memory session store for demo
const mockSessions: SessionResponse[] = [
{
id: 'session-1',
user_id: 'demo-user-id-1',
device_name: 'Chrome on macOS',
device_id: 'device-1',
ip_address: '192.168.1.100',
location_city: 'San Francisco',
location_country: 'United States',
last_used_at: new Date().toISOString(),
created_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days ago
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days from now
is_active: true,
},
{
id: 'session-2',
user_id: 'demo-user-id-1',
device_name: 'Safari on iPhone',
device_id: 'device-2',
ip_address: '192.168.1.101',
location_city: 'San Francisco',
location_country: 'United States',
last_used_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 1 day ago
created_at: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
expires_at: new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString(),
is_active: true,
},
];
/**
* Check if request is authenticated
*/
function isAuthenticated(request: Request): boolean {
const authHeader = request.headers.get('Authorization');
return Boolean(authHeader && authHeader.startsWith('Bearer '));
}
/**
* User endpoint handlers
*/
export const userHandlers = [
/**
* GET /api/v1/users/me - Get current user profile
*/
http.get(`${API_BASE_URL}/api/v1/users/me`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isAuthenticated(request)) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
if (!currentUser) {
return HttpResponse.json(
{
detail: 'User not found',
},
{ status: 404 }
);
}
return HttpResponse.json(currentUser);
}),
/**
* PATCH /api/v1/users/me - Update current user profile
*/
http.patch(`${API_BASE_URL}/api/v1/users/me`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isAuthenticated(request)) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
if (!currentUser) {
return HttpResponse.json(
{
detail: 'User not found',
},
{ status: 404 }
);
}
const body = (await request.json()) as UserUpdate;
// Update user profile
updateCurrentUser(body);
return HttpResponse.json(currentUser);
}),
/**
* DELETE /api/v1/users/me - Delete current user account
*/
http.delete(`${API_BASE_URL}/api/v1/users/me`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isAuthenticated(request)) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
const response: MessageResponse = {
success: true,
message: 'Account deleted successfully',
};
return HttpResponse.json(response);
}),
/**
* GET /api/v1/users/:id - Get user by ID (public profile)
*/
http.get(`${API_BASE_URL}/api/v1/users/:id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!isAuthenticated(request)) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
const { id } = params;
const user = sampleUsers.find((u) => u.id === id);
if (!user) {
return HttpResponse.json(
{
detail: 'User not found',
},
{ status: 404 }
);
}
return HttpResponse.json(user);
}),
/**
* GET /api/v1/users - List users (paginated)
*/
http.get(`${API_BASE_URL}/api/v1/users`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isAuthenticated(request)) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
// Parse query params
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '20');
// Simple pagination
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedUsers = sampleUsers.slice(start, end);
return HttpResponse.json({
data: paginatedUsers,
pagination: {
total: sampleUsers.length,
page,
page_size: pageSize,
total_pages: Math.ceil(sampleUsers.length / pageSize),
has_next: end < sampleUsers.length,
has_prev: page > 1,
},
});
}),
/**
* GET /api/v1/organizations/me - Get current user's organizations
*/
http.get(`${API_BASE_URL}/api/v1/organizations/me`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isAuthenticated(request)) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
if (!currentUser) {
return HttpResponse.json([], { status: 200 });
}
const organizations = getUserOrganizations(currentUser.id);
return HttpResponse.json(organizations);
}),
/**
* GET /api/v1/sessions - Get current user's sessions
*/
http.get(`${API_BASE_URL}/api/v1/sessions`, async ({ request }) => {
await delay(NETWORK_DELAY);
if (!isAuthenticated(request)) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
if (!currentUser) {
return HttpResponse.json({ sessions: [] });
}
// Filter sessions for current user
const userSessions = mockSessions.filter((s) => s.user_id === currentUser.id);
return HttpResponse.json({
sessions: userSessions,
});
}),
/**
* DELETE /api/v1/sessions/:id - Revoke a session
*/
http.delete(`${API_BASE_URL}/api/v1/sessions/:id`, async ({ request, params }) => {
await delay(NETWORK_DELAY);
if (!isAuthenticated(request)) {
return HttpResponse.json(
{
detail: 'Not authenticated',
},
{ status: 401 }
);
}
const { id } = params;
// Find session
const sessionIndex = mockSessions.findIndex((s) => s.id === id);
if (sessionIndex === -1) {
return HttpResponse.json(
{
detail: 'Session not found',
},
{ status: 404 }
);
}
// Remove session
mockSessions.splice(sessionIndex, 1);
const response: MessageResponse = {
success: true,
message: 'Session revoked successfully',
};
return HttpResponse.json(response);
}),
];