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:
2026-10-21 13:45:16 +03:30
parent 631a3267d1
commit 7ae6a5837a
23 changed files with 246 additions and 24 deletions
+45 -6
View File
@@ -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>
+49
View File
@@ -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);
}
+8
View File
@@ -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;
}
+7 -1
View File
@@ -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 }"
+1
View File
@@ -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'],