Files
motovaultpro/backend/src/features/admin/api/admin.controller.ts
2025-11-06 14:07:16 -06:00

425 lines
12 KiB
TypeScript

/**
* @ai-summary Fastify route handlers for admin management API
* @ai-context HTTP request/response handling with admin authorization and audit logging
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { AdminService } from '../domain/admin.service';
import { AdminRepository } from '../data/admin.repository';
import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import {
CreateAdminInput,
AdminAuth0SubInput,
AuditLogsQueryInput
} from './admin.validation';
import {
createAdminSchema,
adminAuth0SubSchema,
auditLogsQuerySchema
} from './admin.validation';
export class AdminController {
private adminService: AdminService;
constructor() {
const repository = new AdminRepository(pool);
this.adminService = new AdminService(repository);
}
/**
* GET /api/admin/verify - Verify admin access (for frontend auth checks)
*/
async verifyAccess(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = request.userContext?.userId;
const userEmail = this.resolveUserEmail(request);
console.log('[DEBUG] Admin verify - userId:', userId);
console.log('[DEBUG] Admin verify - userEmail:', userEmail);
if (userEmail && request.userContext) {
request.userContext.email = userEmail.toLowerCase();
}
if (!userId && !userEmail) {
console.log('[DEBUG] Admin verify - No userId or userEmail, returning 401');
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
let adminRecord = userId
? await this.adminService.getAdminByAuth0Sub(userId)
: null;
console.log('[DEBUG] Admin verify - adminRecord by auth0Sub:', adminRecord ? 'FOUND' : 'NOT FOUND');
// Fallback: attempt to resolve admin by email for legacy records
if (!adminRecord && userEmail) {
const emailMatch = await this.adminService.getAdminByEmail(userEmail.toLowerCase());
console.log('[DEBUG] Admin verify - emailMatch:', emailMatch ? 'FOUND' : 'NOT FOUND');
if (emailMatch) {
console.log('[DEBUG] Admin verify - emailMatch.auth0Sub:', emailMatch.auth0Sub);
console.log('[DEBUG] Admin verify - emailMatch.revokedAt:', emailMatch.revokedAt);
}
if (emailMatch && !emailMatch.revokedAt) {
// If the stored auth0Sub differs, link it to the authenticated user
if (userId && emailMatch.auth0Sub !== userId) {
console.log('[DEBUG] Admin verify - Calling linkAdminAuth0Sub to update auth0Sub');
adminRecord = await this.adminService.linkAdminAuth0Sub(userEmail, userId);
console.log('[DEBUG] Admin verify - adminRecord after link:', adminRecord ? 'SUCCESS' : 'FAILED');
} else {
console.log('[DEBUG] Admin verify - Using emailMatch as adminRecord');
adminRecord = emailMatch;
}
}
}
if (adminRecord && !adminRecord.revokedAt) {
if (request.userContext) {
request.userContext.isAdmin = true;
request.userContext.adminRecord = adminRecord;
}
console.log('[DEBUG] Admin verify - Returning isAdmin: true');
// User is an active admin
return reply.code(200).send({
isAdmin: true,
adminRecord: {
auth0Sub: adminRecord.auth0Sub,
email: adminRecord.email,
role: adminRecord.role
}
});
}
if (request.userContext) {
request.userContext.isAdmin = false;
request.userContext.adminRecord = undefined;
}
console.log('[DEBUG] Admin verify - Returning isAdmin: false');
// User is not an admin
return reply.code(200).send({
isAdmin: false,
adminRecord: null
});
} catch (error) {
console.log('[DEBUG] Admin verify - Error caught:', error instanceof Error ? error.message : 'Unknown error');
logger.error('Error verifying admin access', {
error: error instanceof Error ? error.message : 'Unknown error',
userId: request.userContext?.userId?.substring(0, 8) + '...'
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Admin verification failed'
});
}
}
/**
* GET /api/admin/admins - List all admin users
*/
async listAdmins(request: FastifyRequest, reply: FastifyReply) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
const admins = await this.adminService.getAllAdmins();
// Log VIEW action
await this.adminService.getAdminByAuth0Sub(actorId);
// Note: Not logging VIEW as it would create excessive audit entries
// VIEW logging can be enabled if needed for compliance
return reply.code(200).send({
total: admins.length,
admins
});
} catch (error: any) {
logger.error('Error listing admins', {
error: error.message,
actorId: request.userContext?.userId
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to list admins'
});
}
}
/**
* POST /api/admin/admins - Create new admin user
*/
async createAdmin(
request: FastifyRequest<{ Body: CreateAdminInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate request body
const validation = createAdminSchema.safeParse(request.body);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: validation.error.errors
});
}
const { email, role } = validation.data;
// Generate auth0Sub for the new admin
// In production, this should be the actual Auth0 user ID
// For now, we'll use email-based identifier
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
const admin = await this.adminService.createAdmin(
email,
role,
auth0Sub,
actorId
);
return reply.code(201).send(admin);
} catch (error: any) {
logger.error('Error creating admin', {
error: error.message,
actorId: request.userContext?.userId
});
if (error.message.includes('already exists')) {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to create admin'
});
}
}
/**
* PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access
*/
async revokeAdmin(
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate params
const validation = adminAuth0SubSchema.safeParse(request.params);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid auth0Sub parameter',
details: validation.error.errors
});
}
const { auth0Sub } = validation.data;
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
if (!targetAdmin) {
return reply.code(404).send({
error: 'Not Found',
message: 'Admin user not found'
});
}
// Revoke the admin (service handles last admin check)
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId);
return reply.code(200).send(admin);
} catch (error: any) {
logger.error('Error revoking admin', {
error: error.message,
actorId: request.userContext?.userId,
targetAuth0Sub: request.params.auth0Sub
});
if (error.message.includes('Cannot revoke the last active admin')) {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
if (error.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to revoke admin'
});
}
}
/**
* PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin
*/
async reinstateAdmin(
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate params
const validation = adminAuth0SubSchema.safeParse(request.params);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid auth0Sub parameter',
details: validation.error.errors
});
}
const { auth0Sub } = validation.data;
// Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
if (!targetAdmin) {
return reply.code(404).send({
error: 'Not Found',
message: 'Admin user not found'
});
}
// Reinstate the admin
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId);
return reply.code(200).send(admin);
} catch (error: any) {
logger.error('Error reinstating admin', {
error: error.message,
actorId: request.userContext?.userId,
targetAuth0Sub: request.params.auth0Sub
});
if (error.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to reinstate admin'
});
}
}
/**
* GET /api/admin/audit-logs - Fetch audit trail
*/
async getAuditLogs(
request: FastifyRequest<{ Querystring: AuditLogsQueryInput }>,
reply: FastifyReply
) {
try {
const actorId = request.userContext?.userId;
if (!actorId) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User context missing'
});
}
// Validate query params
const validation = auditLogsQuerySchema.safeParse(request.query);
if (!validation.success) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid query parameters',
details: validation.error.errors
});
}
const { limit, offset } = validation.data;
const result = await this.adminService.getAuditLogs(limit, offset);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error fetching audit logs', {
error: error.message,
actorId: request.userContext?.userId
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to fetch audit logs'
});
}
}
private resolveUserEmail(request: FastifyRequest): string | undefined {
console.log('[DEBUG] resolveUserEmail - request.userContext:', JSON.stringify(request.userContext, null, 2));
console.log('[DEBUG] resolveUserEmail - request.user:', JSON.stringify((request as any).user, null, 2));
const candidates: Array<string | undefined> = [
request.userContext?.email,
(request as any).user?.email,
(request as any).user?.['https://motovaultpro.com/email'],
(request as any).user?.['https://motovaultpro.com/user_email'],
(request as any).user?.preferred_username,
];
console.log('[DEBUG] resolveUserEmail - candidates:', candidates);
for (const value of candidates) {
if (typeof value === 'string' && value.includes('@')) {
console.log('[DEBUG] resolveUserEmail - found email:', value);
return value.trim();
}
}
console.log('[DEBUG] resolveUserEmail - no email found');
return undefined;
}
}