Enhancement 1 - Block suspended/archived org access: - Add org status check in switchOrganization() (auth.service.ts) - Filter suspended/archived orgs from login response (generateTokenResponse) - Add org status guard with 60s cache in TenantMiddleware - Frontend: filter orgs in SelectOrgPage, add 403 handler in api.ts Enhancement 2 - Change tenant plan level: - Add updatePlanLevel() to organizations.service.ts - Add PUT /admin/organizations/:id/plan endpoint - Frontend: clickable plan dropdown in Organizations table + confirmation modal - Plan level Select in tenant detail drawer Enhancement 3 - User impersonation: - Add impersonateUser() to auth.service.ts with impersonatedBy JWT claim - Add POST /admin/impersonate/:userId endpoint - Frontend: Impersonate button in Users tab (disabled for admins) - Impersonation state management in authStore (start/stop/persist) - Orange impersonation banner in AppLayout header with stop button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
6.3 KiB
TypeScript
200 lines
6.3 KiB
TypeScript
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
|
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
import { AuthService } from './auth.service';
|
|
import { UsersService } from '../users/users.service';
|
|
import { OrganizationsService } from '../organizations/organizations.service';
|
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
|
import * as bcrypt from 'bcryptjs';
|
|
|
|
@ApiTags('admin')
|
|
@Controller('admin')
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
export class AdminController {
|
|
constructor(
|
|
private authService: AuthService,
|
|
private usersService: UsersService,
|
|
private orgService: OrganizationsService,
|
|
private analyticsService: AdminAnalyticsService,
|
|
) {}
|
|
|
|
private async requireSuperadmin(req: any) {
|
|
const user = await this.usersService.findById(req.user.userId || req.user.sub);
|
|
if (!user?.isSuperadmin) {
|
|
throw new ForbiddenException('SuperUser Admin access required');
|
|
}
|
|
}
|
|
|
|
// ── Platform Metrics ──
|
|
|
|
@Get('metrics')
|
|
async getPlatformMetrics(@Req() req: any) {
|
|
await this.requireSuperadmin(req);
|
|
return this.analyticsService.getPlatformMetrics();
|
|
}
|
|
|
|
// ── Users ──
|
|
|
|
@Get('users')
|
|
async listUsers(@Req() req: any) {
|
|
await this.requireSuperadmin(req);
|
|
const users = await this.usersService.findAllUsers();
|
|
return users.map(u => ({
|
|
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
|
|
isSuperadmin: u.isSuperadmin, isPlatformOwner: u.isPlatformOwner || false,
|
|
lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
|
organizations: u.userOrganizations?.map(uo => ({
|
|
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
|
})) || [],
|
|
}));
|
|
}
|
|
|
|
// ── Organizations ──
|
|
|
|
@Get('organizations')
|
|
async listOrganizations(@Req() req: any) {
|
|
await this.requireSuperadmin(req);
|
|
return this.usersService.findAllOrganizations();
|
|
}
|
|
|
|
@Get('organizations/:id/detail')
|
|
async getTenantDetail(@Req() req: any, @Param('id') id: string) {
|
|
await this.requireSuperadmin(req);
|
|
const detail = await this.analyticsService.getTenantDetail(id);
|
|
if (!detail) {
|
|
throw new BadRequestException('Organization not found');
|
|
}
|
|
return detail;
|
|
}
|
|
|
|
@Put('organizations/:id/subscription')
|
|
async updateSubscription(
|
|
@Req() req: any,
|
|
@Param('id') id: string,
|
|
@Body() body: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string },
|
|
) {
|
|
await this.requireSuperadmin(req);
|
|
const org = await this.orgService.updateSubscription(id, body);
|
|
return { success: true, organization: org };
|
|
}
|
|
|
|
@Put('organizations/:id/status')
|
|
async updateOrgStatus(
|
|
@Req() req: any,
|
|
@Param('id') id: string,
|
|
@Body() body: { status: string },
|
|
) {
|
|
await this.requireSuperadmin(req);
|
|
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
|
|
if (!validStatuses.includes(body.status)) {
|
|
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
|
}
|
|
const org = await this.orgService.updateStatus(id, body.status);
|
|
return { success: true, organization: org };
|
|
}
|
|
|
|
// ── Plan Level ──
|
|
|
|
@Put('organizations/:id/plan')
|
|
async updateOrgPlan(
|
|
@Req() req: any,
|
|
@Param('id') id: string,
|
|
@Body() body: { planLevel: string },
|
|
) {
|
|
await this.requireSuperadmin(req);
|
|
const validPlans = ['standard', 'premium', 'enterprise'];
|
|
if (!validPlans.includes(body.planLevel)) {
|
|
throw new BadRequestException(`Invalid plan. Must be one of: ${validPlans.join(', ')}`);
|
|
}
|
|
const org = await this.orgService.updatePlanLevel(id, body.planLevel);
|
|
return { success: true, organization: org };
|
|
}
|
|
|
|
// ── Superadmin Toggle ──
|
|
|
|
@Post('users/:id/superadmin')
|
|
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
|
await this.requireSuperadmin(req);
|
|
await this.usersService.setSuperadmin(id, body.isSuperadmin);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── User Impersonation ──
|
|
|
|
@Post('impersonate/:userId')
|
|
async impersonateUser(@Req() req: any, @Param('userId') userId: string) {
|
|
await this.requireSuperadmin(req);
|
|
const adminUserId = req.user.userId || req.user.sub;
|
|
return this.authService.impersonateUser(adminUserId, userId);
|
|
}
|
|
|
|
// ── Tenant Health ──
|
|
|
|
@Get('tenants-health')
|
|
async getTenantsHealth(@Req() req: any) {
|
|
await this.requireSuperadmin(req);
|
|
return this.analyticsService.getAllTenantsHealth();
|
|
}
|
|
|
|
// ── Create Tenant ──
|
|
|
|
@Post('tenants')
|
|
async createTenant(@Req() req: any, @Body() body: {
|
|
orgName: string;
|
|
email?: string;
|
|
phone?: string;
|
|
addressLine1?: string;
|
|
city?: string;
|
|
state?: string;
|
|
zipCode?: string;
|
|
contractNumber?: string;
|
|
planLevel?: string;
|
|
fiscalYearStartMonth?: number;
|
|
adminEmail: string;
|
|
adminPassword: string;
|
|
adminFirstName: string;
|
|
adminLastName: string;
|
|
}) {
|
|
await this.requireSuperadmin(req);
|
|
|
|
if (!body.orgName || !body.adminEmail || !body.adminPassword) {
|
|
throw new BadRequestException('Organization name, admin email and password are required');
|
|
}
|
|
|
|
// Check if admin email already exists
|
|
const existingUser = await this.usersService.findByEmail(body.adminEmail);
|
|
let userId: string;
|
|
|
|
if (existingUser) {
|
|
userId = existingUser.id;
|
|
} else {
|
|
// Create the first user for this tenant
|
|
const passwordHash = await bcrypt.hash(body.adminPassword, 12);
|
|
const newUser = await this.usersService.create({
|
|
email: body.adminEmail,
|
|
passwordHash,
|
|
firstName: body.adminFirstName,
|
|
lastName: body.adminLastName,
|
|
});
|
|
userId = newUser.id;
|
|
}
|
|
|
|
// Create the organization + tenant schema + membership
|
|
const org = await this.orgService.create({
|
|
name: body.orgName,
|
|
email: body.email,
|
|
phone: body.phone,
|
|
addressLine1: body.addressLine1,
|
|
city: body.city,
|
|
state: body.state,
|
|
zipCode: body.zipCode,
|
|
contractNumber: body.contractNumber,
|
|
planLevel: body.planLevel || 'standard',
|
|
fiscalYearStartMonth: body.fiscalYearStartMonth || 1,
|
|
}, userId);
|
|
|
|
return { success: true, organization: org };
|
|
}
|
|
}
|