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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user