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:
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user