diff --git a/backend/src/admin/admin-users.controller.ts b/backend/src/admin/admin-users.controller.ts index a6d0c7f..d1bcffb 100644 --- a/backend/src/admin/admin-users.controller.ts +++ b/backend/src/admin/admin-users.controller.ts @@ -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 }; + } } diff --git a/backend/src/admin/admin-users.service.ts b/backend/src/admin/admin-users.service.ts index a17a421..1d29ee2 100644 --- a/backend/src/admin/admin-users.service.ts +++ b/backend/src/admin/admin-users.service.ts @@ -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, ) {} + /** Record an impersonation start/stop in the audit trail. */ + async logImpersonation( + action: AuditAction, + actorUserId: string, + targetUserId: string, + ip?: string | null, + userAgent?: string | null, + ): Promise { + 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); diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index 0c3f137..14dede0 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -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], diff --git a/backend/src/admin/entities/audit-log.entity.ts b/backend/src/admin/entities/audit-log.entity.ts new file mode 100644 index 0000000..c9c8b07 --- /dev/null +++ b/backend/src/admin/entities/audit-log.entity.ts @@ -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 | null; + + @Column({ type: 'varchar', nullable: true }) + ip: string | null; + + @Column({ type: 'varchar', nullable: true }) + userAgent: string | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index ecdb2b4..f34e609 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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 = { 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, }), ]); diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index b5e015d..e02f5da 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -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, }; } } diff --git a/backend/src/common/guards/impersonation.guard.ts b/backend/src/common/guards/impersonation.guard.ts new file mode 100644 index 0000000..3063cf5 --- /dev/null +++ b/backend/src/common/guards/impersonation.guard.ts @@ -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; + } +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index da11549..a335f11 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -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); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index eb0c3ad..343ea6c 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -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 { + 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 { return bcrypt.compare(plain, user.password); diff --git a/frontend/src/app/[lang]/dashboard/admin/users/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/admin/users/[id]/page.tsx index 0110946..a130dde 100644 --- a/frontend/src/app/[lang]/dashboard/admin/users/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/users/[id]/page.tsx @@ -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['dashboard']['users']['detail']; const lifecycleBadge: Record = { @@ -78,6 +83,7 @@ export default function AdminUserDetailPage() { const { id } = useParams<{ id: string }>(); const router = useLocalizedRouter(); const [tab, setTab] = useState('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({ + 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 (
@@ -130,8 +154,11 @@ export default function AdminUserDetailPage() { { key: 'activity', label: det.tabActivity, icon: }, { key: 'errors', label: det.tabErrors, icon: }, { key: 'tickets', label: det.tabTickets, icon: , badge: overview.counts.ticketsOpen }, + { key: 'audit', label: det.tabAudit, icon: }, ]; + const canImpersonate = p.role !== 'admin' && p.isActive; + return (
{/* Header */} @@ -142,24 +169,38 @@ export default function AdminUserDetailPage() { > {det.back} -
-

- {p.firstName} {p.lastName} -

- - {lookup(roleLabels, p.role)} - - - {p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive} - +
+
+
+

+ {p.firstName} {p.lastName} +

+ + {lookup(roleLabels, p.role)} + + + {p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive} + +
+

+ {p.phone || p.email || '—'} +

+
+ {canImpersonate && ( + + )}
-

- {p.phone || p.email || '—'} -

{/* Tabs */} @@ -367,6 +408,46 @@ export default function AdminUserDetailPage() { } )} + + {/* ── Admin access log (impersonation audit) ── */} + {tab === 'audit' && ( + + {(list) => + list.length === 0 ? ( +
{det.noAudit}
+ ) : ( +
+ + + + + + + + + + + {list.map((a) => ( + + + + + + + ))} + +
{det.auditAction}{det.auditAdmin}{det.auditIp}{det.auditTime}
+ + {lookup(det.auditActions as Record, a.action)} + + {a.actorName || a.actorUserId}{a.ip || '—'} + {new Date(a.createdAt).toLocaleString(locale)} +
+
+ ) + } +
+ )}
); } diff --git a/frontend/src/app/[lang]/dashboard/layout.tsx b/frontend/src/app/[lang]/dashboard/layout.tsx index c52472d..b4c5130 100644 --- a/frontend/src/app/[lang]/dashboard/layout.tsx +++ b/frontend/src/app/[lang]/dashboard/layout.tsx @@ -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 (
+ {/* Mobile overlay */} {sidebarOpen && ( diff --git a/frontend/src/components/impersonation-banner.tsx b/frontend/src/components/impersonation-banner.tsx new file mode 100644 index 0000000..4ebb5ce --- /dev/null +++ b/frontend/src/components/impersonation-banner.tsx @@ -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(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 ( +
+
+ + + + {det.impersonatingBanner.replace('{name}', info.targetName || '—')} + + + +
+
+ ); +} diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index 6a5bfb2..afb1dbe 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -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: { diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index 38d70c5..ba81b62 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -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: { diff --git a/frontend/src/lib/impersonation.ts b/frontend/src/lib/impersonation.ts new file mode 100644 index 0000000..6d9e9f0 --- /dev/null +++ b/frontend/src/lib/impersonation.ts @@ -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 { + 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 { + 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); +} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index 2b2f059..5386529 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -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((set) => ({ logout: () => { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); + clearImpersonation(); set({ user: null, isAuthenticated: false }); }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b1a1fbb..c0e158c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -568,6 +568,22 @@ export interface AdminActivityEvent { meta: Record; } +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;