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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 19:57:26 +03:30
co-authored by Cursor
parent 52c7306e78
commit f7b07f6b7a
13 changed files with 435 additions and 49 deletions
+118 -7
View File
@@ -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<AuthUserDto> {
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
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<LoginResponse> {
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<AuthUserDto> {
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<AuthUserDto> {
@@ -612,6 +707,7 @@ export class AuthService {
private async issueLogin(
userId: string,
organizationId: string,
opts?: { skipLastLogin?: boolean },
): Promise<LoginResponse> {
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<AuthUserDto> {
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 } : {}),
};
}
}