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,5 +1,6 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { Request, Response, NextFunction } from 'express';
import * as jwt from 'jsonwebtoken';
@@ -12,9 +13,16 @@ export interface TenantRequest extends Request {
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private configService: ConfigService) {}
// In-memory cache for org status to avoid DB hit per request
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
private static readonly CACHE_TTL = 60_000; // 60 seconds
use(req: TenantRequest, _res: Response, next: NextFunction) {
constructor(
private configService: ConfigService,
private dataSource: DataSource,
) {}
async use(req: TenantRequest, res: Response, next: NextFunction) {
// Try to extract tenant info from Authorization header JWT
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
@@ -23,6 +31,18 @@ export class TenantMiddleware implements NestMiddleware {
const secret = this.configService.get<string>('JWT_SECRET');
const decoded = jwt.verify(token, secret!) as any;
if (decoded?.orgSchema) {
// Check if the org is still active (catches post-JWT suspension)
if (decoded.orgId) {
const status = await this.getOrgStatus(decoded.orgId);
if (status && ['suspended', 'archived'].includes(status)) {
res.status(403).json({
statusCode: 403,
message: `This organization has been ${status}. Please contact your administrator.`,
});
return;
}
}
req.tenantSchema = decoded.orgSchema;
req.orgId = decoded.orgId;
req.userId = decoded.sub;
@@ -34,4 +54,24 @@ export class TenantMiddleware implements NestMiddleware {
}
next();
}
private async getOrgStatus(orgId: string): Promise<string | null> {
const cached = this.orgStatusCache.get(orgId);
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
return cached.status;
}
try {
const result = await this.dataSource.query(
`SELECT status FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (result.length > 0) {
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
return result[0].status;
}
} catch {
// Non-critical — don't block requests on cache miss errors
}
return null;
}
}