Add admin enhancements: impersonation, plan management, org status enforcement

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>
This commit is contained in:
2026-02-26 13:21:59 -05:00
parent e156cf7c87
commit d9bb9363dd
10 changed files with 345 additions and 18 deletions

View File

@@ -1,6 +1,7 @@
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';
@@ -12,6 +13,7 @@ import * as bcrypt from 'bcryptjs';
@UseGuards(JwtAuthGuard)
export class AdminController {
constructor(
private authService: AuthService,
private usersService: UsersService,
private orgService: OrganizationsService,
private analyticsService: AdminAnalyticsService,
@@ -92,6 +94,23 @@ export class AdminController {
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')
@@ -101,6 +120,15 @@ export class AdminController {
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')

View File

@@ -2,6 +2,8 @@ import {
Injectable,
UnauthorizedException,
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { DataSource } from 'typeorm';
@@ -104,6 +106,14 @@ export class AuthService {
throw new UnauthorizedException('Not a member of this organization');
}
// Block access to suspended/archived organizations
const orgStatus = membership.organization?.status;
if (orgStatus && ['suspended', 'archived'].includes(orgStatus)) {
throw new ForbiddenException(
`This organization has been ${orgStatus}. Please contact your administrator.`,
);
}
const payload = {
sub: user.id,
email: user.email,
@@ -142,8 +152,12 @@ export class AuthService {
}
}
private generateTokenResponse(user: User) {
const orgs = user.userOrganizations || [];
private generateTokenResponse(user: User, impersonatedBy?: string) {
const allOrgs = user.userOrganizations || [];
// Filter out suspended/archived organizations
const orgs = allOrgs.filter(
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
);
const defaultOrg = orgs[0];
const payload: Record<string, any> = {
@@ -152,6 +166,10 @@ export class AuthService {
isSuperadmin: user.isSuperadmin || false,
};
if (impersonatedBy) {
payload.impersonatedBy = impersonatedBy;
}
if (defaultOrg) {
payload.orgId = defaultOrg.organizationId;
payload.orgSchema = defaultOrg.organization?.schemaName;
@@ -172,8 +190,20 @@ export class AuthService {
id: uo.organizationId,
name: uo.organization?.name,
schemaName: uo.organization?.schemaName,
status: uo.organization?.status,
role: uo.role,
})),
};
}
async impersonateUser(adminUserId: string, targetUserId: string) {
const targetUser = await this.usersService.findByIdWithOrgs(targetUserId);
if (!targetUser) {
throw new NotFoundException('User not found');
}
if (targetUser.isSuperadmin) {
throw new ForbiddenException('Cannot impersonate another superadmin');
}
return this.generateTokenResponse(targetUser, adminUserId);
}
}

View File

@@ -21,6 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
orgSchema: payload.orgSchema,
role: payload.role,
isSuperadmin: payload.isSuperadmin || false,
impersonatedBy: payload.impersonatedBy || null,
};
}
}

View File

@@ -62,6 +62,13 @@ export class OrganizationsService {
return this.orgRepository.save(org);
}
async updatePlanLevel(id: string, planLevel: string) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new NotFoundException('Organization not found');
org.planLevel = planLevel;
return this.orgRepository.save(org);
}
async updateSubscription(id: string, data: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string }) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new NotFoundException('Organization not found');