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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UserRole[]>(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('دسترسی به این بخش برای نقش شما مجاز نیست');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown> {
|
||||
const req = context.switchToHttp().getRequest<Request & { user?: Actor }>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ export type Actor = {
|
||||
priorLens?: 'off' | 'current' | 'archive';
|
||||
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیتهای ساختهشده قبل از آن میتوانند قبلی باشند */
|
||||
priorCutoff?: Date | null;
|
||||
/** مالک واقعی وقتی در حالت مشاهده بهجای کاربر دیگر است */
|
||||
impersonatorUserId?: string;
|
||||
impersonatorRole?: string;
|
||||
/** در حالت مشاهده، سرور تغییر را رد میکند */
|
||||
viewAsReadOnly?: boolean;
|
||||
};
|
||||
|
||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||
|
||||
@@ -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<string, boolean> = {};
|
||||
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<NavHubDef | null>(() => {
|
||||
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<NavHubDef | null>(() => {
|
||||
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(() => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canSettings"
|
||||
v-if="showSettingsNav"
|
||||
class="nav-fold"
|
||||
:class="{ active: settingsActive || openHubKey === 'settings' }"
|
||||
>
|
||||
@@ -1027,7 +1042,7 @@ onUnmounted(() => {
|
||||
<i class="chev" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canSettings"
|
||||
v-if="showSettingsNav"
|
||||
class="nav-item nav-fold-btn"
|
||||
type="button"
|
||||
:class="{
|
||||
|
||||
@@ -500,9 +500,13 @@ router.beforeEach((to) => {
|
||||
const roles = to.meta.roles as UserRole[] | undefined;
|
||||
if (roles?.length) {
|
||||
const role = auth.user?.role as UserRole | undefined;
|
||||
if (!role || !roles.includes(role)) {
|
||||
return { name: 'home' };
|
||||
if (role && roles.includes(role)) {
|
||||
return true;
|
||||
}
|
||||
if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) {
|
||||
return true;
|
||||
}
|
||||
return { name: 'home' };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -199,6 +199,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setUser(data);
|
||||
}
|
||||
|
||||
/** مشاهده فقطخواندنی بهجای کاربر، یا بازگشت با targetUserId = خود مالک */
|
||||
async function viewAs(targetUserId: string) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/view-as', {
|
||||
targetUserId,
|
||||
});
|
||||
persist(data);
|
||||
}
|
||||
|
||||
if (token.value) void hydrate();
|
||||
|
||||
return {
|
||||
@@ -221,5 +229,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
removeAvatar,
|
||||
viewAs,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -102,6 +102,12 @@ const loadedAvatars = new Set<string>();
|
||||
const canInvite = computed(() => canManageUsers(auth.user?.role));
|
||||
const canEditOrg = computed(() => canManageSettings(auth.user?.role));
|
||||
const canEditRoles = computed(() => canManageUsers(auth.user?.role));
|
||||
/** مالک واقعی میتواند از جدول کاربران وارد مشاهده شود / برگردد */
|
||||
const canViewAsUsers = computed(
|
||||
() =>
|
||||
auth.user?.role === 'owner' || Boolean(auth.user?.viewAs?.canOpenTeamSettings),
|
||||
);
|
||||
const viewAsBusy = ref(false);
|
||||
|
||||
const selectedRole = computed(
|
||||
() => roles.value.find((r) => r.id === selectedRoleId.value) ?? null,
|
||||
@@ -232,8 +238,38 @@ function pickRole(id: string) {
|
||||
menuUserId.value = null;
|
||||
}
|
||||
|
||||
function onUserActivate(user: OrgUserDto) {
|
||||
if (user.orgRoleId) pickRole(user.orgRoleId);
|
||||
async function onUserActivate(user: OrgUserDto) {
|
||||
if (!canViewAsUsers.value) {
|
||||
if (user.orgRoleId) pickRole(user.orgRoleId);
|
||||
return;
|
||||
}
|
||||
const impersonatorId = auth.user?.viewAs?.impersonatorId;
|
||||
if (impersonatorId && user.id === impersonatorId) {
|
||||
await switchViewAs(user.id);
|
||||
return;
|
||||
}
|
||||
if (user.id === auth.user?.id) {
|
||||
if (user.orgRoleId) pickRole(user.orgRoleId);
|
||||
return;
|
||||
}
|
||||
await switchViewAs(user.id);
|
||||
}
|
||||
|
||||
async function switchViewAs(targetUserId: string) {
|
||||
if (viewAsBusy.value) return;
|
||||
viewAsBusy.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await auth.viewAs(targetUserId);
|
||||
ok.value = auth.user?.viewAs
|
||||
? `در حال مشاهده بهجای ${auth.user.fullName}`
|
||||
: 'به حساب خودتان برگشتید.';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر حالت مشاهده ناموفق بود.');
|
||||
} finally {
|
||||
viewAsBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLastLogin(iso: string | null | undefined) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# مشاهده بهجای کاربر (فقطخواندنی برای مالک)
|
||||
|
||||
تاریخ: ۲۰۲۶-۱۰-۱۷
|
||||
وضعیت: پیادهسازیشده.
|
||||
|
||||
## هدف
|
||||
|
||||
مالک محیط بتواند از جدول کاربران صفحهٔ تیم، با هویت هر عضو همان محیط وارد شود و همان دید داده/منو/نقش او را ببیند، بدون امکان ایجاد، ویرایش یا حذف.
|
||||
|
||||
## قواعد
|
||||
|
||||
- فقط نقش `owner` میتواند وارد حالت مشاهده شود.
|
||||
- هدف: هر عضو همان `organizationId` (فهرست کاربران تیم).
|
||||
- توکن دو هویت دارد: `userId` = کاربر مشاهدهشده، `impersonatorUserId` = مالک واقعی، `viewAsReadOnly: true`.
|
||||
- دید داده و نقش ظاهری مثل کاربر مشاهدهشده است.
|
||||
- هر متد تغییردهنده (POST/PATCH/PUT/DELETE بهجز استثناها) با پیام فارسی رد میشود:
|
||||
«در حالت مشاهده بهجای کاربر دیگر امکان تغییر وجود ندارد.»
|
||||
- ظاهر UI مثل حالت عادی میماند؛ فقط سرور خطا میدهد.
|
||||
- برگشت: کلیک روی ردیف خود مالک در جدول کاربران تیم.
|
||||
- در حالت مشاهده، دسترسی به صفحهٔ تیم برای مالک واقعی باز میماند.
|
||||
- خروج از سیستم مشاهده را تمام میکند؛ ورود بعدی فقط حساب مالک است.
|
||||
|
||||
## استثناهای مجاز در حالت مشاهده
|
||||
|
||||
- `POST`/`PATCH` مربوط به شروع/پایان مشاهده (impersonate / stop)
|
||||
- `POST` خروج (`logout` اگر باشد)
|
||||
- خواندنها: GET
|
||||
- در صورت نیاز: سوئیچ لنز بایگانی اطلاعات قبل (خواندنی برای دیدن همان دید کاربر)
|
||||
|
||||
## خارج از محدوده
|
||||
|
||||
- مشاهده برای غیرمالک
|
||||
- نوار بنر بازگشت (عمداً حذف شد؛ برگشت از جدول تیم)
|
||||
- مخفی کردن دکمههای ویرایش در UI
|
||||
@@ -44,6 +44,16 @@ export interface AuthUserDto {
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
/** اگر باشد، این کاربر سوئیچ «بایگانی اطلاعات قبل» را دارد */
|
||||
priorArchive?: { lens: "current" | "archive"; cutoffAt: string | null } | null;
|
||||
/**
|
||||
* مالک در حال مشاهدهٔ فقطخواندنی بهجای این کاربر است.
|
||||
* role/id مربوط به کاربر مشاهدهشده است.
|
||||
*/
|
||||
viewAs?: {
|
||||
impersonatorId: string;
|
||||
impersonatorFullName: string;
|
||||
/** صفحهٔ تیم برای مالک واقعی باز بماند */
|
||||
canOpenTeamSettings: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type BillingPlanCode =
|
||||
|
||||
Reference in New Issue
Block a user