- 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
390 lines
12 KiB
Vue
390 lines
12 KiB
Vue
<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>
|