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
+28 -12
View File
@@ -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);
}
}
+12 -1
View File
@@ -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 {}
+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 } : {}),
};
}
}
+31
View File
@@ -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,
}
: {}),
};
}
+60 -7
View File
@@ -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);
}
}
+5
View File
@@ -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'>;
+33 -18
View File
@@ -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="{
+6 -2
View File
@@ -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;
});
+9
View File
@@ -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,
};
});
+38 -2
View File
@@ -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
+10
View File
@@ -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 =