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:
@@ -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>
|
||||
Reference in New Issue
Block a user