From 0e1bc4a1fe4611071e7b2e596763859b0392761a Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 9 Nov 2026 11:20:20 +0330 Subject: [PATCH] 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 --- apps/api/src/auth/auth.service.ts | 14 +++- apps/api/src/auth/jwt.strategy.ts | 4 + apps/api/src/common/record-scope.ts | 84 +++++++++++++++++-- apps/api/src/contacts/companies.service.ts | 10 ++- apps/api/src/contacts/people.service.ts | 14 ++-- apps/api/src/leads/leads.service.ts | 38 +++++---- apps/api/src/reports/reports.service.ts | 5 +- .../api/src/treasury/treasury-access.guard.ts | 30 ++++--- apps/web/src/layouts/AppLayout.vue | 18 ++-- apps/web/src/lib/section-permissions.ts | 24 ++++++ apps/web/src/router/index.ts | 16 ++-- apps/web/src/stores/auth.ts | 6 ++ .../views/treasury/TreasuryExpensesView.vue | 14 +++- .../views/treasury/TreasuryIncomesView.vue | 14 +++- packages/shared/src/index.ts | 16 ++++ 15 files changed, 243 insertions(+), 64 deletions(-) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 2dafba4..118e9fa 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 } : {}), }); } diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 83595b1..950c17f 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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 }, }); diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 7bd435e..fb51243 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -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; +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(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 { + 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 } }, diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index c44e60d..d7143ed 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -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([ diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 9fce8b6..4e469c1 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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, + ], }; } diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index aa125cd..f693e13 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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 } }, diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index 7eed9b4..62e0214 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -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); diff --git a/apps/api/src/treasury/treasury-access.guard.ts b/apps/api/src/treasury/treasury-access.guard.ts index c08bc00..30b6c5c 100644 --- a/apps/api/src/treasury/treasury-access.guard.ts +++ b/apps/api/src/treasury/treasury-access.guard.ts @@ -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 { - 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, -): 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' { diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 2cdebc1..56bdc1f 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -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(() => { 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 && diff --git a/apps/web/src/lib/section-permissions.ts b/apps/web/src/lib/section-permissions.ts index c81ebe8..5988cf7 100644 --- a/apps/web/src/lib/section-permissions.ts +++ b/apps/web/src/lib/section-permissions.ts @@ -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, diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index fd4130c..8446b8a 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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' }; } diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 1fdca36..d15d782 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -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, diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index dd2a0f7..ba07a14 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -96,8 +96,18 @@ function docLine(item: FinancialDocumentDto) { async function load() { const [docs, cats, acc, bals] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), - api.get('/treasury/categories', { params: { kind } }), - api.get('/treasury/accounts'), + api.get('/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('/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('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; throw e; diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index 227dd12..323bfc7 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -151,8 +151,18 @@ function docLine(item: FinancialDocumentDto) { async function load() { const [docs, cats, acc, dash, bals] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), - api.get('/treasury/categories', { params: { kind } }), - api.get('/treasury/accounts'), + api.get('/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('/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('/treasury/dashboard').catch(() => null), api.get('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f3fcbc7..a88e449 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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: [] }; }