forked from cardosofelipe/fast-next-template
- 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.
493 lines
12 KiB
TypeScript
493 lines
12 KiB
TypeScript
/**
|
|
* 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,
|
|
},
|
|
});
|
|
}),
|
|
];
|