feat(admin): login-as-user impersonation with audit log
Let super admins act as a user from the user detail dashboard for
support/debugging ("full with guardrails", audit-only).
Backend: AuthService.impersonate issues a short-lived token for the
target carrying an `act` claim (acting admin); refresh preserves it and
JwtStrategy surfaces `impersonatedBy`. Guardrails: cannot impersonate an
admin or a deactivated account; new ImpersonationGuard blocks sensitive
self-service (change own password/phone) while impersonating. New
AuditLog entity records impersonation start/stop (admin, target, ip,
time); admin endpoints POST users/:id/impersonate + .../impersonation/
stop and GET users/:id/audit.
Frontend: lib/impersonation swaps admin/impersonation tokens in
localStorage; persistent banner with exit; "Login as user" button and an
"Admin access log" tab on the detail page; logout clears impersonation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
import { AdminUsersService } from './admin-users.service';
|
import { AdminUsersService } from './admin-users.service';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { AuditAction } from './entities/audit-log.entity';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
import { UserRole } from '../common/enums';
|
import { UserRole } from '../common/enums';
|
||||||
@@ -23,7 +29,10 @@ import { UserRole } from '../common/enums';
|
|||||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
export class AdminUsersController {
|
export class AdminUsersController {
|
||||||
constructor(private readonly adminUsers: AdminUsersService) {}
|
constructor(
|
||||||
|
private readonly adminUsers: AdminUsersService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('users/:id')
|
@Get('users/:id')
|
||||||
@ApiOperation({ summary: 'User overview: profile, status, wallet, revenue, counts' })
|
@ApiOperation({ summary: 'User overview: profile, status, wallet, revenue, counts' })
|
||||||
@@ -68,4 +77,42 @@ export class AdminUsersController {
|
|||||||
getDeploymentLogs(@Param('deploymentId') deploymentId: string) {
|
getDeploymentLogs(@Param('deploymentId') deploymentId: string) {
|
||||||
return this.adminUsers.getDeploymentLogs(deploymentId);
|
return this.adminUsers.getDeploymentLogs(deploymentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('users/:id/audit')
|
||||||
|
@ApiOperation({ summary: 'Impersonation audit history for a user' })
|
||||||
|
getAudit(@Param('id') id: string) {
|
||||||
|
return this.adminUsers.getAuditForUser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:id/impersonate')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Login as user — issue an impersonation token (logged)' })
|
||||||
|
async impersonate(@Param('id') id: string, @Req() req: any) {
|
||||||
|
const result = await this.authService.impersonate(
|
||||||
|
{ id: req.user.id, role: req.user.role },
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
await this.adminUsers.logImpersonation(
|
||||||
|
AuditAction.IMPERSONATION_START,
|
||||||
|
req.user.id,
|
||||||
|
id,
|
||||||
|
req.ip,
|
||||||
|
req.headers?.['user-agent'],
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:id/impersonation/stop')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Record the end of an impersonation session (logged)' })
|
||||||
|
async stopImpersonation(@Param('id') id: string, @Req() req: any) {
|
||||||
|
await this.adminUsers.logImpersonation(
|
||||||
|
AuditAction.IMPERSONATION_STOP,
|
||||||
|
req.user.id,
|
||||||
|
id,
|
||||||
|
req.ip,
|
||||||
|
req.headers?.['user-agent'],
|
||||||
|
);
|
||||||
|
return { stopped: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { ApplicationsService } from '../applications/applications.service';
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
} from '../common/enums';
|
} from '../common/enums';
|
||||||
import { Application } from '../applications/entities/application.entity';
|
import { Application } from '../applications/entities/application.entity';
|
||||||
import { Ticket } from '../tickets/entities/ticket.entity';
|
import { Ticket } from '../tickets/entities/ticket.entity';
|
||||||
|
import { AuditAction, AuditLog } from './entities/audit-log.entity';
|
||||||
|
|
||||||
/** How a non-active application can be brought back, for the admin UI. */
|
/** How a non-active application can be brought back, for the admin UI. */
|
||||||
type RestoreEligibility = 'restorable' | 'recoverable' | 'none';
|
type RestoreEligibility = 'restorable' | 'recoverable' | 'none';
|
||||||
@@ -30,8 +33,56 @@ export class AdminUsersService {
|
|||||||
private readonly applicationsService: ApplicationsService,
|
private readonly applicationsService: ApplicationsService,
|
||||||
private readonly ticketsService: TicketsService,
|
private readonly ticketsService: TicketsService,
|
||||||
private readonly deploymentsService: DeploymentsService,
|
private readonly deploymentsService: DeploymentsService,
|
||||||
|
@InjectRepository(AuditLog)
|
||||||
|
private readonly auditRepo: Repository<AuditLog>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Record an impersonation start/stop in the audit trail. */
|
||||||
|
async logImpersonation(
|
||||||
|
action: AuditAction,
|
||||||
|
actorUserId: string,
|
||||||
|
targetUserId: string,
|
||||||
|
ip?: string | null,
|
||||||
|
userAgent?: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.auditRepo.save(
|
||||||
|
this.auditRepo.create({
|
||||||
|
action,
|
||||||
|
actorUserId,
|
||||||
|
targetUserId,
|
||||||
|
ip: ip ?? null,
|
||||||
|
userAgent: userAgent ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Impersonation audit history for a target user, with admin names resolved. */
|
||||||
|
async getAuditForUser(userId: string, limit = 50) {
|
||||||
|
await this.assertUserExists(userId);
|
||||||
|
const rows = await this.auditRepo.find({
|
||||||
|
where: { targetUserId: userId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actorIds = [...new Set(rows.map((r) => r.actorUserId))];
|
||||||
|
const actors = actorIds.length
|
||||||
|
? await this.usersService.findByIds(actorIds)
|
||||||
|
: [];
|
||||||
|
const nameById = new Map(
|
||||||
|
actors.map((a) => [a.id, `${a.firstName ?? ''} ${a.lastName ?? ''}`.trim()]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
action: r.action,
|
||||||
|
actorUserId: r.actorUserId,
|
||||||
|
actorName: nameById.get(r.actorUserId) ?? null,
|
||||||
|
ip: r.ip,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Overview tab: profile + account status + wallet + revenue + summary counts. */
|
/** Overview tab: profile + account status + wallet + revenue + summary counts. */
|
||||||
async getOverview(userId: string) {
|
async getOverview(userId: string) {
|
||||||
const user = await this.usersService.findById(userId);
|
const user = await this.usersService.findById(userId);
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AdminUsersController } from './admin-users.controller';
|
import { AdminUsersController } from './admin-users.controller';
|
||||||
import { AdminUsersService } from './admin-users.service';
|
import { AdminUsersService } from './admin-users.service';
|
||||||
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { ApplicationsModule } from '../applications/applications.module';
|
import { ApplicationsModule } from '../applications/applications.module';
|
||||||
import { DeploymentsModule } from '../deployments/deployments.module';
|
import { DeploymentsModule } from '../deployments/deployments.module';
|
||||||
import { TicketsModule } from '../tickets/tickets.module';
|
import { TicketsModule } from '../tickets/tickets.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Super-admin aggregation module. Imports the domain modules (which export
|
* Super-admin aggregation module. Imports the domain modules (which export
|
||||||
* their services) so the admin controller can read everything about a user
|
* their services) so the admin controller can read everything about a user
|
||||||
* without re-implementing per-module logic.
|
* without re-implementing per-module logic. Also owns the audit log and the
|
||||||
|
* impersonation ("login as user") entrypoint, which delegates token minting to
|
||||||
|
* AuthService.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([AuditLog]),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
ApplicationsModule,
|
ApplicationsModule,
|
||||||
DeploymentsModule,
|
DeploymentsModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
|
AuthModule,
|
||||||
],
|
],
|
||||||
controllers: [AdminUsersController],
|
controllers: [AdminUsersController],
|
||||||
providers: [AdminUsersService],
|
providers: [AdminUsersService],
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/** Auditable admin actions. Kept open-ended for future action types. */
|
||||||
|
export enum AuditAction {
|
||||||
|
IMPERSONATION_START = 'impersonation_start',
|
||||||
|
IMPERSONATION_STOP = 'impersonation_stop',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security audit trail for privileged admin actions. Currently records
|
||||||
|
* impersonation start/stop so it's always known which admin acted as which
|
||||||
|
* user and when.
|
||||||
|
*/
|
||||||
|
@Entity('audit_logs')
|
||||||
|
export class AuditLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// varchar (not enum) to avoid migrations when new action types are added.
|
||||||
|
@Column({ type: 'varchar' })
|
||||||
|
action: AuditAction;
|
||||||
|
|
||||||
|
/** The admin who performed the action. */
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
@Index()
|
||||||
|
actorUserId: string;
|
||||||
|
|
||||||
|
/** The user the action targeted (e.g. the impersonated user). */
|
||||||
|
@Column({ type: 'uuid', nullable: true })
|
||||||
|
@Index()
|
||||||
|
targetUserId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
ip: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
userAgent: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -13,7 +15,13 @@ import { RegisterDto } from './dto/register.dto';
|
|||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { normalizeIranMobile } from '../common/phone.util';
|
import { normalizeIranMobile } from '../common/phone.util';
|
||||||
import { OtpMessageKind } from '../common/enums';
|
import { OtpMessageKind, UserRole } from '../common/enums';
|
||||||
|
|
||||||
|
/** The acting admin recorded inside an impersonation token (RFC-8693-style "act"). */
|
||||||
|
export interface ActClaim {
|
||||||
|
sub: string; // admin user id
|
||||||
|
role: string; // admin role at impersonation time
|
||||||
|
}
|
||||||
|
|
||||||
/** Returned when an action needs phone verification before tokens are issued. */
|
/** Returned when an action needs phone verification before tokens are issued. */
|
||||||
export interface VerificationRequired {
|
export interface VerificationRequired {
|
||||||
@@ -165,12 +173,42 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.generateTokens(user);
|
// Preserve the impersonation context across refreshes so an admin's
|
||||||
|
// "login as user" session survives token rotation.
|
||||||
|
return this.generateTokens(user, payload.act);
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue tokens that authenticate as `targetUserId` while recording the acting
|
||||||
|
* admin in an `act` claim. Guardrails: the target must exist, be active, and
|
||||||
|
* not be an admin (no admin-on-admin impersonation). Tokens are short-lived.
|
||||||
|
*/
|
||||||
|
async impersonate(admin: { id: string; role: string }, targetUserId: string) {
|
||||||
|
const target = await this.usersService.findById(targetUserId);
|
||||||
|
if (!target) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
if (!target.isActive) {
|
||||||
|
throw new BadRequestException('Cannot impersonate a deactivated account');
|
||||||
|
}
|
||||||
|
if (target.role === UserRole.ADMIN) {
|
||||||
|
throw new ForbiddenException('Cannot impersonate an administrator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.generateTokens(target, {
|
||||||
|
sub: admin.id,
|
||||||
|
role: admin.role,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
user: this.publicUser(target),
|
||||||
|
...tokens,
|
||||||
|
impersonation: { by: admin.id, at: new Date().toISOString() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private publicUser(user: User) {
|
private publicUser(user: User) {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -183,19 +221,27 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateTokens(user: User) {
|
private async generateTokens(user: User, act?: ActClaim) {
|
||||||
// `email` may be null; use phone as the human-readable identity claim.
|
// `email` may be null; use phone as the human-readable identity claim.
|
||||||
const payload = {
|
const payload: Record<string, unknown> = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email ?? user.phone,
|
email: user.email ?? user.phone,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
};
|
};
|
||||||
|
// Impersonation sessions carry the acting admin and are deliberately short-lived.
|
||||||
|
if (act) {
|
||||||
|
payload.act = act;
|
||||||
|
}
|
||||||
|
const accessExpiresIn = act ? '30m' : this.configService.get('jwt.expiresIn');
|
||||||
|
const refreshExpiresIn = act
|
||||||
|
? '30m'
|
||||||
|
: this.configService.get('jwt.refreshExpiresIn');
|
||||||
|
|
||||||
const [accessToken, refreshToken] = await Promise.all([
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
this.jwtService.signAsync(payload),
|
this.jwtService.signAsync(payload, { expiresIn: accessExpiresIn }),
|
||||||
this.jwtService.signAsync(payload, {
|
this.jwtService.signAsync(payload, {
|
||||||
secret: this.configService.get('jwt.refreshSecret'),
|
secret: this.configService.get('jwt.refreshSecret'),
|
||||||
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
expiresIn: refreshExpiresIn,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface JwtPayload {
|
|||||||
sub: string;
|
sub: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
/** Present on impersonation tokens: the acting admin. */
|
||||||
|
act?: { sub: string; role: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -24,6 +26,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
id: payload.sub,
|
id: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
|
// Non-null only while an admin is impersonating this user.
|
||||||
|
impersonatedBy: payload.act?.sub ?? null,
|
||||||
|
impersonatorRole: payload.act?.role ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks sensitive self-service actions while an admin is impersonating the
|
||||||
|
* user (token carries an `act` claim, surfaced as `req.user.impersonatedBy`).
|
||||||
|
* Apply to endpoints like changing one's own password/phone — operations an
|
||||||
|
* admin acting "as" a user must never perform on their behalf.
|
||||||
|
*
|
||||||
|
* Runs after AuthGuard('jwt'), so `req.user` is already populated.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ImpersonationGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
if (user?.impersonatedBy) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'This action is not allowed while impersonating a user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'cla
|
|||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { VerificationService } from './verification.service';
|
import { VerificationService } from './verification.service';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { ImpersonationGuard } from '../common/guards/impersonation.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
import { UserRole } from '../common/enums';
|
import { UserRole } from '../common/enums';
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('me/password')
|
@Post('me/password')
|
||||||
|
@UseGuards(ImpersonationGuard)
|
||||||
@ApiOperation({ summary: 'Change own password (requires current password)' })
|
@ApiOperation({ summary: 'Change own password (requires current password)' })
|
||||||
async changePassword(@Request() req: any, @Body() dto: ChangePasswordDto) {
|
async changePassword(@Request() req: any, @Body() dto: ChangePasswordDto) {
|
||||||
await this.usersService.changeOwnPassword(
|
await this.usersService.changeOwnPassword(
|
||||||
@@ -143,6 +145,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('me/phone/request')
|
@Post('me/phone/request')
|
||||||
|
@UseGuards(ImpersonationGuard)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Start phone change — sends an OTP to the new number',
|
summary: 'Start phone change — sends an OTP to the new number',
|
||||||
})
|
})
|
||||||
@@ -158,6 +161,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('me/phone/confirm')
|
@Post('me/phone/confirm')
|
||||||
|
@UseGuards(ImpersonationGuard)
|
||||||
@ApiOperation({ summary: 'Confirm phone change with the OTP' })
|
@ApiOperation({ summary: 'Confirm phone change with the OTP' })
|
||||||
async confirmPhoneChange(@Request() req: any, @Body() dto: ConfirmCodeDto) {
|
async confirmPhoneChange(@Request() req: any, @Body() dto: ConfirmCodeDto) {
|
||||||
return this.verificationService.confirmPhoneChange(req.user.id, dto.code);
|
return this.verificationService.confirmPhoneChange(req.user.id, dto.code);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, ILike, LessThan } from 'typeorm';
|
import { Repository, ILike, LessThan, In } from 'typeorm';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { User } from './entities/user.entity';
|
import { User } from './entities/user.entity';
|
||||||
import { UserRole } from '../common/enums';
|
import { UserRole } from '../common/enums';
|
||||||
@@ -105,6 +105,12 @@ export class UsersService {
|
|||||||
return this.usersRepository.findOne({ where: { id } });
|
return this.usersRepository.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Batch lookup by id — used to resolve actor names for audit logs. */
|
||||||
|
async findByIds(ids: string[]): Promise<User[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
return this.usersRepository.find({ where: { id: In(ids) } });
|
||||||
|
}
|
||||||
|
|
||||||
/** True when `plain` matches the user's stored password hash. */
|
/** True when `plain` matches the user's stored password hash. */
|
||||||
async verifyPassword(user: User, plain: string): Promise<boolean> {
|
async verifyPassword(user: User, plain: string): Promise<boolean> {
|
||||||
return bcrypt.compare(plain, user.password);
|
return bcrypt.compare(plain, user.password);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useParams } from 'next/navigation';
|
|||||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import { startImpersonation } from '@/lib/impersonation';
|
||||||
import type {
|
import type {
|
||||||
AdminUserOverview,
|
AdminUserOverview,
|
||||||
AdminUserApplication,
|
AdminUserApplication,
|
||||||
@@ -13,6 +15,7 @@ import type {
|
|||||||
AdminUserTicket,
|
AdminUserTicket,
|
||||||
AdminActivityEvent,
|
AdminActivityEvent,
|
||||||
AdminDeploymentLogs,
|
AdminDeploymentLogs,
|
||||||
|
AdminAuditEntry,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -27,9 +30,11 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
Layers,
|
Layers,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
UserCog,
|
||||||
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type TabKey = 'overview' | 'applications' | 'activity' | 'errors' | 'tickets';
|
type TabKey = 'overview' | 'applications' | 'activity' | 'errors' | 'tickets' | 'audit';
|
||||||
type Det = ReturnType<typeof useT>['dashboard']['users']['detail'];
|
type Det = ReturnType<typeof useT>['dashboard']['users']['detail'];
|
||||||
|
|
||||||
const lifecycleBadge: Record<string, string> = {
|
const lifecycleBadge: Record<string, string> = {
|
||||||
@@ -78,6 +83,7 @@ export default function AdminUserDetailPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const router = useLocalizedRouter();
|
const router = useLocalizedRouter();
|
||||||
const [tab, setTab] = useState<TabKey>('overview');
|
const [tab, setTab] = useState<TabKey>('overview');
|
||||||
|
const [impersonating, setImpersonating] = useState(false);
|
||||||
|
|
||||||
const money = (n: number | string) =>
|
const money = (n: number | string) =>
|
||||||
`${Number(n || 0).toLocaleString('en-US')} ${t.common.currencyShort}`;
|
`${Number(n || 0).toLocaleString('en-US')} ${t.common.currencyShort}`;
|
||||||
@@ -111,6 +117,24 @@ export default function AdminUserDetailPage() {
|
|||||||
enabled: tab === 'tickets',
|
enabled: tab === 'tickets',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const audit = useQuery<AdminAuditEntry[]>({
|
||||||
|
queryKey: ['admin-user', id, 'audit'],
|
||||||
|
queryFn: () => api.get(`/admin/users/${id}/audit`).then((r) => r.data),
|
||||||
|
enabled: tab === 'audit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLoginAsUser = async () => {
|
||||||
|
setImpersonating(true);
|
||||||
|
try {
|
||||||
|
await startImpersonation(id);
|
||||||
|
// Hard reload into the impersonated session.
|
||||||
|
window.location.href = `/${locale}/dashboard`;
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(err, det.impersonateFailed);
|
||||||
|
setImpersonating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
@@ -130,8 +154,11 @@ export default function AdminUserDetailPage() {
|
|||||||
{ key: 'activity', label: det.tabActivity, icon: <Layers className="w-4 h-4" /> },
|
{ key: 'activity', label: det.tabActivity, icon: <Layers className="w-4 h-4" /> },
|
||||||
{ key: 'errors', label: det.tabErrors, icon: <AlertTriangle className="w-4 h-4" /> },
|
{ key: 'errors', label: det.tabErrors, icon: <AlertTriangle className="w-4 h-4" /> },
|
||||||
{ key: 'tickets', label: det.tabTickets, icon: <TicketIcon className="w-4 h-4" />, badge: overview.counts.ticketsOpen },
|
{ key: 'tickets', label: det.tabTickets, icon: <TicketIcon className="w-4 h-4" />, badge: overview.counts.ticketsOpen },
|
||||||
|
{ key: 'audit', label: det.tabAudit, icon: <ShieldCheck className="w-4 h-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const canImpersonate = p.role !== 'admin' && p.isActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -142,24 +169,38 @@ export default function AdminUserDetailPage() {
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 rtl:rotate-180" /> {det.back}
|
<ArrowLeft className="w-4 h-4 rtl:rotate-180" /> {det.back}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<div>
|
||||||
{p.firstName} {p.lastName}
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
</h1>
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
<span
|
{p.firstName} {p.lastName}
|
||||||
className={`badge ${
|
</h1>
|
||||||
p.role === 'admin' ? 'badge-purple' : p.role === 'user' ? 'badge-gray' : 'badge-blue'
|
<span
|
||||||
}`}
|
className={`badge ${
|
||||||
>
|
p.role === 'admin' ? 'badge-purple' : p.role === 'user' ? 'badge-gray' : 'badge-blue'
|
||||||
{lookup(roleLabels, p.role)}
|
}`}
|
||||||
</span>
|
>
|
||||||
<span className={`badge ${p.isActive ? 'badge-green' : 'badge-red'}`}>
|
{lookup(roleLabels, p.role)}
|
||||||
{p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive}
|
</span>
|
||||||
</span>
|
<span className={`badge ${p.isActive ? 'badge-green' : 'badge-red'}`}>
|
||||||
|
{p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1" dir="ltr">
|
||||||
|
{p.phone || p.email || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canImpersonate && (
|
||||||
|
<button
|
||||||
|
onClick={onLoginAsUser}
|
||||||
|
disabled={impersonating}
|
||||||
|
className="btn-primary inline-flex items-center gap-2 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<UserCog className="w-4 h-4" />
|
||||||
|
{impersonating ? det.loading : det.loginAsUser}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 mt-1" dir="ltr">
|
|
||||||
{p.phone || p.email || '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
@@ -367,6 +408,46 @@ export default function AdminUserDetailPage() {
|
|||||||
}
|
}
|
||||||
</TabState>
|
</TabState>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Admin access log (impersonation audit) ── */}
|
||||||
|
{tab === 'audit' && (
|
||||||
|
<TabState query={audit}>
|
||||||
|
{(list) =>
|
||||||
|
list.length === 0 ? (
|
||||||
|
<div className="card text-center py-10 text-sm text-gray-500">{det.noAudit}</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50/80">
|
||||||
|
<tr>
|
||||||
|
<Th>{det.auditAction}</Th>
|
||||||
|
<Th>{det.auditAdmin}</Th>
|
||||||
|
<Th>{det.auditIp}</Th>
|
||||||
|
<Th>{det.auditTime}</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{list.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<span className={`badge ${a.action === 'impersonation_start' ? 'badge-blue' : 'badge-gray'}`}>
|
||||||
|
{lookup(det.auditActions as Record<string, string>, a.action)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">{a.actorName || a.actorUserId}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500" dir="ltr">{a.ip || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">
|
||||||
|
{new Date(a.createdAt).toLocaleString(locale)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</TabState>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useT } from '@/i18n/I18nProvider';
|
|||||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||||
import { DeploymentProgressManager } from '@/components/deployment-progress-manager';
|
import { DeploymentProgressManager } from '@/components/deployment-progress-manager';
|
||||||
|
import { ImpersonationBanner } from '@/components/impersonation-banner';
|
||||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -222,6 +223,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<ImpersonationBanner />
|
||||||
<DeploymentProgressManager />
|
<DeploymentProgressManager />
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
|
import {
|
||||||
|
getImpersonation,
|
||||||
|
stopImpersonation,
|
||||||
|
type ImpersonationInfo,
|
||||||
|
} from '@/lib/impersonation';
|
||||||
|
import { UserCog, LogOut } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent banner shown while an admin is impersonating a user. Reads the
|
||||||
|
* impersonation marker from localStorage (only changes across full reloads).
|
||||||
|
*/
|
||||||
|
export function ImpersonationBanner() {
|
||||||
|
const t = useT();
|
||||||
|
const locale = useLocale();
|
||||||
|
const det = t.dashboard.users.detail;
|
||||||
|
const [info, setInfo] = useState<ImpersonationInfo | null>(null);
|
||||||
|
const [exiting, setExiting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInfo(getImpersonation());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!info) return null;
|
||||||
|
|
||||||
|
const exit = async () => {
|
||||||
|
setExiting(true);
|
||||||
|
const targetId = await stopImpersonation();
|
||||||
|
// Hard reload back into the admin session, landing on the user's detail page.
|
||||||
|
window.location.href = targetId
|
||||||
|
? `/${locale}/dashboard/admin/users/${targetId}`
|
||||||
|
: `/${locale}/dashboard/admin/users`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-500 text-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2 flex items-center justify-between gap-3">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium min-w-0">
|
||||||
|
<UserCog className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{det.impersonatingBanner.replace('{name}', info.targetName || '—')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={exit}
|
||||||
|
disabled={exiting}
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 bg-white/20 hover:bg-white/30 disabled:opacity-60 rounded-lg px-3 py-1 text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
|
{exiting ? det.loading : det.exitImpersonation}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -869,6 +869,17 @@ const en: Dictionary = {
|
|||||||
ticketDepts: { technical: 'Technical', sales: 'Sales' },
|
ticketDepts: { technical: 'Technical', sales: 'Sales' },
|
||||||
ticketPriorities: { low: 'Low', medium: 'Medium', high: 'High' },
|
ticketPriorities: { low: 'Low', medium: 'Medium', high: 'High' },
|
||||||
senderRoles: { user: 'User', admin: 'Admin', technical: 'Technical support', sales: 'Sales' },
|
senderRoles: { user: 'User', admin: 'Admin', technical: 'Technical support', sales: 'Sales' },
|
||||||
|
loginAsUser: 'Login as user',
|
||||||
|
impersonateFailed: 'Failed to start impersonation',
|
||||||
|
impersonatingBanner: 'You are viewing the panel as "{name}"',
|
||||||
|
exitImpersonation: 'Exit user mode',
|
||||||
|
tabAudit: 'Admin access log',
|
||||||
|
auditAction: 'Event',
|
||||||
|
auditAdmin: 'Admin',
|
||||||
|
auditTime: 'Time',
|
||||||
|
auditIp: 'IP',
|
||||||
|
noAudit: 'No admin access recorded.',
|
||||||
|
auditActions: { impersonation_start: 'Logged in as user', impersonation_stop: 'Exited user mode' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pools: {
|
pools: {
|
||||||
|
|||||||
@@ -868,6 +868,17 @@ const fa = {
|
|||||||
ticketDepts: { technical: 'فنی', sales: 'فروش' },
|
ticketDepts: { technical: 'فنی', sales: 'فروش' },
|
||||||
ticketPriorities: { low: 'کم', medium: 'متوسط', high: 'زیاد' },
|
ticketPriorities: { low: 'کم', medium: 'متوسط', high: 'زیاد' },
|
||||||
senderRoles: { user: 'کاربر', admin: 'مدیر', technical: 'پشتیبان فنی', sales: 'کارشناس فروش' },
|
senderRoles: { user: 'کاربر', admin: 'مدیر', technical: 'پشتیبان فنی', sales: 'کارشناس فروش' },
|
||||||
|
loginAsUser: 'ورود بهعنوان کاربر',
|
||||||
|
impersonateFailed: 'ورود بهعنوان کاربر ناموفق بود',
|
||||||
|
impersonatingBanner: 'شما در حال مشاهدهٔ پنل بهعنوان «{name}» هستید',
|
||||||
|
exitImpersonation: 'خروج از حالت کاربر',
|
||||||
|
tabAudit: 'گزارش ورود ادمین',
|
||||||
|
auditAction: 'رویداد',
|
||||||
|
auditAdmin: 'ادمین',
|
||||||
|
auditTime: 'زمان',
|
||||||
|
auditIp: 'IP',
|
||||||
|
noAudit: 'هیچ ورود ادمینی ثبت نشده.',
|
||||||
|
auditActions: { impersonation_start: 'ورود بهعنوان کاربر', impersonation_stop: 'خروج از حالت کاربر' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pools: {
|
pools: {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side "Login as user" (impersonation) helpers.
|
||||||
|
*
|
||||||
|
* The admin's own tokens are stashed under `admin*` keys while the active
|
||||||
|
* `accessToken`/`refreshToken` are swapped for the impersonation tokens, so the
|
||||||
|
* whole app (and the axios interceptor) transparently acts as the target user.
|
||||||
|
* Both start and stop are recorded server-side in the audit log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const IMP_KEY = 'impersonation';
|
||||||
|
|
||||||
|
export interface ImpersonationInfo {
|
||||||
|
targetUserId: string;
|
||||||
|
targetName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImpersonation(): ImpersonationInfo | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(IMP_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as ImpersonationInfo) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImpersonating(): boolean {
|
||||||
|
return getImpersonation() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin impersonating a user. Caller should hard-reload afterwards. */
|
||||||
|
export async function startImpersonation(targetUserId: string): Promise<ImpersonationInfo> {
|
||||||
|
const { data } = await api.post(`/admin/users/${targetUserId}/impersonate`);
|
||||||
|
// Stash the admin session so we can return to it later.
|
||||||
|
localStorage.setItem('adminAccessToken', localStorage.getItem('accessToken') ?? '');
|
||||||
|
localStorage.setItem('adminRefreshToken', localStorage.getItem('refreshToken') ?? '');
|
||||||
|
const info: ImpersonationInfo = {
|
||||||
|
targetUserId,
|
||||||
|
targetName: `${data.user?.firstName ?? ''} ${data.user?.lastName ?? ''}`.trim(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(IMP_KEY, JSON.stringify(info));
|
||||||
|
// Swap in the impersonation tokens.
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End impersonation, restoring the admin session. Caller should hard-reload. */
|
||||||
|
export async function stopImpersonation(): Promise<string | null> {
|
||||||
|
const info = getImpersonation();
|
||||||
|
const adminAccess = localStorage.getItem('adminAccessToken');
|
||||||
|
const adminRefresh = localStorage.getItem('adminRefreshToken');
|
||||||
|
|
||||||
|
// Restore the admin tokens first so the stop call is authorized as the admin.
|
||||||
|
if (adminAccess) localStorage.setItem('accessToken', adminAccess);
|
||||||
|
if (adminRefresh) localStorage.setItem('refreshToken', adminRefresh);
|
||||||
|
localStorage.removeItem('adminAccessToken');
|
||||||
|
localStorage.removeItem('adminRefreshToken');
|
||||||
|
localStorage.removeItem(IMP_KEY);
|
||||||
|
|
||||||
|
if (info?.targetUserId) {
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/users/${info.targetUserId}/impersonation/stop`);
|
||||||
|
} catch {
|
||||||
|
// Best-effort audit; never block returning to the admin session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info?.targetUserId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear any impersonation artefacts (used on full logout). */
|
||||||
|
export function clearImpersonation(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem('adminAccessToken');
|
||||||
|
localStorage.removeItem('adminRefreshToken');
|
||||||
|
localStorage.removeItem(IMP_KEY);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { clearImpersonation } from '@/lib/impersonation';
|
||||||
import type { User, AuthResponse } from '@/types';
|
import type { User, AuthResponse } from '@/types';
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
@@ -80,6 +81,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem('accessToken');
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
|
clearImpersonation();
|
||||||
set({ user: null, isAuthenticated: false });
|
set({ user: null, isAuthenticated: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -568,6 +568,22 @@ export interface AdminActivityEvent {
|
|||||||
meta: Record<string, any>;
|
meta: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminAuditEntry {
|
||||||
|
id: string;
|
||||||
|
action: 'impersonation_start' | 'impersonation_stop' | string;
|
||||||
|
actorUserId: string;
|
||||||
|
actorName?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpersonateResponse {
|
||||||
|
user: User;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
impersonation: { by: string; at: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClusterNode {
|
export interface ClusterNode {
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user