- 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
559 lines
16 KiB
Vue
559 lines
16 KiB
Vue
<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>
|