From f7b07f6b7a43cf5f1fda6f81159e40a86eea2694 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 17 Oct 2026 19:57:26 +0330 Subject: [PATCH] feat: owner read-only view-as from team users table Allow org owners to inspect any member's data scope via dual JWT; server blocks mutations and team page stays reachable for return. Co-authored-by: Cursor --- apps/api/src/auth/auth.controller.ts | 40 ++++-- apps/api/src/auth/auth.module.ts | 13 +- apps/api/src/auth/auth.service.ts | 125 +++++++++++++++++- apps/api/src/auth/jwt.strategy.ts | 31 +++++ apps/api/src/auth/roles.guard.ts | 67 +++++++++- .../src/auth/view-as-readonly.interceptor.ts | 51 +++++++ apps/api/src/common/record-scope.ts | 5 + apps/web/src/layouts/AppLayout.vue | 51 ++++--- apps/web/src/router/index.ts | 8 +- apps/web/src/stores/auth.ts | 9 ++ apps/web/src/views/TeamSettingsView.vue | 40 +++++- .../2026-10-17-owner-view-as-user-design.md | 34 +++++ packages/shared/src/index.ts | 10 ++ 13 files changed, 435 insertions(+), 49 deletions(-) create mode 100644 apps/api/src/auth/view-as-readonly.interceptor.ts create mode 100644 docs/superpowers/specs/2026-10-17-owner-view-as-user-design.md diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 9faa2c2..c79c3fa 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -26,8 +26,7 @@ import { } from './dto'; import { JwtAuthGuard } from './jwt-auth.guard'; import { CurrentUser } from './current-user.decorator'; - -type AuthActor = { userId: string; organizationId: string }; +import type { Actor } from '../common/record-scope'; @Controller('auth') export class AuthController { @@ -69,7 +68,7 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Post('switch-organization') switchOrganization( - @CurrentUser() user: AuthActor, + @CurrentUser() user: Actor, @Body() dto: SwitchOrganizationDto, ) { return this.authService.switchOrganization(user.userId, dto.organizationId); @@ -77,38 +76,55 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Get('organizations') - listOrganizations(@CurrentUser() user: AuthActor) { + listOrganizations(@CurrentUser() user: Actor) { return this.authService.listMyOrganizations(user.userId); } @UseGuards(JwtAuthGuard) @Get('me') - me(@CurrentUser() user: AuthActor) { - return this.authService.me(user.userId, user.organizationId); + me(@CurrentUser() user: Actor) { + return this.authService.me(user.userId, user.organizationId, user); + } + + @UseGuards(JwtAuthGuard) + @Post('view-as') + viewAs( + @CurrentUser() user: Actor, + @Body() body: { targetUserId?: string }, + ) { + if (!body.targetUserId?.trim()) { + throw new BadRequestException('کاربر هدف مشخص نیست.'); + } + return this.authService.viewAs(user, body.targetUserId.trim()); } @UseGuards(JwtAuthGuard) @Patch('me') - updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) { + updateMe(@CurrentUser() user: Actor, @Body() dto: UpdateProfileDto) { return this.authService.updateMe(user.userId, user.organizationId, dto); } @UseGuards(JwtAuthGuard) @Patch('me/prior-archive') setPriorLens( - @CurrentUser() user: AuthActor, + @CurrentUser() user: Actor, @Body() body: { lens?: string }, ) { if (body.lens !== 'archive' && body.lens !== 'current') { throw new BadRequestException('حالت بایگانی نامعتبر است'); } - return this.authService.setPriorLens(user.userId, user.organizationId, body.lens); + return this.authService.setPriorLens( + user.userId, + user.organizationId, + body.lens, + user, + ); } /** عکس پروفایل کاربر جاری */ @UseGuards(JwtAuthGuard) @Get('me/avatar') - async avatar(@CurrentUser() user: AuthActor) { + async avatar(@CurrentUser() user: Actor) { const file = await this.authService.openAvatar(user.userId); return new StreamableFile(file.stream, { type: file.mimeType }); } @@ -122,7 +138,7 @@ export class AuthController { }), ) uploadAvatar( - @CurrentUser() user: AuthActor, + @CurrentUser() user: Actor, @UploadedFile() file: Express.Multer.File, ) { return this.authService.saveAvatar(user.userId, user.organizationId, file); @@ -130,7 +146,7 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Delete('me/avatar') - removeAvatar(@CurrentUser() user: AuthActor) { + removeAvatar(@CurrentUser() user: Actor) { return this.authService.removeAvatar(user.userId, user.organizationId); } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 85d1347..a13dd73 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -8,6 +9,7 @@ import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { RolesGuard } from './roles.guard'; +import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor'; import { SmsService } from '../common/sms.service'; @Module({ @@ -28,7 +30,16 @@ import { SmsService } from '../common/sms.service'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, RolesGuard, SmsService], + providers: [ + AuthService, + JwtStrategy, + RolesGuard, + SmsService, + { + provide: APP_INTERCEPTOR, + useClass: ViewAsReadOnlyInterceptor, + }, + ], exports: [AuthService, RolesGuard, JwtModule], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index c90218c..51a1009 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -26,6 +26,7 @@ import { SmsService } from '../common/sms.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { MembershipsService } from '../memberships/memberships.service'; import { BillingService } from '../billing/billing.service'; +import type { Actor } from '../common/record-scope'; import { LoginDto, LoginOtpRequestDto, @@ -406,19 +407,113 @@ export class AuthService { return this.finishLogin(user.id); } - async me(userId: string, organizationId: string): Promise { + async me(userId: string, organizationId: string, actor?: Actor): Promise { await this.memberships.requireActive(userId, organizationId); const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException(); } - return this.toAuthUserDto(user, organizationId); + let impersonatorFullName: string | undefined; + if (actor?.viewAsReadOnly && actor.impersonatorUserId) { + const owner = await this.prisma.user.findUnique({ + where: { id: actor.impersonatorUserId }, + select: { fullName: true }, + }); + impersonatorFullName = owner?.fullName; + } + return this.toAuthUserDto(user, organizationId, undefined, actor, impersonatorFullName); + } + + /** + * مالک محیط: مشاهده فقط‌خواندنی به‌جای کاربر هدف، یا بازگشت به حساب خودش. + */ + async viewAs(actor: Actor, targetUserId: string): Promise { + const ownerId = actor.impersonatorUserId ?? actor.userId; + const ownerMembership = await this.prisma.membership.findFirst({ + where: { + userId: ownerId, + organizationId: actor.organizationId, + isActive: true, + role: 'owner', + }, + select: { userId: true, organizationId: true }, + }); + if (!ownerMembership) { + throw new ForbiddenException('فقط مالک محیط می‌تواند به‌جای کاربر دیگر مشاهده کند.'); + } + + if (!targetUserId?.trim()) { + throw new BadRequestException('کاربر هدف مشخص نیست.'); + } + + if (targetUserId === ownerId) { + return this.issueLogin(ownerId, actor.organizationId, { skipLastLogin: true }); + } + + const target = await this.prisma.user.findFirst({ + where: { + id: targetUserId, + isActive: true, + memberships: { + some: { + organizationId: actor.organizationId, + isActive: true, + }, + }, + }, + select: { + id: true, + email: true, + mobile: true, + fullName: true, + memberships: { + where: { organizationId: actor.organizationId, isActive: true }, + select: { role: true }, + take: 1, + }, + }, + }); + if (!target?.memberships[0]) { + throw new NotFoundException('کاربر در این محیط پیدا نشد.'); + } + + const role = target.memberships[0].role as AuthUserDto['role']; + const subscription = await this.billing.requireActiveForOrganization( + actor.organizationId, + ); + const accessToken = this.jwt.sign({ + sub: target.id, + organizationId: actor.organizationId, + role, + impersonatorUserId: ownerId, + viewAsReadOnly: true, + }); + const ownerUser = await this.prisma.user.findUnique({ + where: { id: ownerId }, + select: { fullName: true }, + }); + const userDto = await this.toAuthUserDto( + { ...target, role, organizationId: actor.organizationId }, + actor.organizationId, + subscription, + { + userId: target.id, + role, + organizationId: actor.organizationId, + impersonatorUserId: ownerId, + impersonatorRole: 'owner', + viewAsReadOnly: true, + }, + ownerUser?.fullName ?? 'مالک', + ); + return { accessToken, user: userDto }; } async setPriorLens( userId: string, organizationId: string, lens: 'current' | 'archive', + actor?: Actor, ): Promise { const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId, isActive: true }, @@ -435,7 +530,7 @@ export class AuthService { where: { id: membership.id }, data: { priorArchiveLens: lens }, }); - return this.me(userId, organizationId); + return this.me(userId, organizationId, actor); } async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise { @@ -612,6 +707,7 @@ export class AuthService { private async issueLogin( userId: string, organizationId: string, + opts?: { skipLastLogin?: boolean }, ): Promise { const membership = await this.memberships.requireActive(userId, organizationId); const user = await this.prisma.user.findUnique({ where: { id: userId } }); @@ -626,10 +722,12 @@ export class AuthService { organizationId, membership.role, ); - await this.prisma.user.update({ - where: { id: userId }, - data: { lastLoginAt: new Date() }, - }); + if (!opts?.skipLastLogin) { + await this.prisma.user.update({ + where: { id: userId }, + data: { lastLoginAt: new Date() }, + }); + } const accessToken = this.jwt.sign({ sub: userId, organizationId, @@ -656,6 +754,8 @@ export class AuthService { }, organizationId: string, subscription?: SubscriptionSummaryDto | null, + actor?: Actor, + impersonatorFullName?: string, ): Promise { const membership = await this.prisma.membership.findFirst({ where: { userId: user.id, organizationId, isActive: true }, @@ -670,6 +770,14 @@ export class AuthService { const sub = subscription ?? (await this.billing.requireActiveForOrganization(organizationId)); + const viewAs = + actor?.viewAsReadOnly && actor.impersonatorUserId + ? { + impersonatorId: actor.impersonatorUserId, + impersonatorFullName: impersonatorFullName?.trim() || 'مالک', + canOpenTeamSettings: true, + } + : undefined; return this.toAuthUser({ ...user, role, @@ -682,6 +790,7 @@ export class AuthService { subscription: sub, isOrgOwner: role === 'owner', priorArchive: this.priorArchiveFor(membership), + ...(viewAs ? { viewAs } : {}), }); } @@ -787,6 +896,7 @@ export class AuthService { subscription?: SubscriptionSummaryDto | null; isOrgOwner?: boolean; priorArchive?: AuthUserDto['priorArchive']; + viewAs?: AuthUserDto['viewAs']; }): AuthUserDto { return { id: user.id, @@ -801,6 +911,7 @@ export class AuthService { ...(user.subscription ? { subscription: user.subscription } : {}), ...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}), ...(user.priorArchive ? { priorArchive: user.priorArchive } : {}), + ...(user.viewAs ? { viewAs: user.viewAs } : {}), }; } } diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index d851e78..b0c1ee2 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -16,6 +16,8 @@ export type JwtPayload = { sub: string; organizationId: string; role: string; + impersonatorUserId?: string; + viewAsReadOnly?: boolean; }; @Injectable() @@ -43,6 +45,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) { if (!payload.sub || !payload.organizationId) { throw new UnauthorizedException(); } + + let impersonatorUserId: string | undefined; + let impersonatorRole: string | undefined; + const viewAsReadOnly = Boolean(payload.viewAsReadOnly && payload.impersonatorUserId); + if (viewAsReadOnly && payload.impersonatorUserId) { + const ownerMembership = await this.prisma.membership.findFirst({ + where: { + userId: payload.impersonatorUserId, + organizationId: payload.organizationId, + isActive: true, + role: 'owner', + organization: { isActive: true }, + }, + select: { userId: true, role: true }, + }); + if (!ownerMembership) { + throw new UnauthorizedException(); + } + impersonatorUserId = ownerMembership.userId; + impersonatorRole = ownerMembership.role; + } + const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, select: { id: true, isActive: true }, @@ -111,6 +135,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) { priorGranted && membership.organization.priorArchiveEnabledAt ? membership.organization.priorArchiveEnabledAt : null, + ...(viewAsReadOnly && impersonatorUserId + ? { + impersonatorUserId, + impersonatorRole: impersonatorRole ?? 'owner', + viewAsReadOnly: true, + } + : {}), }; } diff --git a/apps/api/src/auth/roles.guard.ts b/apps/api/src/auth/roles.guard.ts index 1fa4ac1..67af626 100644 --- a/apps/api/src/auth/roles.guard.ts +++ b/apps/api/src/auth/roles.guard.ts @@ -6,26 +6,79 @@ import { } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import type { UserRole } from '@mento/shared'; +import type { Actor } from '../common/record-scope'; import { ROLES_KEY } from './roles.decorator'; +const VIEW_AS_MESSAGE = + 'در حالت مشاهده به‌جای کاربر دیگر امکان تغییر وجود ندارد.'; + +/** خواندن‌های صفحهٔ تیم وقتی مالک در حالت مشاهده است. */ +function isTeamSettingsRead(method: string, url: string): boolean { + if (method.toUpperCase() !== 'GET') return false; + const path = (url.split('?')[0] || '').replace(/\/+$/, ''); + return ( + /\/users(\/|$)/.test(path) || + /\/org-roles(\/|$)/.test(path) || + /\/teams(\/|$)/.test(path) || + /\/organizations\/current/.test(path) + ); +} + +function isViewAsAllowedMutation(method: string, url: string): boolean { + const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/'; + const m = method.toUpperCase(); + if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) { + return true; + } + if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) { + return true; + } + return false; +} + @Injectable() export class RolesGuard implements CanActivate { constructor(private readonly reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ + user?: Actor; + method?: string; + originalUrl?: string; + url?: string; + }>(); + const method = (request.method || 'GET').toUpperCase(); + const url = request.originalUrl || request.url || ''; + const actor = request.user; + + if ( + actor?.viewAsReadOnly && + method !== 'GET' && + method !== 'HEAD' && + method !== 'OPTIONS' && + !isViewAsAllowedMutation(method, url) + ) { + throw new ForbiddenException(VIEW_AS_MESSAGE); + } + const roles = this.reflector.getAllAndOverride(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); if (!roles?.length) return true; - const request = context.switchToHttp().getRequest<{ - user?: { role?: string }; - }>(); - const role = request.user?.role as UserRole | undefined; - if (!role || !roles.includes(role)) { - throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست'); + const role = actor?.role as UserRole | undefined; + if (role && roles.includes(role)) return true; + + if ( + actor?.viewAsReadOnly && + actor.impersonatorRole === 'owner' && + roles.includes('owner') && + isTeamSettingsRead(method, url) + ) { + return true; } - return true; + + throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست'); } } diff --git a/apps/api/src/auth/view-as-readonly.interceptor.ts b/apps/api/src/auth/view-as-readonly.interceptor.ts new file mode 100644 index 0000000..9d7a819 --- /dev/null +++ b/apps/api/src/auth/view-as-readonly.interceptor.ts @@ -0,0 +1,51 @@ +import { + CallHandler, + ExecutionContext, + ForbiddenException, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import type { Request } from 'express'; +import type { Observable } from 'rxjs'; +import type { Actor } from '../common/record-scope'; + +const VIEW_AS_MESSAGE = + 'در حالت مشاهده به‌جای کاربر دیگر امکان تغییر وجود ندارد.'; + +function normalizePath(url: string): string { + const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/'; + return path; +} + +/** مسیرهایی که در حالت مشاهده مجاز به تغییرند (خروج از مشاهده و لنز بایگانی). */ +function isAllowedWhileViewing(method: string, url: string): boolean { + const path = normalizePath(url); + const m = method.toUpperCase(); + if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) { + return true; + } + if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) { + return true; + } + return false; +} + +@Injectable() +export class ViewAsReadOnlyInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const method = (req.method || 'GET').toUpperCase(); + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { + return next.handle(); + } + const actor = req.user; + if (!actor?.viewAsReadOnly) { + return next.handle(); + } + const url = req.originalUrl || req.url || ''; + if (isAllowedWhileViewing(method, url)) { + return next.handle(); + } + throw new ForbiddenException(VIEW_AS_MESSAGE); + } +} diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 6eb9022..a568d10 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -24,6 +24,11 @@ export type Actor = { priorLens?: 'off' | 'current' | 'archive'; /** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */ priorCutoff?: Date | null; + /** مالک واقعی وقتی در حالت مشاهده به‌جای کاربر دیگر است */ + impersonatorUserId?: string; + impersonatorRole?: string; + /** در حالت مشاهده، سرور تغییر را رد می‌کند */ + viewAsReadOnly?: boolean; }; export type ScopedActor = Pick; diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index a163167..6f21b37 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -26,7 +26,15 @@ const route = useRoute(); const { unreadTotal: chatUnreadTotal, ensureConnected: ensureChatConnected } = useOrgChat(); const chatBadge = computed(() => chatUnreadTotal.value); +const canOpenTeamWhileViewing = computed(() => + Boolean(auth.user?.viewAs?.canOpenTeamSettings), +); +/** تنظیمات مدیریتی واقعی (نقش مشاهده‌شده) */ const canSettings = computed(() => canManageSettings(auth.user?.role)); +/** نمایش هاب تنظیمات؛ در مشاهده فقط‌خواندنی فقط صفحهٔ تیم باز می‌ماند */ +const showSettingsNav = computed( + () => canSettings.value || canOpenTeamWhileViewing.value, +); const showOwnRecordsBanner = computed( () => Boolean(auth.user?.role) && !canViewAllRecords(auth.user?.role), ); @@ -142,15 +150,30 @@ function moduleAllowed(module?: string) { return enabledModules.value.includes(module as never); } +function cardAllowedForNav(card: { + ownerOnly?: boolean; + admin?: boolean; + module?: string; + to?: string; +}) { + if (card.ownerOnly && auth.user?.role !== 'owner') return false; + if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false; + if (!moduleAllowed(card.module)) return false; + if ( + canOpenTeamWhileViewing.value && + !canSettings.value && + card.to && + !card.to.startsWith('/settings/team') + ) { + return false; + } + return true; +} + const hubVisible = computed(() => { const map: Record = {}; for (const hub of NAV_HUBS) { - map[hub.key] = hub.cards.some( - (card) => - (!card.ownerOnly || auth.user?.role === 'owner') && - (!card.admin || canSettings.value) && - moduleAllowed(card.module), - ); + map[hub.key] = hub.cards.some((card) => cardAllowedForNav(card)); } return map; }); @@ -159,12 +182,7 @@ const openHub = computed(() => { if (!openHubKey.value) return null; const hub = NAV_HUBS.find((item) => item.key === openHubKey.value); if (!hub) return null; - const cards = hub.cards.filter( - (card) => - (!card.ownerOnly || auth.user?.role === 'owner') && - (!card.admin || canSettings.value) && - moduleAllowed(card.module), - ); + const cards = hub.cards.filter((card) => cardAllowedForNav(card)); if ( hub.key === 'customers' && isMobileNav.value && @@ -183,10 +201,7 @@ const openHub = computed(() => { return { ...hub, cards, - shortcuts: hub.shortcuts.filter( - (item) => - (!item.admin || canSettings.value) && moduleAllowed(item.module), - ), + shortcuts: hub.shortcuts.filter((item) => cardAllowedForNav(item)), }; }); @@ -657,7 +672,7 @@ onUnmounted(() => {