feat(acl): deep data access by section for roles and users

Role default + user override with OR rules; covers customers, leads, sales, activities, notes, routines, projects, and tickets.
This commit is contained in:
2026-10-22 11:53:26 +03:30
parent cb5944e767
commit 09a60b3b5a
25 changed files with 1997 additions and 122 deletions
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import type {
DataAccessPolicy,
DataAccessSection,
RolePermissionKey,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS,
emptySectionDataAccess,
parseDataAccessPolicy,
} from '@mento/shared';
import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const props = defineProps<{
modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
enabledPermissions?: RolePermissionKey[];
disabled?: boolean;
compact?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [DataAccessPolicy];
}>();
/** بخش‌هایی که به RolePermissionKey وصل‌اند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده می‌شوند */
const SECTION_PERM: Partial<Record<DataAccessSection, RolePermissionKey>> = {
customers: 'customers',
leads: 'leads',
sales: 'sales',
activities: 'activities',
notes: 'notes',
};
const visibleSections = computed(() => {
const perms = props.enabledPermissions;
if (!perms?.length) return DATA_ACCESS_SECTIONS;
return DATA_ACCESS_SECTIONS.filter((s) => {
const key = SECTION_PERM[s];
if (!key) return true;
return perms.includes(key);
});
});
const active = ref<DataAccessSection>('customers');
watch(
visibleSections,
(list) => {
if (!list.includes(active.value) && list[0]) active.value = list[0];
},
{ immediate: true },
);
const policy = computed(() => parseDataAccessPolicy(props.modelValue));
function sectionValue(key: DataAccessSection): SectionDataAccess {
return policy.value[key] ?? emptySectionDataAccess();
}
function setSection(key: DataAccessSection, next: SectionDataAccess) {
emit('update:modelValue', {
...policy.value,
[key]: next,
});
}
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const leadStages = ref<IdOption[]>([]);
const pipelines = ref<IdOption[]>([]);
const dealStages = ref<IdOption[]>([]);
const activityBoards = ref<IdOption[]>([]);
const activityStages = ref<IdOption[]>([]);
const activityTypes = ref<IdOption[]>([]);
const projectBoards = ref<IdOption[]>([]);
const projectStages = ref<IdOption[]>([]);
const routineCategories = ref<IdOption[]>([]);
async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/leads/stages');
leadStages.value = data.map((s) => ({ id: s.id, name: s.name }));
} catch {
leadStages.value = [];
}
try {
const { data } = await api.get<
Array<{
id: string;
name: string;
stages?: Array<{ id: string; name: string }>;
}>
>('/pipelines');
pipelines.value = data.map((p) => ({ id: p.id, name: p.name }));
dealStages.value = data.flatMap((p) =>
(p.stages ?? []).map((s) => ({ id: s.id, name: `${p.name} · ${s.name}` })),
);
} catch {
pipelines.value = [];
dealStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/activities/boards');
activityBoards.value = data.map((b) => ({ id: b.id, name: b.name }));
} catch {
activityBoards.value = [];
}
try {
const stageLists = await Promise.all(
activityBoards.value.map(async (b) => {
const { data } = await api.get<Array<{ id: string; name: string }>>(
'/activities/stages',
{ params: { boardId: b.id } },
);
return data.map((s) => ({
id: s.id,
name: `${b.name} · ${s.name}`,
}));
}),
);
activityStages.value = stageLists.flat();
} catch {
activityStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/activities/types');
activityTypes.value = data.map((t) => ({ id: t.id, name: t.name }));
} catch {
activityTypes.value = [];
}
try {
const { data } = await api.get<
Array<{
id: string;
name: string;
stages?: Array<{ id: string; name: string }>;
}>
>('/project-boards');
projectBoards.value = data.map((b) => ({ id: b.id, name: b.name }));
projectStages.value = data.flatMap((b) =>
(b.stages ?? []).map((s) => ({ id: s.id, name: `${b.name} · ${s.name}` })),
);
} catch {
projectBoards.value = [];
projectStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/routines/categories');
routineCategories.value = data.map((c) => ({ id: c.id, name: c.name }));
} catch {
routineCategories.value = [];
}
}
onMounted(() => {
void loadOptions();
});
const stagesForActive = computed(() => {
if (active.value === 'leads') return leadStages.value;
if (active.value === 'sales') return dealStages.value;
if (active.value === 'activities') return activityStages.value;
if (active.value === 'projects') return projectStages.value;
return [];
});
const boardsForActive = computed(() => {
if (active.value === 'activities') return activityBoards.value;
if (active.value === 'projects') return projectBoards.value;
return [];
});
const typesForActive = computed(() => {
if (active.value === 'activities') return activityTypes.value;
if (active.value === 'routines') return routineCategories.value;
return [];
});
</script>
<template>
<div class="dap">
<p v-if="!visibleSections.length" class="muted">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</p>
<template v-else>
<div class="tabs" role="tablist">
<button
v-for="s in visibleSections"
:key="s"
type="button"
class="tab"
:class="{ on: active === s }"
role="tab"
:aria-selected="active === s"
@click="active = s"
>
{{ DATA_ACCESS_SECTION_LABELS[s] }}
</button>
</div>
<DataAccessEditor
:key="active"
:section="active"
:model-value="sectionValue(active)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
:activity-types="typesForActive"
:disabled="disabled"
:compact="compact"
@update:model-value="setSection(active, $event)"
/>
</template>
</div>
</template>
<style scoped>
.dap {
display: grid;
gap: 0.75rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tab {
font: inherit;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: #475569;
}
.tab.on {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
</style>