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
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
KeyOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SaveOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
adminId?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', val: boolean): void;
|
||||
(e: 'saved'): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const rolesList = ref<any[]>([]);
|
||||
const workspacesList = ref<any[]>([]);
|
||||
const campaignsList = ref<any[]>([]);
|
||||
|
||||
const formState = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
mobile: '',
|
||||
password: '',
|
||||
status: 'ACTIVE',
|
||||
isSuperAdmin: false,
|
||||
roleIds: [] as string[],
|
||||
workspaceId: '',
|
||||
scopeType: 'ALL',
|
||||
specificCampaignIds: [] as string[],
|
||||
});
|
||||
|
||||
const isEdit = computed(() => !!props.adminId);
|
||||
|
||||
async function loadMetadata() {
|
||||
try {
|
||||
const [rolesRes, wsRes, campRes] = await Promise.all([
|
||||
apiClient.get('/admin/roles'),
|
||||
apiClient.get('/admin/workspaces'),
|
||||
apiClient.get('/admin/campaigns', { params: { pageSize: 100 } }),
|
||||
]);
|
||||
rolesList.value = Array.isArray(rolesRes.data) ? rolesRes.data : rolesRes.data?.data || [];
|
||||
workspacesList.value = Array.isArray(wsRes.data) ? wsRes.data : wsRes.data?.data || [];
|
||||
campaignsList.value = Array.isArray(campRes.data) ? campRes.data : campRes.data?.data || [];
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminData(id: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/managers/${id}`);
|
||||
const a = res.data;
|
||||
formState.name = a.name || '';
|
||||
formState.email = a.email || '';
|
||||
formState.mobile = a.mobile || '';
|
||||
formState.password = '';
|
||||
formState.status = a.status || 'ACTIVE';
|
||||
formState.isSuperAdmin = !!a.isSuperAdmin;
|
||||
formState.roleIds = a.roles ? a.roles.map((r: any) => r.role?.id || r.roleId) : [];
|
||||
|
||||
if (a.workspaceAssignments && a.workspaceAssignments.length > 0) {
|
||||
const wa = a.workspaceAssignments[0];
|
||||
formState.workspaceId = wa.workspaceId || wa.workspace?.id || '';
|
||||
formState.scopeType = wa.scopeType || 'ALL';
|
||||
formState.specificCampaignIds = Array.isArray(wa.specificCampaignIds) ? wa.specificCampaignIds : [];
|
||||
} else {
|
||||
formState.workspaceId = workspacesList.value[0]?.id || '';
|
||||
formState.scopeType = 'ALL';
|
||||
formState.specificCampaignIds = [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formState.name = '';
|
||||
formState.email = '';
|
||||
formState.mobile = '';
|
||||
formState.password = '';
|
||||
formState.status = 'ACTIVE';
|
||||
formState.isSuperAdmin = false;
|
||||
formState.roleIds = [];
|
||||
formState.workspaceId = workspacesList.value[0]?.id || '';
|
||||
formState.scopeType = 'ALL';
|
||||
formState.specificCampaignIds = [];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadMetadata();
|
||||
if (props.adminId) {
|
||||
loadAdminData(props.adminId);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!formState.name.trim()) {
|
||||
message.error('لطفاً نام و نام خانوادگی مدیر را وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (!formState.email.trim() || !formState.email.includes('@')) {
|
||||
message.error('لطفاً یک آدرس ایمیل معتبر وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (!formState.mobile.trim() || !/^09\d{9}$/.test(formState.mobile.trim())) {
|
||||
message.error('شماره موبایل باید ۱۱ رقمی بوده و با ۰۹ شروع شود.');
|
||||
return;
|
||||
}
|
||||
if (!isEdit.value && (!formState.password || formState.password.length < 6)) {
|
||||
message.error('کلمه عبور باید حداقل ۶ کاراکتر باشد.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload: any = {
|
||||
name: formState.name.trim(),
|
||||
email: formState.email.trim().toLowerCase(),
|
||||
mobile: formState.mobile.trim(),
|
||||
status: formState.status,
|
||||
isSuperAdmin: formState.isSuperAdmin,
|
||||
roleIds: formState.roleIds,
|
||||
};
|
||||
|
||||
if (formState.password) {
|
||||
payload.password = formState.password;
|
||||
}
|
||||
|
||||
if (formState.workspaceId) {
|
||||
payload.workspaceAssignments = [
|
||||
{
|
||||
workspaceId: formState.workspaceId,
|
||||
scopeType: formState.scopeType,
|
||||
specificCampaignIds:
|
||||
formState.scopeType === 'CUSTOM_CAMPAIGNS' ? formState.specificCampaignIds : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (isEdit.value && props.adminId) {
|
||||
await apiClient.patch(`/admin/managers/${props.adminId}`, payload);
|
||||
message.success('مشخصات مدیر با موفقیت بهروزرسانی شد.');
|
||||
} else {
|
||||
await apiClient.post('/admin/managers', payload);
|
||||
message.success('مدیر جدید با موفقیت ایجاد شد.');
|
||||
}
|
||||
|
||||
emit('saved');
|
||||
emit('update:open', false);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer
|
||||
:open="open"
|
||||
:title="isEdit ? 'ویرایش اطلاعات مدیر' : 'افزودن مدیر جدید به سامانه'"
|
||||
:width="540"
|
||||
placement="right"
|
||||
:maskClosable="false"
|
||||
rootClassName="admin-form-drawer-rtl"
|
||||
@close="emit('update:open', false)"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical">
|
||||
<a-tabs defaultActiveKey="1" class="admin-form-tabs">
|
||||
<!-- TAB 1: اطلاعات فردی و ورود -->
|
||||
<a-tab-pane key="1">
|
||||
<template #tab>
|
||||
<span><UserOutlined /> اطلاعات هویتی</span>
|
||||
</template>
|
||||
|
||||
<a-form-item label="نام و نام خانوادگی" required>
|
||||
<a-input
|
||||
v-model:value="formState.name"
|
||||
placeholder="مثال: علی رضایی"
|
||||
size="large"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="شماره موبایل سازمانی" required>
|
||||
<a-input
|
||||
v-model:value="formState.mobile"
|
||||
placeholder="09xxxxxxxxx"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
maxLength="11"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="ایمیل سازمانی (نام کاربری ورود)" required>
|
||||
<a-input
|
||||
v-model:value="formState.email"
|
||||
placeholder="admin@example.com"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
:label="isEdit ? 'کلمه عبور جدید (در صورت نیاز به تغییر)' : 'کلمه عبور ورود'"
|
||||
:required="!isEdit"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="formState.password"
|
||||
placeholder="حداقل ۶ کاراکتر"
|
||||
size="large"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="وضعیت حساب کاربری">
|
||||
<a-radio-group v-model:value="formState.status" buttonStyle="solid">
|
||||
<a-radio-button value="ACTIVE">فعال</a-radio-button>
|
||||
<a-radio-button value="DISABLED">غیرفعال / مسدود</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- TAB 2: نقشها و اختیارات -->
|
||||
<a-tab-pane key="2">
|
||||
<template #tab>
|
||||
<span><SafetyCertificateOutlined /> نقشها و اختیارات</span>
|
||||
</template>
|
||||
|
||||
<div class="super-admin-banner">
|
||||
<a-checkbox v-model:checked="formState.isSuperAdmin">
|
||||
<strong style="color: #7c3aed; font-size: 15px">مدیر ارشد سامانه (Super Admin)</strong>
|
||||
</a-checkbox>
|
||||
<p class="banner-sub">
|
||||
مدیر ارشد دسترسی کامل و بیقیدوشرط به تمامی بخشها، ایجاد مدیران و تنظیمات کلی سامانه دارد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a-divider style="margin: 16px 0" />
|
||||
|
||||
<a-form-item label="نقشهای سازمانی (میتوانید چند نقش همزمان انتخاب کنید)">
|
||||
<div v-if="rolesList.length === 0" style="color: #94a3b8">
|
||||
در حال بارگذاری نقشها...
|
||||
</div>
|
||||
<a-checkbox-group
|
||||
v-else
|
||||
v-model:value="formState.roleIds"
|
||||
style="width: 100%"
|
||||
:disabled="formState.isSuperAdmin"
|
||||
>
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col :span="24" v-for="r in rolesList" :key="r.id">
|
||||
<div class="role-item-card" :class="{ 'is-selected': formState.roleIds.includes(r.id) }">
|
||||
<a-checkbox :value="r.id">
|
||||
<span class="role-name">{{ r.name }}</span>
|
||||
<a-tag v-if="r.isSystem" color="blue" size="small" style="margin-right: 6px">سیستمی</a-tag>
|
||||
</a-checkbox>
|
||||
<div class="role-desc">{{ r.description || 'بدون توضیح' }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- TAB 3: فضاهای کاری و اسکوپ دسترسی -->
|
||||
<a-tab-pane key="3">
|
||||
<template #tab>
|
||||
<span><ApartmentOutlined /> قلمرو و فضای کاری</span>
|
||||
</template>
|
||||
|
||||
<a-form-item label="فضای کاری مجاز">
|
||||
<a-select
|
||||
v-model:value="formState.workspaceId"
|
||||
placeholder="انتخاب فضای کاری"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="ws in workspacesList" :key="ws.id" :value="ws.id">
|
||||
{{ ws.name }} ({{ ws.slug }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="قلمرو دسترسی کمپینها (Scope)">
|
||||
<a-radio-group v-model:value="formState.scopeType" style="margin-bottom: 12px">
|
||||
<a-radio value="ALL">دسترسی به تمامی کمپینهای این فضای کاری</a-radio>
|
||||
<a-radio value="CUSTOM_CAMPAIGNS">محدود به کمپینهای خاص</a-radio>
|
||||
</a-radio-group>
|
||||
|
||||
<div v-if="formState.scopeType === 'CUSTOM_CAMPAIGNS'" style="margin-top: 8px">
|
||||
<a-select
|
||||
v-model:value="formState.specificCampaignIds"
|
||||
mode="multiple"
|
||||
placeholder="کمپینهای مجاز را انتخاب کنید"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="c in campaignsList" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<a-button @click="emit('update:open', false)">انصراف</a-button>
|
||||
<a-button type="primary" :loading="submitting" @click="onSubmit">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
{{ isEdit ? 'ذخیره تغییرات' : 'ثبت مدیر' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.super-admin-banner {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.banner-sub {
|
||||
margin: 6px 0 0 24px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.role-item-card {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
background: #fff;
|
||||
}
|
||||
.role-item-card.is-selected {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.role-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.role-desc {
|
||||
margin-right: 24px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
:deep(.ant-drawer-content) {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
:deep(.ant-drawer-header) {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
:deep(.ant-drawer-title) {
|
||||
text-align: right;
|
||||
}
|
||||
:deep(.ant-drawer-close) {
|
||||
margin-right: 0;
|
||||
margin-left: var(--ant-margin-xs, 8px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user