feat: enforce role section permissions and add a leads permission
Unticking a section hides it from the menu, closes its routes and blocks its writes on the server; existing roles keep their current access via migration.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
-- تیکهای مجوز نقش از این نسخه واقعاً اعمال میشوند.
|
||||
-- تا امروز همهٔ نقشها عملاً به این بخشها دسترسی داشتند؛ همان دسترسی را برای نقشهای موجود حفظ میکنیم.
|
||||
UPDATE "OrgRole"
|
||||
SET "permissions" = ARRAY(
|
||||
SELECT DISTINCT p
|
||||
FROM unnest(
|
||||
"permissions" || ARRAY['customers', 'leads', 'sales', 'billing', 'products', 'activities', 'notes', 'reports']
|
||||
) AS p
|
||||
);
|
||||
|
||||
-- لاگ فعالیت کاربران تا امروز فقط برای مالک و مدیر باز بود (مستقل از تیک)؛ همان را حفظ میکنیم.
|
||||
UPDATE "OrgRole"
|
||||
SET "permissions" = array_remove("permissions", 'audit')
|
||||
WHERE "systemKey" IS NULL OR "systemKey" NOT IN ('owner', 'admin');
|
||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
@@ -50,7 +51,8 @@ const excelUpload = FileInterceptor('file', {
|
||||
});
|
||||
|
||||
@Controller('activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('activities')
|
||||
export class ActivitiesController {
|
||||
constructor(
|
||||
private readonly activitiesService: ActivitiesService,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
@@ -42,9 +43,9 @@ class ListAuditQueryDto {
|
||||
}
|
||||
|
||||
@Controller('audit-logs')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('audit_logs')
|
||||
@Roles('owner', 'admin')
|
||||
@RequirePermission('audit', { reads: true })
|
||||
export class AuditLogsController {
|
||||
constructor(
|
||||
private readonly auditLogs: AuditLogsService,
|
||||
@@ -52,6 +53,7 @@ export class AuditLogsController {
|
||||
) {}
|
||||
|
||||
@Get('attendance')
|
||||
@Roles('owner', 'admin')
|
||||
attendanceList(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListAuditQueryDto,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { RolePermissionKey } from '@mento/shared';
|
||||
import { ROLE_PERMISSION_ITEMS } from '@mento/shared';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const SECTION_PERMISSION_KEY = 'sectionPermission';
|
||||
|
||||
type SectionPermissionMeta = {
|
||||
key: RolePermissionKey;
|
||||
/** true: خواندن هم مجوز میخواهد. false: فقط مدیریت (ثبت/ویرایش/حذف/ورود/خروجی) */
|
||||
reads: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* مجوز «بخش» از نقش سازمانی. مالک و مدیر همیشه مجازند.
|
||||
* پیشفرض فقط مدیریت را میبندد تا انتخاب رکورد در فرمهای بخشهای دیگر کار کند.
|
||||
*/
|
||||
export const RequirePermission = (key: RolePermissionKey, opts?: { reads?: boolean }) =>
|
||||
SetMetadata(SECTION_PERMISSION_KEY, { key, reads: opts?.reads ?? false } satisfies SectionPermissionMeta);
|
||||
|
||||
function isReadRequest(method: string, url: string): boolean {
|
||||
if (method !== 'GET' && method !== 'HEAD') return false;
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
|
||||
return !path.endsWith('/export');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SectionPermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const meta = this.reflector.getAllAndOverride<SectionPermissionMeta | undefined>(
|
||||
SECTION_PERMISSION_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!meta) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
user?: Actor;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
}>();
|
||||
const actor = req.user;
|
||||
const label = ROLE_PERMISSION_ITEMS.find((item) => item.key === meta.key)?.label ?? meta.key;
|
||||
if (!actor) throw new ForbiddenException(`دسترسی به «${label}» مجاز نیست.`);
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
if (!meta.reads && isReadRequest(method, req.originalUrl || req.url || '')) return true;
|
||||
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = membership?.orgRole?.permissions ?? [];
|
||||
if (!permissions.includes(meta.key)) {
|
||||
throw new ForbiddenException(`دسترسی به «${label}» برای نقش شما مجاز نیست.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { CompaniesService } from './companies.service';
|
||||
@@ -31,7 +32,8 @@ const excelUpload = FileInterceptor('file', {
|
||||
});
|
||||
|
||||
@Controller('companies')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('customers')
|
||||
export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
@@ -33,8 +34,9 @@ const excelUpload = FileInterceptor('file', {
|
||||
});
|
||||
|
||||
@Controller('people')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('customers')
|
||||
@RequirePermission('customers')
|
||||
export class PeopleController {
|
||||
constructor(
|
||||
private readonly peopleService: PeopleService,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -18,7 +19,8 @@ import { InventoryService } from './inventory.service';
|
||||
type AuthUser = { organizationId: string };
|
||||
|
||||
@Controller('inventory')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
|
||||
@RequirePermission('products')
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventory: InventoryService) {}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
CreateInvoiceDto,
|
||||
@@ -22,7 +23,8 @@ import { InvoicesService } from './invoices.service';
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('invoices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('billing')
|
||||
export class InvoicesController {
|
||||
constructor(private readonly invoicesService: InvoicesService) {}
|
||||
|
||||
|
||||
@@ -46,10 +46,12 @@ import {
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { LeadsImportService } from './leads-import.service';
|
||||
import { LeadsService } from './leads.service';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
|
||||
@Controller('leads')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('leads')
|
||||
@RequirePermission('leads', { reads: true })
|
||||
export class LeadsController {
|
||||
constructor(
|
||||
private readonly leads: LeadsService,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto';
|
||||
import { NoteAiService } from './note-ai.service';
|
||||
@@ -30,7 +31,8 @@ type AuthUser = {
|
||||
};
|
||||
|
||||
@Controller('notes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('notes')
|
||||
export class NotesController {
|
||||
constructor(
|
||||
private readonly notesService: NotesService,
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SYSTEM_ROLE_SEEDS: Array<{
|
||||
name: 'کارشناس فروش',
|
||||
description: 'امکان دسترسی به بخشهای فروش و مشتریان برای انجام فعالیتهای روزمره.',
|
||||
color: 'teal',
|
||||
permissions: [...ALL_ROLE_PERMISSIONS],
|
||||
permissions: ALL_ROLE_PERMISSIONS.filter((key) => key !== 'audit'),
|
||||
},
|
||||
{
|
||||
systemKey: 'support',
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
@@ -26,8 +27,9 @@ import { DealsExcelService } from './deals-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('deals')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('pipeline')
|
||||
@RequirePermission('sales')
|
||||
export class DealsController {
|
||||
constructor(
|
||||
private readonly dealsService: DealsService,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -24,7 +25,8 @@ import {
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
@Controller('pipelines')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
|
||||
@RequirePermission('sales')
|
||||
export class PipelinesController {
|
||||
constructor(private readonly pipelinesService: PipelinesService) {}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -27,7 +28,8 @@ import { ProductsExcelService } from './products-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('products')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
|
||||
@RequirePermission('products')
|
||||
export class ProductsController {
|
||||
constructor(
|
||||
private readonly productsService: ProductsService,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
CreateQuoteDto,
|
||||
@@ -21,7 +22,8 @@ import { QuotesService } from './quotes.service';
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('quotes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('billing')
|
||||
export class QuotesController {
|
||||
constructor(private readonly quotesService: QuotesService) {}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { SavedViewsService } from '../saved-views/saved-views.service';
|
||||
@@ -52,7 +53,8 @@ class UpdateReportViewDto {
|
||||
}
|
||||
|
||||
@Controller('reports')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('reports', { reads: true })
|
||||
export class ReportsController {
|
||||
constructor(
|
||||
private readonly reportsService: ReportsService,
|
||||
@@ -95,7 +97,9 @@ export class ReportsController {
|
||||
});
|
||||
}
|
||||
|
||||
/** داشبورد صفحهٔ خانه برای همه باز است */
|
||||
@Get('home')
|
||||
@RequirePermission('reports', { reads: false })
|
||||
home(@CurrentUser() user: Actor) {
|
||||
return this.reportsService.home(user);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
CreateSalesGoalDto,
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
import { SalesService } from './sales.service';
|
||||
|
||||
@Controller('sales')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('sales')
|
||||
export class SalesController {
|
||||
constructor(private readonly salesService: SalesService) {}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ import type {
|
||||
NotificationsListDto,
|
||||
} from '@mento/shared';
|
||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import {
|
||||
canOpenPath,
|
||||
hasSectionPermission,
|
||||
sectionPermissionForPath,
|
||||
} from '@/lib/section-permissions';
|
||||
import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat';
|
||||
|
||||
const auth = useAuthStore();
|
||||
@@ -145,6 +150,11 @@ const moreSectionActive = computed(
|
||||
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
|
||||
const subscription = computed(() => auth.user?.subscription ?? null);
|
||||
|
||||
/** لینکهای مستقیم منو (بیرون از هابها) با مجوز بخش */
|
||||
function canOpen(path: string) {
|
||||
return canOpenPath(auth.user?.role, auth.user?.permissions, path);
|
||||
}
|
||||
|
||||
function moduleAllowed(module?: string) {
|
||||
if (!module) return true;
|
||||
if (!enabledModules.value) return true;
|
||||
@@ -163,7 +173,16 @@ function cardAllowedForNav(card: {
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury'))
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll'));
|
||||
if (card.to?.startsWith('/finance')) return canTreasury;
|
||||
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false;
|
||||
const section = card.to ? sectionPermissionForPath(card.to) : null;
|
||||
const hasSection = section
|
||||
? hasSectionPermission(auth.user?.role, auth.user?.permissions, section)
|
||||
: true;
|
||||
if (!hasSection) return false;
|
||||
// لاگ فعالیت با تیک «لاگ فعالیت کاربران» برای غیرمدیر هم باز میشود
|
||||
const grantedBySection = section === 'audit' && hasSection;
|
||||
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value && !grantedBySection) {
|
||||
return false;
|
||||
}
|
||||
if (!moduleAllowed(card.module)) return false;
|
||||
if (
|
||||
card.admin &&
|
||||
@@ -607,6 +626,7 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="canOpen('/notes')"
|
||||
to="/notes"
|
||||
class="nav-item"
|
||||
title="یادداشتها"
|
||||
@@ -636,7 +656,13 @@ onUnmounted(() => {
|
||||
<UiIcon name="gift" :size="collapsed ? 20 : 18" />
|
||||
<span>باشگاه مشتریان</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/reports" class="nav-item" title="گزارشها" @click="closeHub(); closeMenu()">
|
||||
<RouterLink
|
||||
v-if="canOpen('/reports')"
|
||||
to="/reports"
|
||||
class="nav-item"
|
||||
title="گزارشها"
|
||||
@click="closeHub(); closeMenu()"
|
||||
>
|
||||
<UiIcon name="chart" :size="collapsed ? 20 : 18" />
|
||||
<span>گزارشها</span>
|
||||
</RouterLink>
|
||||
@@ -807,14 +833,24 @@ onUnmounted(() => {
|
||||
</form>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی">
|
||||
<RouterLink
|
||||
v-if="canOpen('/notes')"
|
||||
class="round-btn accent"
|
||||
:to="{ name: 'notes' }"
|
||||
title="هوش مصنوعی"
|
||||
>
|
||||
<UiIcon name="sparkle" :size="18" />
|
||||
</RouterLink>
|
||||
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||
<UiIcon name="bell" :size="18" />
|
||||
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
||||
</button>
|
||||
<RouterLink class="round-btn" :to="{ name: 'activities-calendar' }" title="تقویم">
|
||||
<RouterLink
|
||||
v-if="canOpen('/activities')"
|
||||
class="round-btn"
|
||||
:to="{ name: 'activities-calendar' }"
|
||||
title="تقویم"
|
||||
>
|
||||
<UiIcon name="calendar" :size="18" />
|
||||
</RouterLink>
|
||||
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیامها">
|
||||
@@ -897,6 +933,7 @@ onUnmounted(() => {
|
||||
<span>داشبورد</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="hubVisible.customers || hubVisible.leads"
|
||||
class="tab"
|
||||
type="button"
|
||||
:class="{
|
||||
@@ -915,6 +952,7 @@ onUnmounted(() => {
|
||||
<span>مشتریان</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hubVisible.sales"
|
||||
class="tab"
|
||||
type="button"
|
||||
:class="{ on: !moreOpen && (salesActive || openHubKey === 'sales') }"
|
||||
@@ -925,6 +963,7 @@ onUnmounted(() => {
|
||||
<span>فروش</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hubVisible.activities"
|
||||
class="tab"
|
||||
type="button"
|
||||
:class="{ on: !moreOpen && (activitiesActive || openHubKey === 'activities') }"
|
||||
@@ -984,7 +1023,7 @@ onUnmounted(() => {
|
||||
<UiIcon name="trophy" :size="18" />
|
||||
<span>باشگاه امتیازات</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<RouterLink v-if="canOpen('/notes')" to="/notes" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<UiIcon name="document" :size="18" />
|
||||
<span>یادداشتها</span>
|
||||
</RouterLink>
|
||||
@@ -1017,7 +1056,7 @@ onUnmounted(() => {
|
||||
<UiIcon name="gift" :size="18" />
|
||||
<span>باشگاه مشتریان</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/reports" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<RouterLink v-if="canOpen('/reports')" to="/reports" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<UiIcon name="chart" :size="18" />
|
||||
<span>گزارشها</span>
|
||||
</RouterLink>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { RolePermissionKey, UserRole } from '@mento/shared';
|
||||
|
||||
/** مسیر صفحه ← مجوز بخش؛ همتراز SectionPermissionGuard در API */
|
||||
const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [
|
||||
['/contacts', 'customers'],
|
||||
['/leads', 'leads'],
|
||||
['/pipeline', 'sales'],
|
||||
['/deals', 'sales'],
|
||||
['/sales/', 'sales'],
|
||||
['/settings/pipelines', 'sales'],
|
||||
['/invoices', 'billing'],
|
||||
['/quotes', 'billing'],
|
||||
['/settings/products', 'products'],
|
||||
['/inventory', 'products'],
|
||||
['/activities', 'activities'],
|
||||
['/notes', 'notes'],
|
||||
['/attachments', 'notes'],
|
||||
['/reports', 'reports'],
|
||||
['/settings/audit-logs', 'audit'],
|
||||
];
|
||||
|
||||
export function sectionPermissionForPath(path: string): RolePermissionKey | null {
|
||||
const clean = path.split(/[?#]/)[0] ?? path;
|
||||
for (const [prefix, key] of PATH_PERMISSIONS) {
|
||||
if (clean === prefix || clean.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** مالک و مدیر همیشه؛ بقیه با تیک همان بخش در نقششان */
|
||||
export function hasSectionPermission(
|
||||
role: UserRole | string | null | undefined,
|
||||
permissions: readonly string[] | null | undefined,
|
||||
key: RolePermissionKey,
|
||||
): boolean {
|
||||
if (role === 'owner' || role === 'admin') return true;
|
||||
return Boolean(permissions?.includes(key));
|
||||
}
|
||||
|
||||
export function canOpenPath(
|
||||
role: UserRole | string | null | undefined,
|
||||
permissions: readonly string[] | null | undefined,
|
||||
path: string,
|
||||
): boolean {
|
||||
const key = sectionPermissionForPath(path);
|
||||
return !key || hasSectionPermission(role, permissions, key);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||
import { canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -581,12 +582,19 @@ router.beforeEach((to) => {
|
||||
const modules = auth.user?.enabledModules;
|
||||
if (modules && !modules.includes('treasury')) return { name: 'home' };
|
||||
}
|
||||
if (!canOpenPath(auth.user?.role, auth.user?.permissions, String(to.path))) {
|
||||
return { name: 'home' };
|
||||
}
|
||||
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 true;
|
||||
}
|
||||
// لاگ فعالیت: تیک «لاگ فعالیت کاربران» جایگزین نقش مدیر میشود
|
||||
if (sectionPermissionForPath(String(to.path)) === 'audit') {
|
||||
return true;
|
||||
}
|
||||
if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const isOwner = computed(() => auth.user?.role === 'owner');
|
||||
/** ورود و خروج فقط برای مالک و مدیر؛ بقیه با مجوز «لاگ فعالیت» فقط لاگها را میبینند */
|
||||
const visibleTabs = computed(() =>
|
||||
auth.user?.role === 'owner' || auth.user?.role === 'admin'
|
||||
? TABS
|
||||
: TABS.filter((item) => item.key !== 'attendance'),
|
||||
);
|
||||
|
||||
const loading = ref(false);
|
||||
const savingRetention = ref(false);
|
||||
@@ -366,7 +372,7 @@ onMounted(() => {
|
||||
|
||||
<nav class="tabs" aria-label="نوع رویداد">
|
||||
<button
|
||||
v-for="item in TABS"
|
||||
v-for="item in visibleTabs"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
:class="{ on: tab === item.key }"
|
||||
|
||||
@@ -52,6 +52,7 @@ const router = useRouter();
|
||||
/** مجوزی که ماژولش در سوپرادمین خاموش است در نقشها نشان داده نمیشود. */
|
||||
const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
|
||||
customers: ['customers'],
|
||||
leads: ['leads'],
|
||||
sales: ['pipeline'],
|
||||
billing: ['invoices', 'quotes'],
|
||||
products: ['products'],
|
||||
|
||||
@@ -419,7 +419,7 @@ export const DATA_SCOPE_LABELS: Record<DataScope, string> = {
|
||||
contacts: "مشتریان (اشخاص و شرکتها)",
|
||||
deals: "معاملات و اهداف فروش",
|
||||
activities: "فعالیتها و کارها",
|
||||
leads: "سرنخها",
|
||||
leads: "لیدها",
|
||||
finance: "فاکتور، پیشفاکتور و چک",
|
||||
tickets: "تیکتها",
|
||||
notes: "یادداشتها",
|
||||
@@ -438,6 +438,7 @@ export interface UpdateUserVisibilityRequest {
|
||||
|
||||
export type RolePermissionKey =
|
||||
| "customers"
|
||||
| "leads"
|
||||
| "sales"
|
||||
| "billing"
|
||||
| "products"
|
||||
@@ -456,6 +457,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "customers", label: "مشتریان" },
|
||||
{ key: "leads", label: "لیدها" },
|
||||
{ key: "sales", label: "فروش و معاملات" },
|
||||
{ key: "billing", label: "پیشفاکتورها و فاکتورها" },
|
||||
{ key: "products", label: "محصولات و خدمات" },
|
||||
|
||||
Reference in New Issue
Block a user