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:
keyhan
2026-06-18 00:49:02 +03:30
parent 7958d2fa72
commit fd38f5659f
17 changed files with 532 additions and 27 deletions
+48 -1
View File
@@ -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 };
}
} }
+51
View File
@@ -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);
+8 -1
View File
@@ -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;
}
+52 -6
View File
@@ -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;
}
}
+4
View File
@@ -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 -1
View File
@@ -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,6 +169,8 @@ 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-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-gray-900"> <h1 className="text-2xl font-bold text-gray-900">
{p.firstName} {p.lastName} {p.firstName} {p.lastName}
@@ -161,6 +190,18 @@ export default function AdminUserDetailPage() {
{p.phone || p.email || '—'} {p.phone || p.email || '—'}
</p> </p>
</div> </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>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 border-b border-gray-200 overflow-x-auto"> <div className="flex gap-1 border-b border-gray-200 overflow-x-auto">
@@ -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>
);
}
+11
View File
@@ -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: {
+11
View File
@@ -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: {
+81
View File
@@ -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
View File
@@ -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 });
}, },
+16
View File
@@ -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;