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
+92
View File
@@ -12,6 +12,7 @@ import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu, UiModal } from '@/components/ui';
import {
ROLE_PERMISSION_ITEMS,
type DataAccessPolicy,
type InviteUserRequest,
type OrganizationDto,
type OrgModuleKey,
@@ -30,6 +31,7 @@ import {
toFaDigits,
} from '@/lib/jalali';
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
@@ -89,6 +91,8 @@ const orgName = ref('');
const businessField = ref('');
const orgSaving = ref(false);
const accessSaving = ref(false);
const roleFormDataAccess = ref<DataAccessPolicy>({});
const focusDataAccess = ref<DataAccessPolicy>({});
const tab = ref<TabKey>('users');
const query = ref('');
@@ -583,6 +587,7 @@ function openCreateRole(parentId?: string | null) {
roleFormPerms.value = parent
? [...parent.permissions]
: visibleRolePermissions.value.map((p) => p.key);
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
roleFormOpen.value = true;
}
@@ -602,6 +607,7 @@ function openEditRole() {
: 'teal') as RoleColor;
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
roleFormPerms.value = [...role.permissions];
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
roleFormOpen.value = true;
}
@@ -631,12 +637,14 @@ async function submitRoleForm() {
description: string;
color: RoleColor;
permissions: RolePermissionKey[];
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
parentId?: string | null;
} = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
dataAccessPolicy: roleFormDataAccess.value,
};
if (!editing?.systemKey) {
body.parentId = roleFormParentId.value || null;
@@ -693,6 +701,40 @@ async function toggleAccess(role: OrgRoleDto, key: RolePermissionKey) {
}
}
function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
const src = policy ?? {};
const out: DataAccessPolicy = {};
for (const [key, section] of Object.entries(src)) {
if (!section) continue;
out[key as keyof DataAccessPolicy] = {
mode: section.mode,
rules: (section.rules ?? []).map((r) => ({ ...r })),
};
}
return out;
}
function syncFocusDataAccess() {
focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy);
}
async function saveFocusDataAccess() {
const role = accessFocusRole.value;
if (!role || !canEditRoles.value) return;
accessSaving.value = true;
error.value = '';
try {
await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy: focusDataAccess.value });
ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`;
await load();
syncFocusDataAccess();
} catch (e) {
error.value = apiError(e, 'ذخیره محدوده داده ناموفق بود');
} finally {
accessSaving.value = false;
}
}
function onDocClick() {
menuUserId.value = null;
}
@@ -728,10 +770,15 @@ watch(
if (!list.some((r) => r.id === accessFocusId.value)) {
accessFocusId.value = list[0].id;
}
syncFocusDataAccess();
},
{ immediate: true },
);
watch(accessFocusId, () => {
syncFocusDataAccess();
});
onMounted(() => {
syncViewport();
window.addEventListener('resize', syncViewport);
@@ -862,6 +909,29 @@ onUnmounted(() => {
</tbody>
</table>
</div>
<div v-if="accessFocusRole" class="data-access-block">
<div class="matrix-head">
<h3>محدوده داده — {{ accessFocusRole.name }}</h3>
<button
v-if="canEditRoles"
type="button"
class="btn primary"
:disabled="accessSaving"
@click="saveFocusDataAccess"
>
{{ accessSaving ? 'در حال ذخیره…' : 'ذخیره محدوده' }}
</button>
</div>
<p class="hint">
زیرمجموعهٔ مجوز هر بخش: اگر تنظیم نشود همه را می‌بیند؛ قوانین با هم «یا» می‌شوند.
</p>
<DataAccessPolicyPanel
v-model="focusDataAccess"
:enabled-permissions="accessFocusRole.permissions"
:disabled="!canEditRoles"
/>
</div>
</section>
</div>
@@ -1362,6 +1432,14 @@ onUnmounted(() => {
{{ perm.label }}
</label>
</fieldset>
<fieldset class="perm-set">
<legend>محدوده داده</legend>
<DataAccessPolicyPanel
v-model="roleFormDataAccess"
:enabled-permissions="roleFormPerms"
compact
/>
</fieldset>
</form>
<template #footer>
<button type="button" class="btn ghost" @click="roleFormOpen = false">انصراف</button>
@@ -1390,6 +1468,20 @@ onUnmounted(() => {
min-width: 0;
}
.data-access-block {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.75rem;
}
.data-access-block .hint {
color: #64748b;
font-size: 0.85rem;
margin: 0;
}
.page-head {
display: flex;
justify-content: flex-start;