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 {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
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 { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../common/enums';
|
||||
@@ -23,7 +29,10 @@ import { UserRole } from '../common/enums';
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
export class AdminUsersController {
|
||||
constructor(private readonly adminUsers: AdminUsersService) {}
|
||||
constructor(
|
||||
private readonly adminUsers: AdminUsersService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Get('users/:id')
|
||||
@ApiOperation({ summary: 'User overview: profile, status, wallet, revenue, counts' })
|
||||
@@ -68,4 +77,42 @@ export class AdminUsersController {
|
||||
getDeploymentLogs(@Param('deploymentId') deploymentId: string) {
|
||||
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 { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
} from '../common/enums';
|
||||
import { Application } from '../applications/entities/application.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. */
|
||||
type RestoreEligibility = 'restorable' | 'recoverable' | 'none';
|
||||
@@ -30,8 +33,56 @@ export class AdminUsersService {
|
||||
private readonly applicationsService: ApplicationsService,
|
||||
private readonly ticketsService: TicketsService,
|
||||
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. */
|
||||
async getOverview(userId: string) {
|
||||
const user = await this.usersService.findById(userId);
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { ApplicationsModule } from '../applications/applications.module';
|
||||
import { DeploymentsModule } from '../deployments/deployments.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
/**
|
||||
* Super-admin aggregation module. Imports the domain modules (which export
|
||||
* 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({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AuditLog]),
|
||||
UsersModule,
|
||||
BillingModule,
|
||||
ApplicationsModule,
|
||||
DeploymentsModule,
|
||||
TicketsModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [AdminUsersController],
|
||||
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,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -13,7 +15,13 @@ import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
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. */
|
||||
export interface VerificationRequired {
|
||||
@@ -165,12 +173,42 @@ export class AuthService {
|
||||
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 {
|
||||
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) {
|
||||
return {
|
||||
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.
|
||||
const payload = {
|
||||
const payload: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
email: user.email ?? user.phone,
|
||||
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([
|
||||
this.jwtService.signAsync(payload),
|
||||
this.jwtService.signAsync(payload, { expiresIn: accessExpiresIn }),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
||||
expiresIn: refreshExpiresIn,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
/** Present on impersonation tokens: the acting admin. */
|
||||
act?: { sub: string; role: string };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -24,6 +26,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
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 { VerificationService } from './verification.service';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { ImpersonationGuard } from '../common/guards/impersonation.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
@@ -132,6 +133,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Post('me/password')
|
||||
@UseGuards(ImpersonationGuard)
|
||||
@ApiOperation({ summary: 'Change own password (requires current password)' })
|
||||
async changePassword(@Request() req: any, @Body() dto: ChangePasswordDto) {
|
||||
await this.usersService.changeOwnPassword(
|
||||
@@ -143,6 +145,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Post('me/phone/request')
|
||||
@UseGuards(ImpersonationGuard)
|
||||
@ApiOperation({
|
||||
summary: 'Start phone change — sends an OTP to the new number',
|
||||
})
|
||||
@@ -158,6 +161,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Post('me/phone/confirm')
|
||||
@UseGuards(ImpersonationGuard)
|
||||
@ApiOperation({ summary: 'Confirm phone change with the OTP' })
|
||||
async confirmPhoneChange(@Request() req: any, @Body() dto: ConfirmCodeDto) {
|
||||
return this.verificationService.confirmPhoneChange(req.user.id, dto.code);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, ILike, LessThan } from 'typeorm';
|
||||
import { Repository, ILike, LessThan, In } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UserRole } from '../common/enums';
|
||||
@@ -105,6 +105,12 @@ export class UsersService {
|
||||
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. */
|
||||
async verifyPassword(user: User, plain: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, user.password);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useParams } from 'next/navigation';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import api from '@/lib/api';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { startImpersonation } from '@/lib/impersonation';
|
||||
import type {
|
||||
AdminUserOverview,
|
||||
AdminUserApplication,
|
||||
@@ -13,6 +15,7 @@ import type {
|
||||
AdminUserTicket,
|
||||
AdminActivityEvent,
|
||||
AdminDeploymentLogs,
|
||||
AdminAuditEntry,
|
||||
} from '@/types';
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -27,9 +30,11 @@ import {
|
||||
ChevronUp,
|
||||
Layers,
|
||||
CreditCard,
|
||||
UserCog,
|
||||
ShieldCheck,
|
||||
} 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'];
|
||||
|
||||
const lifecycleBadge: Record<string, string> = {
|
||||
@@ -78,6 +83,7 @@ export default function AdminUserDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useLocalizedRouter();
|
||||
const [tab, setTab] = useState<TabKey>('overview');
|
||||
const [impersonating, setImpersonating] = useState(false);
|
||||
|
||||
const money = (n: number | string) =>
|
||||
`${Number(n || 0).toLocaleString('en-US')} ${t.common.currencyShort}`;
|
||||
@@ -111,6 +117,24 @@ export default function AdminUserDetailPage() {
|
||||
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) {
|
||||
return (
|
||||
<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: '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: 'audit', label: det.tabAudit, icon: <ShieldCheck className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const canImpersonate = p.role !== 'admin' && p.isActive;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
@@ -142,24 +169,38 @@ export default function AdminUserDetailPage() {
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 rtl:rotate-180" /> {det.back}
|
||||
</button>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{p.firstName} {p.lastName}
|
||||
</h1>
|
||||
<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'}`}>
|
||||
{p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive}
|
||||
</span>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{p.firstName} {p.lastName}
|
||||
</h1>
|
||||
<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'}`}>
|
||||
{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>
|
||||
<p className="text-sm text-gray-500 mt-1" dir="ltr">
|
||||
{p.phone || p.email || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -367,6 +408,46 @@ export default function AdminUserDetailPage() {
|
||||
}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useT } from '@/i18n/I18nProvider';
|
||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||
import { DeploymentProgressManager } from '@/components/deployment-progress-manager';
|
||||
import { ImpersonationBanner } from '@/components/impersonation-banner';
|
||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -222,6 +223,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<ImpersonationBanner />
|
||||
<DeploymentProgressManager />
|
||||
{/* Mobile overlay */}
|
||||
{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' },
|
||||
ticketPriorities: { low: 'Low', medium: 'Medium', high: 'High' },
|
||||
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: {
|
||||
|
||||
@@ -868,6 +868,17 @@ const fa = {
|
||||
ticketDepts: { technical: 'فنی', sales: 'فروش' },
|
||||
ticketPriorities: { low: 'کم', medium: 'متوسط', high: 'زیاد' },
|
||||
senderRoles: { user: 'کاربر', admin: 'مدیر', technical: 'پشتیبان فنی', sales: 'کارشناس فروش' },
|
||||
loginAsUser: 'ورود بهعنوان کاربر',
|
||||
impersonateFailed: 'ورود بهعنوان کاربر ناموفق بود',
|
||||
impersonatingBanner: 'شما در حال مشاهدهٔ پنل بهعنوان «{name}» هستید',
|
||||
exitImpersonation: 'خروج از حالت کاربر',
|
||||
tabAudit: 'گزارش ورود ادمین',
|
||||
auditAction: 'رویداد',
|
||||
auditAdmin: 'ادمین',
|
||||
auditTime: 'زمان',
|
||||
auditIp: 'IP',
|
||||
noAudit: 'هیچ ورود ادمینی ثبت نشده.',
|
||||
auditActions: { impersonation_start: 'ورود بهعنوان کاربر', impersonation_stop: 'خروج از حالت کاربر' },
|
||||
},
|
||||
},
|
||||
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 api from '@/lib/api';
|
||||
import { clearImpersonation } from '@/lib/impersonation';
|
||||
import type { User, AuthResponse } from '@/types';
|
||||
|
||||
export interface RegisterData {
|
||||
@@ -80,6 +81,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
logout: () => {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
clearImpersonation();
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
|
||||
@@ -568,6 +568,22 @@ export interface AdminActivityEvent {
|
||||
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 {
|
||||
name: string;
|
||||
status: string;
|
||||
|
||||
Reference in New Issue
Block a user