fix(access): honor finance sections and team-member data scope

Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-09 11:20:20 +03:30
co-authored by Cursor
parent 3c9b906bd6
commit 0e1bc4a1fe
15 changed files with 243 additions and 64 deletions
+11 -3
View File
@@ -19,6 +19,10 @@ import type {
StorageUsageDto,
SubscriptionSummaryDto,
} from '@mento/shared';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer';
@@ -848,6 +852,9 @@ export class AuthService {
},
});
const role = membership?.role ?? user.role;
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: [];
const orgName = membership?.organization.name;
const sub =
subscription ??
@@ -879,9 +886,10 @@ export class AuthService {
storage,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: []) as AuthUserDto['permissions'],
permissions: permissionsWithTreasuryOverride(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}),
});
}
+4
View File
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const peers = await this.prisma.teamMember.findMany({
where: {
teamId: { in: memberships.map((m) => m.teamId) },
team: { organizationId },
user: {
memberships: { some: { organizationId, isActive: true } },
},
},
select: { userId: true },
});
+78 -6
View File
@@ -5,7 +5,7 @@ import type {
SectionDataAccess,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import {
activitiesDeepWhere,
@@ -13,6 +13,7 @@ import {
notesDeepWhere,
projectsDeepWhere,
routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere,
} from './data-access';
@@ -60,6 +61,65 @@ export type Actor = {
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
type DeepAccessActor = ScopedActor &
Partial<
Pick<
Actor,
| 'customersAccess'
| 'leadsAccess'
| 'salesAccess'
| 'activitiesAccess'
| 'notesAccess'
| 'routinesAccess'
| 'projectsAccess'
| 'ticketsAccess'
| 'teamPeerIds'
>
>;
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
switch (scope) {
case 'contacts':
return actor.customersAccess;
case 'leads':
return actor.leadsAccess;
case 'deals':
return actor.salesAccess;
case 'activities':
return actor.activitiesAccess;
case 'notes':
return actor.notesAccess;
case 'routines':
return actor.routinesAccess;
case 'projects':
return actor.projectsAccess;
case 'tickets':
return actor.ticketsAccess;
default:
return undefined;
}
}
/**
* شناسه‌های مالک از قانون عمق داده.
* legacy = بخش عمیق روی این Actor نیست.
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
*/
function deepOwnerIds(
actor: DeepAccessActor,
scope: DataScope,
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
const section = sectionForScope(actor, scope);
if (!section) return { kind: 'legacy' };
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
const ids = sectionOwnerIdUnion(section, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (ids === null) return { kind: 'open' };
return { kind: 'ids', ids };
}
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope];
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere(
actor: ScopedActor,
actor: DeepAccessActor,
scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const deep = deepOwnerIds(actor, scope);
if (deep.kind === 'open') return {};
if (deep.kind === 'ids') {
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
if (!deep.ids.length) return { ownerId: '__no_access__' };
return { ownerId: { in: deep.ids } };
}
const ids = scopedOwnerIds(actor, scope);
if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! };
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
}
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere(
actor: ScopedActor,
): Prisma.ActivityWhereInput {
const ids = scopedOwnerIds(actor, 'activities');
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
const deep = deepOwnerIds(actor, 'activities');
const ids =
deep.kind === 'open'
? null
: deep.kind === 'ids'
? deep.ids
: scopedOwnerIds(actor, 'activities');
if (!ids) return {};
if (!ids.length) return { assigneeId: '__no_access__' };
return {
OR: [
{ assigneeId: { in: ids } },
+7 -3
View File
@@ -112,9 +112,13 @@ export class CompaniesService {
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
];
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
...and,
],
};
const [rows, total] = await this.prisma.$transaction([
+9 -5
View File
@@ -218,11 +218,15 @@ export class PeopleService implements OnModuleInit {
if (disc) and.push(disc);
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
return {
organizationId: actor.organizationId,
...customersPersonWhere(actor),
...(moods.length ? { mood: { in: moods } } : {}),
...(sources.length ? { importSource: { in: sources } } : {}),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
...(moods.length ? [{ mood: { in: moods } }] : []),
...(sources.length ? [{ importSource: { in: sources } }] : []),
...and,
],
};
}
+21 -17
View File
@@ -176,22 +176,26 @@ export class LeadsService {
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
if (createdRange) and.push(createdRange);
return {
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null,
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
...(opts.unassigned ? { assigneeId: null } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.region
? { region: { contains: opts.region, mode: 'insensitive' } }
: {}),
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
...(opts.source
? { source: { contains: opts.source, mode: 'insensitive' } }
: {}),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null,
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.region
? { region: { contains: opts.region, mode: 'insensitive' } }
: {}),
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
...(opts.source
? { source: { contains: opts.source, mode: 'insensitive' } }
: {}),
},
...(opts.unassigned ? [{ assigneeId: null }] : []),
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
...and,
],
};
}
@@ -1185,7 +1189,7 @@ export class LeadsService {
newStage
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
: Promise.resolve(0),
this.prisma.lead.count({ where: { ...base, assigneeId: null } }),
this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
convertedIds.length
? this.prisma.lead.count({
where: { ...base, stageId: { in: convertedIds } },
+3 -2
View File
@@ -18,6 +18,7 @@ import {
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { customersPersonWhere } from '../common/data-access';
import {
type Actor,
assigneeWhere,
@@ -54,7 +55,7 @@ export class ReportsService {
const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const personScope = customersPersonWhere(actor);
const durationMs = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1);
@@ -1534,7 +1535,7 @@ export class ReportsService {
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals');
const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const personScope = customersPersonWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId);
const now = new Date();
const { jy, jm } = dateToJalali(now);
+20 -10
View File
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed,
type TreasurySectionKey,
} from '@mento/shared';
@@ -16,7 +17,12 @@ export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
const req = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
if (actor.role === 'owner' || actor.role === 'admin') return true;
@@ -30,11 +36,18 @@ export class TreasuryAccessGuard implements CanActivate {
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String)
: [];
const permissions = permissionsForTreasury(
const permissions = permissionsWithTreasuryOverride(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
);
const method = (req.method || 'GET').toUpperCase();
const url = req.originalUrl || req.url || '';
if (
isLookupForDocuments(method, url) &&
treasurySectionAllowed(actor.role, permissions, 'treasury_documents')
) {
return true;
}
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
}
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
}
}
function permissionsForTreasury(
rolePermissions: string[],
override: ReturnType<typeof parseUserTreasuryAccess>,
): string[] {
if (!override) return rolePermissions;
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
if (!override.enabled) return rest;
return ['treasury', ...override.sections, ...rest];
/** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریت‌شان جدا می‌ماند. */
function isLookupForDocuments(method: string, url: string): boolean {
if (method !== 'GET' && method !== 'HEAD') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
}
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
+12 -6
View File
@@ -21,6 +21,7 @@ import type {
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import {
canOpenPath,
firstAllowedFinancePath,
hasSectionPermission,
sectionPermissionForPath,
} from '@/lib/section-permissions';
@@ -204,12 +205,11 @@ function cardAllowedForNav(card: {
}) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const perms = auth.user?.permissions ?? [];
const canTreasury = auth.user?.role === 'owner'
|| auth.user?.role === 'admin'
|| perms.includes('treasury')
|| perms.some((key) => key.startsWith('treasury_'));
if (card.to?.startsWith('/finance')) {
return moduleAllowed(card.module ?? 'treasury') && canTreasury;
return (
moduleAllowed(card.module ?? 'treasury')
&& Boolean(firstAllowedFinancePath(auth.user?.role, perms))
);
}
const section = card.to ? sectionPermissionForPath(card.to) : null;
const hasSection = section
@@ -246,7 +246,13 @@ 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) => cardAllowedForNav(card));
const financeTo = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
const cards = hub.cards
.filter((card) => cardAllowedForNav(card))
.map((card) => {
if (!financeTo || !card.to.startsWith('/finance') || card.to === financeTo) return card;
return { ...card, to: financeTo };
});
if (
hub.key === 'customers' &&
isMobileNav.value &&
+24
View File
@@ -69,6 +69,30 @@ export function canOpenFinancePath(
return treasurySectionAllowed(role, permissions, section);
}
const FINANCE_ENTRIES: Array<{ path: string; section: TreasurySectionKey }> = [
{ path: '/finance', section: 'treasury_dashboard' },
{ path: '/finance/incomes', section: 'treasury_incomes' },
{ path: '/finance/expenses', section: 'treasury_expenses' },
{ path: '/finance/receivables', section: 'treasury_receivables' },
{ path: '/finance/accounts', section: 'treasury_accounts' },
{ path: '/finance/payroll', section: 'treasury_payroll' },
{ path: '/finance/recurring', section: 'treasury_recurring' },
{ path: '/finance/reports', section: 'treasury_reports' },
{ path: '/finance/audit', section: 'treasury_audit' },
{ path: '/finance/categories', section: 'treasury_categories' },
];
/** اولین صفحهٔ مالی که این نقش واقعاً می‌تواند باز کند. */
export function firstAllowedFinancePath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
): string | null {
for (const entry of FINANCE_ENTRIES) {
if (treasurySectionAllowed(role, permissions, entry.section)) return entry.path;
}
return null;
}
export function canOpenPath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
+8 -8
View File
@@ -2,7 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import { canOpenFinancePath, canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
import {
canOpenFinancePath,
canOpenPath,
firstAllowedFinancePath,
sectionPermissionForPath,
} from '@/lib/section-permissions';
export const router = createRouter({
history: createWebHistory(),
@@ -683,13 +688,8 @@ router.beforeEach((to) => {
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return true;
}
const exactHome = to.path === '/finance' || to.path === '/finance/';
if (
exactHome &&
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
) {
return { path: '/finance/payroll' };
}
const next = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
if (next && next !== to.path) return { path: next };
}
return { name: 'home' };
}
+6
View File
@@ -214,6 +214,12 @@ export const useAuthStore = defineStore('auth', () => {
if (token.value) void hydrate();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void hydrate();
});
}
return {
token,
user,
@@ -96,8 +96,18 @@ function docLine(item: FinancialDocumentDto) {
async function load() {
const [docs, cats, acc, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
return { data: [] as FinancialCategoryDto[] };
}
throw e;
}),
api.get<FinancialAccountDto[]>('/treasury/accounts').catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
return { data: [] as FinancialAccountDto[] };
}
throw e;
}),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
@@ -151,8 +151,18 @@ function docLine(item: FinancialDocumentDto) {
async function load() {
const [docs, cats, acc, dash, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
return { data: [] as FinancialCategoryDto[] };
}
throw e;
}),
api.get<FinancialAccountDto[]>('/treasury/accounts').catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
return { data: [] as FinancialAccountDto[] };
}
throw e;
}),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
+16
View File
@@ -636,6 +636,22 @@ export function parseUserTreasuryAccess(raw: unknown): UserTreasuryAccess | null
};
}
/**
* دسترسی مالی کاربر، اگر تنظیم شده باشد، کلیدهای مالی نقش را جایگزین می‌کند.
* null یعنی همان مجوزهای نقش.
*/
export function permissionsWithTreasuryOverride(
rolePermissions: readonly string[],
override: UserTreasuryAccess | null,
): string[] {
if (!override) return [...rolePermissions];
const rest = rolePermissions.filter(
(key) => key !== "treasury" && !key.startsWith("treasury_"),
);
if (!override.enabled) return rest;
return ["treasury", ...override.sections, ...rest];
}
export function emptySectionDataAccess(): SectionDataAccess {
return { mode: "all", rules: [] };
}