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
@@ -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);
}
}