Files
Lucky_Wheel/apps/admin/src/views/roles/RoleMatrixView.vue
T
s.esmaeili c5a841c35f feat(rbac): implement full admin management, dynamic roles, permissions matrix, and workspace access control
- Add Workspace, Role, Permission, RolePermission, AdminRoleAssignment, and AdminWorkspaceAssignment models to Prisma schema\n- Create database migration and RBAC seed script\n- Implement PermissionsGuard, @RequirePermission decorator, and real-time permission resolution in JwtAdminStrategy\n- Implement AdminManagementModule with endpoints for managers CRUD, roles matrix, permissions catalog, and workspaces\n- Secure all sensitive admin controllers with granular permission requirements\n- Add useAuthStore helpers (can, canAny, fetchMe) and v-permission directive\n- Implement Desktop views: AdminListView, AdminFormDrawer (RTL right-side), RoleMatrixView, WorkspaceListView, and ForbiddenView\n- Implement Mobile views: MAdminListView, MAdminFormView (stepped mobile form), MRoleMatrixView, MWorkspaceListView, and MForbiddenView\n- Fix sticky header behavior across desktop and mobile shells\n- Update route guards and device detection logic for mobile prefixing
2026-10-02 16:29:23 +03:30

559 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
CheckOutlined,
DeleteOutlined,
EditOutlined,
LockOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SaveOutlined,
TeamOutlined,
} from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { toFarsiDigits } from '@lucky-wheel/shared-types';
import { useAuthStore } from '../../stores/auth.store';
import { apiClient } from '../../services/api/client';
const authStore = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const roles = ref<any[]>([]);
const permissions = ref<any[]>([]);
const selectedRoleId = ref<string | null>(null);
// Form state for creating a new role
const isCreateModalOpen = ref(false);
const isSubmittingNewRole = ref(false);
const newRoleForm = reactive({
code: '',
name: '',
description: '',
permissionIds: [] as string[],
});
// Permission IDs assigned to the currently selected role in edit matrix
const activeRolePermIds = ref<string[]>([]);
const isRoleDirty = ref(false);
const selectedRole = computed(() => {
return roles.value.find((r) => r.id === selectedRoleId.value) || null;
});
// Group permissions by module
const moduleLabels: Record<string, string> = {
campaigns: 'مدیریت کمپین‌ها',
prizes: 'مدیریت جوایز',
users: 'کاربران سامانه',
chances: 'تنظیم دستی شانس‌ها',
spins: 'تاریخچه اسپین‌ها',
winners: 'مدیریت برندگان و کدهای هدیه',
sms: 'لاگ و ارسال پیامک',
audit: 'لاگ بازرسی (Audit)',
reports: 'گزارش‌ها و KPI',
admins: 'مدیریت مدیران و همکاران',
roles: 'مدیریت نقش‌ها و دسترسی‌ها',
workspaces: 'فضاهای کاری سازمانی',
};
const groupedPermissions = computed(() => {
const groups: Record<string, any[]> = {};
permissions.value.forEach((p) => {
const mod = p.module || 'other';
if (!groups[mod]) groups[mod] = [];
groups[mod].push(p);
});
return groups;
});
async function loadData() {
loading.value = true;
try {
const [rolesRes, permsRes] = await Promise.all([
apiClient.get('/admin/roles'),
apiClient.get('/admin/permissions'),
]);
roles.value = Array.isArray(rolesRes.data) ? rolesRes.data : rolesRes.data?.data || [];
permissions.value = Array.isArray(permsRes.data) ? permsRes.data : permsRes.data?.data || [];
if (!selectedRoleId.value && roles.value.length > 0) {
selectRole(roles.value[0].id);
} else if (selectedRoleId.value) {
selectRole(selectedRoleId.value);
}
} finally {
loading.value = false;
}
}
function selectRole(roleId: string) {
selectedRoleId.value = roleId;
const role = roles.value.find((r) => r.id === roleId);
if (role) {
activeRolePermIds.value = role.permissions ? role.permissions.map((rp: any) => rp.permissionId || rp.permission?.id) : [];
} else {
activeRolePermIds.value = [];
}
isRoleDirty.value = false;
}
function togglePermission(permId: string) {
if (selectedRole.value?.isSystem && selectedRole.value?.code === 'SUPER_ADMIN') {
message.warning('مجوزهای نقش مدیر ارشد به صورت ذاتی کامل و غیرقابل تغییر است.');
return;
}
const idx = activeRolePermIds.value.indexOf(permId);
if (idx > -1) {
activeRolePermIds.value.splice(idx, 1);
} else {
activeRolePermIds.value.push(permId);
}
isRoleDirty.value = true;
}
function toggleModuleAll(mod: string) {
if (selectedRole.value?.isSystem && selectedRole.value?.code === 'SUPER_ADMIN') return;
const perms = groupedPermissions.value[mod] || [];
const ids = perms.map((p) => p.id);
const allSelected = ids.every((id) => activeRolePermIds.value.includes(id));
if (allSelected) {
activeRolePermIds.value = activeRolePermIds.value.filter((id) => !ids.includes(id));
} else {
ids.forEach((id) => {
if (!activeRolePermIds.value.includes(id)) {
activeRolePermIds.value.push(id);
}
});
}
isRoleDirty.value = true;
}
async function saveRolePermissions() {
if (!selectedRoleId.value) return;
saving.value = true;
try {
await apiClient.patch(`/admin/roles/${selectedRoleId.value}`, {
permissionIds: activeRolePermIds.value,
});
message.success('مجوزهای نقش با موفقیت به‌روزرسانی شد.');
isRoleDirty.value = false;
loadData();
} finally {
saving.value = false;
}
}
function openCreateModal() {
newRoleForm.code = '';
newRoleForm.name = '';
newRoleForm.description = '';
newRoleForm.permissionIds = [];
isCreateModalOpen.value = true;
}
async function handleCreateRole() {
if (!newRoleForm.name.trim()) {
message.error('نام فارسی نقش الزامی است.');
return;
}
if (!newRoleForm.code.trim()) {
message.error('کد انگلیسی نقش الزامی است.');
return;
}
isSubmittingNewRole.value = true;
try {
const res = await apiClient.post('/admin/roles', {
name: newRoleForm.name.trim(),
code: newRoleForm.code.trim().toUpperCase(),
description: newRoleForm.description?.trim() || undefined,
permissionIds: newRoleForm.permissionIds,
});
message.success('نقش جدید با موفقیت ایجاد شد.');
isCreateModalOpen.value = false;
await loadData();
if (res.data?.id) selectRole(res.data.id);
} finally {
isSubmittingNewRole.value = false;
}
}
function confirmDeleteRole(role: any) {
Modal.confirm({
title: 'حذف نقش سفارشی',
content: `آیا از حذف نقش «${role.name}» اطمینان دارید؟`,
okText: 'حذف نقش',
okType: 'danger',
cancelText: 'انصراف',
onOk: async () => {
try {
await apiClient.delete(`/admin/roles/${role.id}`);
message.success('نقش با موفقیت حذف شد.');
selectedRoleId.value = null;
loadData();
} catch {
// Handled
}
},
});
}
onMounted(() => {
loadData();
});
</script>
<template>
<div class="roles-matrix-page">
<div class="page-header-title">
<div>
<h1 class="title">مدیریت نقش‌ها و ماتریس مجوزها</h1>
<p class="subtitle">تعریف نقش‌های سازمانی و انتساب دانه‌بندی‌شده مجوزهای دسترسی به ماژول‌های سامانه</p>
</div>
<div class="page-actions" v-if="authStore.can('roles:manage')">
<a-button type="primary" size="large" @click="openCreateModal">
<template #icon><PlusOutlined /></template>
تعریف نقش جدید
</a-button>
</div>
</div>
<a-spin :spinning="loading">
<a-row :gutter="20">
<!-- سمت راست: لیست نقش‌ها -->
<a-col :xs="24" :md="8" :lg="7">
<a-card :bordered="false" class="roles-sidebar-card" title="نقش‌های سامانه">
<div class="roles-list">
<div
v-for="r in roles"
:key="r.id"
class="role-item"
:class="{ 'is-active': r.id === selectedRoleId }"
@click="selectRole(r.id)"
>
<div class="role-item-header">
<div class="role-title">
<SafetyCertificateOutlined
:style="{ color: r.isSystem ? '#7c3aed' : '#2563eb', marginLeft: '6px' }"
/>
<strong>{{ r.name }}</strong>
</div>
<a-tag v-if="r.isSystem" color="purple" size="small">سیستمی</a-tag>
</div>
<div class="role-code">{{ r.code }}</div>
<div class="role-desc">{{ r.description || 'بدون توضیح' }}</div>
<div class="role-footer">
<span class="user-count">
<TeamOutlined style="margin-left: 4px" />
{{ toFarsiDigits(r._count?.admins ?? 0) }} مدیر منتسب
</span>
<a-button
v-if="!r.isSystem && authStore.can('roles:manage')"
type="text"
danger
size="small"
@click.stop="confirmDeleteRole(r)"
>
<DeleteOutlined />
</a-button>
</div>
</div>
</div>
</a-card>
</a-col>
<!-- سمت چپ: ماتریس مجوزهای نقش انتخاب‌شده -->
<a-col :xs="24" :md="16" :lg="17">
<a-card :bordered="false" class="matrix-card">
<template #title>
<div class="matrix-header">
<div>
<h3 style="margin: 0; font-size: 16px">
ماتریس دسترسی‌های:
<span style="color: #2563eb">{{ selectedRole?.name || 'انتخاب کنید' }}</span>
</h3>
<span style="font-size: 12px; color: #64748b">
{{ selectedRole?.code }}
{{ selectedRole?.isSystem ? '• نقش سیستمی پایه' : '• نقش سفارشی' }}
</span>
</div>
<a-button
v-if="authStore.can('roles:manage') && !selectedRole?.isSystem"
type="primary"
:loading="saving"
:disabled="!isRoleDirty"
@click="saveRolePermissions"
>
<template #icon><SaveOutlined /></template>
ذخیره تغییرات مجوزها
</a-button>
</div>
</template>
<div v-if="selectedRole?.code === 'SUPER_ADMIN'" class="super-admin-notice">
<SafetyCertificateOutlined style="font-size: 20px; color: #7c3aed" />
<div>
<strong>نقش مدیر ارشد سامانه (Super Admin)</strong>
<p style="margin: 4px 0 0; color: #6b7280; font-size: 13px">
تمامی مجوزهای سامانه به صورت خودکار و دائمی برای مدیر ارشد فعال است و نیازی به تغییر دستی ندارد.
</p>
</div>
</div>
<div class="modules-grid">
<div
v-for="(perms, modKey) in groupedPermissions"
:key="modKey"
class="module-perm-block"
>
<div class="module-header">
<div class="module-title">
<h4>{{ moduleLabels[modKey] || modKey }}</h4>
<span class="count-label">
({{ toFarsiDigits(perms.filter((p) => activeRolePermIds.includes(p.id)).length) }} از {{ toFarsiDigits(perms.length) }})
</span>
</div>
<a-button
type="link"
size="small"
:disabled="selectedRole?.code === 'SUPER_ADMIN' || !authStore.can('roles:manage')"
@click="toggleModuleAll(modKey)"
>
تغییر انتخاب همه
</a-button>
</div>
<div class="perms-list">
<div
v-for="p in perms"
:key="p.id"
class="perm-checkbox-item"
:class="{ 'is-checked': activeRolePermIds.includes(p.id) }"
@click="togglePermission(p.id)"
>
<a-checkbox
:checked="activeRolePermIds.includes(p.id)"
:disabled="selectedRole?.code === 'SUPER_ADMIN' || !authStore.can('roles:manage')"
>
<strong class="perm-name">{{ p.name }}</strong>
</a-checkbox>
<div class="perm-code">{{ p.code }}</div>
<div class="perm-desc" v-if="p.description">{{ p.description }}</div>
</div>
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</a-spin>
<!-- Create Role Modal -->
<a-modal
v-model:open="isCreateModalOpen"
title="تعریف نقش سازمانی جدید"
okText="ایجاد نقش"
cancelText="انصراف"
:confirmLoading="isSubmittingNewRole"
@ok="handleCreateRole"
>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="نام فارسی نقش" required>
<a-input
v-model:value="newRoleForm.name"
placeholder="مثال: سرپرست بازاریابی"
size="large"
/>
</a-form-item>
<a-form-item label="کد انگلیسی نقش (فقط حروف بزرگ و خط زیر)" required>
<a-input
v-model:value="newRoleForm.code"
placeholder="MARKETING_SUPERVISOR"
dir="ltr"
size="large"
/>
</a-form-item>
<a-form-item label="توضیحات و شرح اختیارات">
<a-textarea
v-model:value="newRoleForm.description"
placeholder="شرح وظایف و اختیارات این نقش..."
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.roles-matrix-page {
padding-bottom: 24px;
}
.page-header-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 22px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.subtitle {
font-size: 13px;
color: #64748b;
margin-top: 4px;
margin-bottom: 0;
}
.roles-sidebar-card,
.matrix-card {
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.roles-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.role-item {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
background: #fff;
}
.role-item:hover {
border-color: #93c5fd;
background: #f8fafc;
}
.role-item.is-active {
border-color: #2563eb;
background: #eff6ff;
}
.role-item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.role-code {
font-size: 11px;
color: #94a3b8;
direction: ltr;
text-align: right;
margin: 2px 0 6px;
}
.role-desc {
font-size: 12px;
color: #64748b;
line-height: 1.5;
}
.role-footer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #64748b;
}
.matrix-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.super-admin-notice {
display: flex;
gap: 12px;
align-items: center;
background: #f5f3ff;
border: 1px solid #ddd6fe;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 20px;
}
.modules-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.module-perm-block {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.module-header {
background: #f8fafc;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.module-title h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
display: inline-block;
color: #1e293b;
}
.count-label {
font-size: 12px;
color: #64748b;
margin-right: 6px;
}
.perms-list {
padding: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
.perm-checkbox-item {
padding: 8px 10px;
border: 1px solid #f1f5f9;
border-radius: 6px;
transition: all 0.15s ease;
cursor: pointer;
background: #fff;
}
.perm-checkbox-item:hover {
border-color: #cbd5e1;
}
.perm-checkbox-item.is-checked {
background: #eff6ff;
border-color: #bfdbfe;
}
.perm-name {
font-size: 13px;
color: #1e293b;
}
.perm-code {
font-size: 11px;
color: #94a3b8;
direction: ltr;
text-align: right;
margin: 2px 24px 0 0;
}
.perm-desc {
font-size: 11px;
color: #64748b;
margin: 2px 24px 0 0;
}
</style>