feat(auth): enforce admin permissions in UI
Add can()/router meta, HomePage module filtering, Admins permission matrix, and hide write actions when the user lacks *:write. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/** Mirror of API permission catalog for SuperAdmin web */
|
||||
export const PERMISSIONS = [
|
||||
'sms:read',
|
||||
'sms:write',
|
||||
'servers:read',
|
||||
'servers:write',
|
||||
'plugins:read',
|
||||
'plugins:write',
|
||||
'support:read',
|
||||
'support:write',
|
||||
'hamkar:read',
|
||||
'hamkar:write',
|
||||
'khanegi:read',
|
||||
'khanegi:write',
|
||||
'admins:read',
|
||||
'admins:write',
|
||||
'audit:read',
|
||||
'settings:write',
|
||||
] as const
|
||||
|
||||
export type PermissionCode = (typeof PERMISSIONS)[number]
|
||||
|
||||
export type PermissionModule = {
|
||||
key: string
|
||||
label: string
|
||||
read?: PermissionCode
|
||||
write?: PermissionCode
|
||||
}
|
||||
|
||||
export const PERMISSION_MODULES: PermissionModule[] = [
|
||||
{ key: 'sms', label: 'پیامک', read: 'sms:read', write: 'sms:write' },
|
||||
{ key: 'servers', label: 'سرور', read: 'servers:read', write: 'servers:write' },
|
||||
{ key: 'plugins', label: 'افزونه', read: 'plugins:read', write: 'plugins:write' },
|
||||
{ key: 'support', label: 'پشتیبانی', read: 'support:read', write: 'support:write' },
|
||||
{ key: 'hamkar', label: 'همکار', read: 'hamkar:read', write: 'hamkar:write' },
|
||||
{ key: 'khanegi', label: 'خانگی', read: 'khanegi:read', write: 'khanegi:write' },
|
||||
{ key: 'admins', label: 'مدیران', read: 'admins:read', write: 'admins:write' },
|
||||
{ key: 'audit', label: 'لاگ', read: 'audit:read' },
|
||||
{ key: 'settings', label: 'تنظیمات حساس', write: 'settings:write' },
|
||||
]
|
||||
|
||||
export function normalizePermissionList(codes: string[]): PermissionCode[] {
|
||||
const set = new Set<PermissionCode>()
|
||||
for (const raw of codes) {
|
||||
const code = String(raw || '').trim() as PermissionCode
|
||||
if (!(PERMISSIONS as readonly string[]).includes(code)) continue
|
||||
set.add(code)
|
||||
if (code.endsWith(':write')) {
|
||||
const read = code.replace(/:write$/, ':read') as PermissionCode
|
||||
if ((PERMISSIONS as readonly string[]).includes(read)) set.add(read)
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { LogoutOutlined, HomeOutlined } from '@ant-design/icons-vue'
|
||||
@@ -10,6 +10,7 @@ const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const adminSmsEnabled = ref(true)
|
||||
const smsToggleLoading = ref(false)
|
||||
const canSettingsWrite = computed(() => auth.can('settings:write'))
|
||||
|
||||
function goHome() {
|
||||
router.push({ name: 'home' })
|
||||
@@ -21,6 +22,7 @@ function logout() {
|
||||
}
|
||||
|
||||
async function loadSmsToggle() {
|
||||
if (!canSettingsWrite.value) return
|
||||
try {
|
||||
const { data } = await api.get('/settings/admin-sms')
|
||||
adminSmsEnabled.value = data.adminSmsEnabled !== false
|
||||
@@ -30,6 +32,7 @@ async function loadSmsToggle() {
|
||||
}
|
||||
|
||||
async function onSmsToggle(checked: boolean) {
|
||||
if (!canSettingsWrite.value) return
|
||||
smsToggleLoading.value = true
|
||||
const previous = adminSmsEnabled.value
|
||||
adminSmsEnabled.value = checked
|
||||
@@ -62,7 +65,11 @@ onMounted(loadSmsToggle)
|
||||
</button>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<div class="sms-toggle" :class="{ off: !adminSmsEnabled }">
|
||||
<div
|
||||
v-if="canSettingsWrite"
|
||||
class="sms-toggle"
|
||||
:class="{ off: !adminSmsEnabled }"
|
||||
>
|
||||
<span class="sms-toggle-label">پیامک هشدار</span>
|
||||
<a-switch
|
||||
:checked="adminSmsEnabled"
|
||||
|
||||
+201
-9
@@ -11,6 +11,11 @@ import {
|
||||
} from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
PERMISSION_MODULES,
|
||||
normalizePermissionList,
|
||||
type PermissionCode,
|
||||
} from '@/auth/permissions'
|
||||
|
||||
type Admin = {
|
||||
id: string
|
||||
@@ -20,6 +25,8 @@ type Admin = {
|
||||
name?: string
|
||||
isActive: boolean
|
||||
role: string
|
||||
permissions?: string[]
|
||||
isFullAccess?: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -31,20 +38,90 @@ const modalOpen = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const rows = ref<Admin[]>([])
|
||||
|
||||
const canWrite = computed(() => auth.can('admins:write'))
|
||||
const canGrantFullAccess = computed(() => auth.isFullAccess)
|
||||
|
||||
const form = reactive({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
mobile: '',
|
||||
password: '',
|
||||
isActive: true,
|
||||
fullAccess: false,
|
||||
permissions: [] as PermissionCode[],
|
||||
})
|
||||
|
||||
const modalTitle = computed(() => (editingId.value ? 'ویرایش مدیر' : 'افزودن مدیر'))
|
||||
|
||||
const permissionSet = computed(() => new Set(form.permissions))
|
||||
|
||||
function fullName(item: Admin) {
|
||||
return item.name || `${item.firstName} ${item.lastName}`.trim()
|
||||
}
|
||||
|
||||
function canAssignCode(code: PermissionCode) {
|
||||
if (canGrantFullAccess.value) return true
|
||||
return auth.can(code)
|
||||
}
|
||||
|
||||
function isChecked(code?: PermissionCode) {
|
||||
if (!code) return false
|
||||
return permissionSet.value.has(code)
|
||||
}
|
||||
|
||||
function setPermission(code: PermissionCode | undefined, checked: boolean) {
|
||||
if (!code) return
|
||||
if (!canAssignCode(code)) return
|
||||
const next = new Set(form.permissions)
|
||||
if (checked) {
|
||||
next.add(code)
|
||||
if (code.endsWith(':write')) {
|
||||
const read = code.replace(/:write$/, ':read') as PermissionCode
|
||||
if (canAssignCode(read)) next.add(read)
|
||||
}
|
||||
} else {
|
||||
next.delete(code)
|
||||
if (code.endsWith(':read')) {
|
||||
const write = code.replace(/:read$/, ':write') as PermissionCode
|
||||
next.delete(write)
|
||||
}
|
||||
}
|
||||
form.permissions = normalizePermissionList([...next])
|
||||
}
|
||||
|
||||
function onReadChange(mod: (typeof PERMISSION_MODULES)[number], checked: boolean) {
|
||||
setPermission(mod.read, checked)
|
||||
}
|
||||
|
||||
function onWriteChange(mod: (typeof PERMISSION_MODULES)[number], checked: boolean) {
|
||||
setPermission(mod.write, checked)
|
||||
}
|
||||
|
||||
function permissionSummary(item: Admin) {
|
||||
if (item.role === 'super_admin' || item.isFullAccess) return 'دسترسی کامل'
|
||||
const list = normalizePermissionList(item.permissions || [])
|
||||
if (!list.length) return 'بدون مجوز'
|
||||
const labels: string[] = []
|
||||
for (const mod of PERMISSION_MODULES) {
|
||||
const hasWrite = mod.write && list.includes(mod.write)
|
||||
const hasRead = mod.read && list.includes(mod.read)
|
||||
if (hasWrite) labels.push(`${mod.label}: نوشتن`)
|
||||
else if (hasRead) labels.push(`${mod.label}: خواندن`)
|
||||
else if (mod.write && list.includes(mod.write)) labels.push(mod.label)
|
||||
}
|
||||
return labels.join(' · ') || `${list.length} مجوز`
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.mobile = ''
|
||||
form.password = ''
|
||||
form.isActive = true
|
||||
form.fullAccess = false
|
||||
form.permissions = []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -58,26 +135,29 @@ async function load() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!canWrite.value) return
|
||||
editingId.value = null
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.mobile = ''
|
||||
form.password = ''
|
||||
form.isActive = true
|
||||
resetForm()
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: Admin) {
|
||||
if (!canWrite.value) return
|
||||
editingId.value = item.id
|
||||
form.firstName = item.firstName
|
||||
form.lastName = item.lastName
|
||||
form.mobile = item.mobile
|
||||
form.password = ''
|
||||
form.isActive = item.isActive
|
||||
form.fullAccess = item.role === 'super_admin' || Boolean(item.isFullAccess)
|
||||
form.permissions = form.fullAccess
|
||||
? []
|
||||
: normalizePermissionList(item.permissions || [])
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canWrite.value) return
|
||||
if (!form.firstName.trim() || !form.lastName.trim() || !form.mobile.trim()) {
|
||||
message.warning('نام، نام خانوادگی و موبایل لازم است')
|
||||
return
|
||||
@@ -91,6 +171,20 @@ async function save() {
|
||||
return
|
||||
}
|
||||
|
||||
const editingWasFull =
|
||||
Boolean(editingId.value) &&
|
||||
rows.value.some(
|
||||
(r) =>
|
||||
r.id === editingId.value &&
|
||||
(r.role === 'super_admin' || Boolean(r.isFullAccess)),
|
||||
)
|
||||
|
||||
const role =
|
||||
form.fullAccess && (canGrantFullAccess.value || editingWasFull)
|
||||
? 'super_admin'
|
||||
: 'admin'
|
||||
const permissions = role === 'super_admin' ? [] : normalizePermissionList(form.permissions)
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
@@ -101,6 +195,11 @@ async function save() {
|
||||
isActive: form.isActive,
|
||||
}
|
||||
if (form.password) payload.password = form.password
|
||||
// Limited editors must not re-assign role/permissions on a full-access user
|
||||
if (!(editingWasFull && !canGrantFullAccess.value)) {
|
||||
payload.role = role
|
||||
payload.permissions = permissions
|
||||
}
|
||||
await api.patch(`/admins/${editingId.value}`, payload)
|
||||
message.success('مدیر بهروزرسانی شد')
|
||||
} else {
|
||||
@@ -109,6 +208,11 @@ async function save() {
|
||||
lastName: form.lastName.trim(),
|
||||
mobile: form.mobile.trim(),
|
||||
password: form.password,
|
||||
role: canGrantFullAccess.value && form.fullAccess ? 'super_admin' : 'admin',
|
||||
permissions:
|
||||
canGrantFullAccess.value && form.fullAccess
|
||||
? []
|
||||
: normalizePermissionList(form.permissions),
|
||||
})
|
||||
message.success('مدیر اضافه شد')
|
||||
}
|
||||
@@ -123,6 +227,7 @@ async function save() {
|
||||
}
|
||||
|
||||
function confirmDelete(item: Admin) {
|
||||
if (!canWrite.value) return
|
||||
if (item.id === auth.user?.id) {
|
||||
message.warning('نمیتوانید خودتان را حذف کنید')
|
||||
return
|
||||
@@ -147,6 +252,7 @@ function confirmDelete(item: Admin) {
|
||||
}
|
||||
|
||||
async function testSms(item: Admin) {
|
||||
if (!canWrite.value) return
|
||||
try {
|
||||
await api.post(`/admins/${item.id}/test-sms`, {})
|
||||
message.success(`پیامک تست به ${item.mobile} ارسال شد`)
|
||||
@@ -168,10 +274,10 @@ onMounted(load)
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">مدیران</h1>
|
||||
<p class="page-sub">
|
||||
مدیریت کاربران سوپرادمین — ورود با موبایل و رمز. هشدارها به همین شمارهها پیامک میشود.
|
||||
مدیریت کاربران سوپرادمین — ورود با موبایل و رمز. سطح دسترسی ماژولمحور برای مدیران جدید.
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="openCreate">
|
||||
<a-button v-if="canWrite" type="primary" @click="openCreate">
|
||||
<PlusOutlined /> افزودن مدیر
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -186,10 +292,13 @@ onMounted(load)
|
||||
<a-tag :color="item.isActive ? 'success' : 'default'">
|
||||
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
<a-tag>{{ item.role }}</a-tag>
|
||||
<a-tag :color="item.role === 'super_admin' ? 'gold' : 'blue'">
|
||||
{{ item.role === 'super_admin' ? 'دسترسی کامل' : 'مدیر' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<p class="perm-summary">{{ permissionSummary(item) }}</p>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<div v-if="canWrite" class="admin-actions">
|
||||
<a-button @click="testSms(item)">
|
||||
<MessageOutlined /> تست SMS
|
||||
</a-button>
|
||||
@@ -211,6 +320,7 @@ onMounted(load)
|
||||
:confirm-loading="saving"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
width="720px"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 12px">
|
||||
@@ -229,6 +339,40 @@ onMounted(load)
|
||||
<a-form-item v-if="editingId" label="فعال">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="canGrantFullAccess" label="دسترسی کامل (super)">
|
||||
<a-switch v-model:checked="form.fullAccess" />
|
||||
<div class="field-hint">با روشن بودن، همه ماژولها بدون محدودیت در دسترس است.</div>
|
||||
</a-form-item>
|
||||
|
||||
<div v-if="!form.fullAccess" class="perm-matrix">
|
||||
<div class="perm-matrix-head">
|
||||
<span>ماژول</span>
|
||||
<span>خواندن</span>
|
||||
<span>نوشتن</span>
|
||||
</div>
|
||||
<div v-for="mod in PERMISSION_MODULES" :key="mod.key" class="perm-matrix-row">
|
||||
<span class="mod-label">{{ mod.label }}</span>
|
||||
<span>
|
||||
<a-checkbox
|
||||
v-if="mod.read"
|
||||
:checked="isChecked(mod.read)"
|
||||
:disabled="!canAssignCode(mod.read)"
|
||||
@change="(e: any) => onReadChange(mod, e.target.checked)"
|
||||
/>
|
||||
<span v-else class="dash">—</span>
|
||||
</span>
|
||||
<span>
|
||||
<a-checkbox
|
||||
v-if="mod.write"
|
||||
:checked="isChecked(mod.write)"
|
||||
:disabled="!canAssignCode(mod.write)"
|
||||
@change="(e: any) => onWriteChange(mod, e.target.checked)"
|
||||
/>
|
||||
<span v-else class="dash">—</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
@@ -263,9 +407,57 @@ onMounted(load)
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.perm-summary {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.perm-matrix {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e8eef5;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.perm-matrix-head,
|
||||
.perm-matrix-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 72px 72px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.perm-matrix-head {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.perm-matrix-row + .perm-matrix-row {
|
||||
border-top: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.mod-label {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.dash {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,9 +16,12 @@ import {
|
||||
type HamkarUiConfig,
|
||||
} from '@/config/defaultHamkarUiConfig'
|
||||
import { resolveHamkarEffectiveTheme } from '@/config/hamkarEffectiveTheme'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canWrite = computed(() => auth.can('hamkar:write'))
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const resetting = ref(false)
|
||||
@@ -420,6 +423,7 @@ onMounted(load)
|
||||
|
||||
<div class="sticky-actions">
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
size="large"
|
||||
block
|
||||
:loading="resetting"
|
||||
@@ -428,7 +432,14 @@ onMounted(load)
|
||||
>
|
||||
بازگشت به ظاهر اولیه
|
||||
</a-button>
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="save">
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
ذخیره ظاهر همکار
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
+91
-46
@@ -12,6 +12,7 @@ import {
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
@@ -56,6 +57,7 @@ type ExpiringPlugin = {
|
||||
type ExpiringSupport = ExpiringPlugin
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const smsTenants = ref<Tenant[]>([])
|
||||
@@ -67,52 +69,95 @@ const hamkarCount = computed(() => tenants.value.filter((t) => t.product === 'ha
|
||||
const khanegiCount = computed(() => tenants.value.filter((t) => t.product === 'khanegi').length)
|
||||
const lowestSmsTenants = computed(() => smsTenants.value.slice(0, 5))
|
||||
|
||||
const modules = computed(() => [
|
||||
{
|
||||
key: 'admins',
|
||||
className: 'admins',
|
||||
title: 'مدیران',
|
||||
desc: 'افزودن و ویرایش مدیران؛ ورود با موبایل و دریافت هشدار SMS',
|
||||
meta: 'دسترسی',
|
||||
icon: UserOutlined,
|
||||
to: { name: 'admins' as const },
|
||||
},
|
||||
{
|
||||
key: 'hamkar',
|
||||
className: 'hamkar',
|
||||
title: 'همکار',
|
||||
desc: 'مدیریت نسخههای فروشی پروژه همکار',
|
||||
meta: `${hamkarCount.value} نسخه`,
|
||||
icon: TeamOutlined,
|
||||
to: { name: 'product-versions' as const, params: { product: 'hamkar' } },
|
||||
},
|
||||
{
|
||||
key: 'khanegi',
|
||||
className: 'khanegi',
|
||||
title: 'خانگی',
|
||||
desc: 'مدیریت نسخههای فروشی و ظاهر اپ خانگی',
|
||||
meta: `${khanegiCount.value} نسخه`,
|
||||
icon: HomeOutlined,
|
||||
to: { name: 'product-versions' as const, params: { product: 'khanegi' } },
|
||||
},
|
||||
])
|
||||
const canSms = computed(() => auth.can('sms:read'))
|
||||
const canServers = computed(() => auth.can('servers:read'))
|
||||
const canPlugins = computed(() => auth.can('plugins:read'))
|
||||
const canSupport = computed(() => auth.can('support:read'))
|
||||
const canAdmins = computed(() => auth.can('admins:read'))
|
||||
const canHamkar = computed(() => auth.can('hamkar:read'))
|
||||
const canKhanegi = computed(() => auth.can('khanegi:read'))
|
||||
|
||||
const modules = computed(() =>
|
||||
[
|
||||
{
|
||||
key: 'admins',
|
||||
className: 'admins',
|
||||
title: 'مدیران',
|
||||
desc: 'افزودن و ویرایش مدیران؛ تعیین سطح دسترسی',
|
||||
meta: 'دسترسی',
|
||||
icon: UserOutlined,
|
||||
to: { name: 'admins' as const },
|
||||
visible: canAdmins.value,
|
||||
},
|
||||
{
|
||||
key: 'hamkar',
|
||||
className: 'hamkar',
|
||||
title: 'همکار',
|
||||
desc: 'مدیریت نسخههای فروشی پروژه همکار',
|
||||
meta: `${hamkarCount.value} نسخه`,
|
||||
icon: TeamOutlined,
|
||||
to: { name: 'product-versions' as const, params: { product: 'hamkar' } },
|
||||
visible: canHamkar.value,
|
||||
},
|
||||
{
|
||||
key: 'khanegi',
|
||||
className: 'khanegi',
|
||||
title: 'خانگی',
|
||||
desc: 'مدیریت نسخههای فروشی و ظاهر اپ خانگی',
|
||||
meta: `${khanegiCount.value} نسخه`,
|
||||
icon: HomeOutlined,
|
||||
to: { name: 'product-versions' as const, params: { product: 'khanegi' } },
|
||||
visible: canKhanegi.value,
|
||||
},
|
||||
].filter((item) => item.visible),
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [tenantsRes, lowestRes, expiringRes, pluginExpiringRes, supportExpiringRes] =
|
||||
await Promise.all([
|
||||
api.get('/tenants'),
|
||||
api.get('/sms/lowest', { params: { limit: 5 } }),
|
||||
api.get('/servers/expiring', { params: { limit: 5 } }),
|
||||
api.get('/plugins/expiring', { params: { limit: 5 } }),
|
||||
api.get('/support/expiring', { params: { limit: 5 } }),
|
||||
])
|
||||
tenants.value = tenantsRes.data
|
||||
smsTenants.value = lowestRes.data
|
||||
expiringServers.value = expiringRes.data
|
||||
expiringPlugins.value = pluginExpiringRes.data
|
||||
expiringSupports.value = supportExpiringRes.data
|
||||
const tasks: Array<Promise<void>> = []
|
||||
|
||||
if (canHamkar.value || canKhanegi.value) {
|
||||
tasks.push(
|
||||
api.get('/tenants').then((res) => {
|
||||
tenants.value = res.data
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (canSms.value) {
|
||||
tasks.push(
|
||||
api.get('/sms/lowest', { params: { limit: 5 } }).then((res) => {
|
||||
smsTenants.value = res.data
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (canServers.value) {
|
||||
tasks.push(
|
||||
api.get('/servers/expiring', { params: { limit: 5 } }).then((res) => {
|
||||
expiringServers.value = res.data
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (canPlugins.value) {
|
||||
tasks.push(
|
||||
api.get('/plugins/expiring', { params: { limit: 5 } }).then((res) => {
|
||||
expiringPlugins.value = res.data
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (canSupport.value) {
|
||||
tasks.push(
|
||||
api.get('/support/expiring', { params: { limit: 5 } }).then((res) => {
|
||||
expiringSupports.value = res.data
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const failed = results.some((r) => r.status === 'rejected')
|
||||
if (failed) {
|
||||
message.error('خطا در دریافت بخشی از آمار داشبورد')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت آمار')
|
||||
} finally {
|
||||
@@ -193,7 +238,7 @@ onMounted(load)
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="dash-modules">
|
||||
<div class="module-card sms sms-dashboard-card" @click="openSmsPanel()">
|
||||
<div v-if="canSms" class="module-card sms sms-dashboard-card" @click="openSmsPanel()">
|
||||
<div>
|
||||
<div class="icon-wrap">
|
||||
<MessageOutlined />
|
||||
@@ -227,7 +272,7 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module-card server sms-dashboard-card" @click="openServerPanel()">
|
||||
<div v-if="canServers" class="module-card server sms-dashboard-card" @click="openServerPanel()">
|
||||
<div>
|
||||
<div class="icon-wrap">
|
||||
<CloudServerOutlined />
|
||||
@@ -266,7 +311,7 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module-card plugin sms-dashboard-card" @click="openPluginPanel()">
|
||||
<div v-if="canPlugins" class="module-card plugin sms-dashboard-card" @click="openPluginPanel()">
|
||||
<div>
|
||||
<div class="icon-wrap">
|
||||
<AppstoreAddOutlined />
|
||||
@@ -300,7 +345,7 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module-card support sms-dashboard-card" @click="openSupportPanel()">
|
||||
<div v-if="canSupport" class="module-card support sms-dashboard-card" @click="openSupportPanel()">
|
||||
<div>
|
||||
<div class="icon-wrap">
|
||||
<CustomerServiceOutlined />
|
||||
|
||||
@@ -4,9 +4,12 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canWrite = computed(() => auth.can('khanegi:write'))
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const switching = ref(false)
|
||||
@@ -165,10 +168,18 @@ onMounted(load)
|
||||
{ value: 'modular', label: 'Modular' },
|
||||
{ value: 'newTheme', label: 'New theme' },
|
||||
]"
|
||||
:disabled="switching || saving || loading"
|
||||
:disabled="!canWrite || switching || saving || loading"
|
||||
@change="(v: string) => v === 'newTheme' && switchToNewTheme()"
|
||||
/>
|
||||
<a-button type="primary" size="large" :loading="saving" @click="save">ذخیره</a-button>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
ذخیره
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
@@ -34,6 +35,8 @@ type PluginItem = {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canWrite = computed(() => auth.can('plugins:write'))
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const renewing = ref(false)
|
||||
@@ -272,7 +275,7 @@ onMounted(loadDetail)
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showCreate = true">
|
||||
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> افزونه جدید
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -312,7 +315,7 @@ onMounted(loadDetail)
|
||||
<section class="form-section">
|
||||
<div class="section-head">
|
||||
<h3>مشخصات افزونه</h3>
|
||||
<a-button danger type="text" @click="confirmDelete">
|
||||
<a-button v-if="canWrite" danger type="text" @click="confirmDelete">
|
||||
<DeleteOutlined /> حذف
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -369,13 +372,13 @@ onMounted(loadDetail)
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<div class="sticky-actions settings-save">
|
||||
<div v-if="canWrite" class="sticky-actions settings-save">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="savePlugin">
|
||||
ذخیره تنظیمات
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section class="form-section renew-section">
|
||||
<section v-if="canWrite" class="form-section renew-section">
|
||||
<h3>شارژ / تمدید جدید</h3>
|
||||
<p class="field-hint">
|
||||
با ثبت شارژ، تاریخ انقضا تمدید میشود و هشدارهای قبلی قطع میگردد تا دوباره نزدیک پایان برسد.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Tenant = {
|
||||
id: string
|
||||
@@ -23,6 +24,7 @@ type Tenant = {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const rows = ref<Tenant[]>([])
|
||||
const q = ref('')
|
||||
@@ -32,6 +34,10 @@ const product = computed(() => {
|
||||
return p === 'khanegi' ? 'khanegi' : 'hamkar'
|
||||
})
|
||||
|
||||
const canWrite = computed(() =>
|
||||
product.value === 'hamkar' ? auth.can('hamkar:write') : auth.can('khanegi:write'),
|
||||
)
|
||||
|
||||
const title = computed(() => (product.value === 'hamkar' ? 'همکار' : 'خانگی'))
|
||||
const subtitle = computed(() =>
|
||||
product.value === 'hamkar'
|
||||
@@ -110,7 +116,7 @@ onMounted(load)
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-button type="primary" @click="addVersion">
|
||||
<a-button v-if="canWrite" type="primary" @click="addVersion">
|
||||
<PlusOutlined /> نسخه جدید
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -173,12 +179,20 @@ onMounted(load)
|
||||
>
|
||||
ظاهر اپ
|
||||
</a-button>
|
||||
<a-button danger ghost style="flex: 0 0 auto" @click="remove(item)">حذف</a-button>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
danger
|
||||
ghost
|
||||
style="flex: 0 0 auto"
|
||||
@click="remove(item)"
|
||||
>
|
||||
حذف
|
||||
</a-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty v-else description="نسخهای ثبت نشده" style="margin: 32px 0">
|
||||
<a-button type="primary" @click="addVersion">افزودن اولین نسخه</a-button>
|
||||
<a-button v-if="canWrite" type="primary" @click="addVersion">افزودن اولین نسخه</a-button>
|
||||
</a-empty>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
@@ -40,6 +41,8 @@ type ServerRow = {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canWrite = computed(() => auth.can('servers:write'))
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const renewing = ref(false)
|
||||
@@ -292,7 +295,7 @@ onMounted(loadDetail)
|
||||
<section class="form-section server-picker">
|
||||
<div class="picker-head">
|
||||
<h3>سرورهای این نسخه</h3>
|
||||
<a-button type="primary" :loading="creating" @click="addServer">
|
||||
<a-button v-if="canWrite" type="primary" :loading="creating" @click="addServer">
|
||||
<PlusOutlined /> افزودن سرور
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -395,7 +398,7 @@ onMounted(loadDetail)
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<div class="sticky-actions settings-save actions-row">
|
||||
<div v-if="canWrite" class="sticky-actions settings-save actions-row">
|
||||
<a-button type="primary" size="large" :loading="saving" @click="saveAccount">
|
||||
ذخیره تنظیمات
|
||||
</a-button>
|
||||
@@ -404,7 +407,7 @@ onMounted(loadDetail)
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section v-if="!form.isDedicated" class="form-section renew-section">
|
||||
<section v-if="canWrite && !form.isDedicated" class="form-section renew-section">
|
||||
<h3>شارژ / تمدید جدید</h3>
|
||||
<p class="field-hint">
|
||||
با ثبت شارژ، تاریخ انقضا تمدید میشود و هشدارهای قبلی قطع میگردد تا دوباره نزدیک پایان برسد.
|
||||
|
||||
@@ -10,6 +10,7 @@ import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
|
||||
import { formatMoney, formatNumber } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
@@ -28,6 +29,8 @@ type SmsSummary = {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canWrite = computed(() => auth.can('sms:write'))
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const charging = ref(false)
|
||||
@@ -452,7 +455,15 @@ onMounted(async () => {
|
||||
<h3>تعرفه پیامک</h3>
|
||||
<div class="inline-actions">
|
||||
<MoneyInput v-model:value="form.pricePerPartRials" placeholder="قیمت هر پارت" />
|
||||
<a-button type="primary" size="large" :loading="saving" @click="savePrice">ذخیره قیمت</a-button>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="saving"
|
||||
@click="savePrice"
|
||||
>
|
||||
ذخیره قیمت
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -465,7 +476,13 @@ onMounted(async () => {
|
||||
placeholder="مبلغ (تومان)"
|
||||
/>
|
||||
<a-input v-model:value="chargeForm.note" size="large" placeholder="توضیح (اختیاری)" />
|
||||
<a-button type="primary" size="large" :loading="charging" @click="openChargeConfirm">
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="charging"
|
||||
@click="openChargeConfirm"
|
||||
>
|
||||
شارژ کیف پول
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -480,6 +497,7 @@ onMounted(async () => {
|
||||
<a-switch
|
||||
:checked="form.isBlocked"
|
||||
:loading="blockLoading"
|
||||
:disabled="!canWrite"
|
||||
checked-children="مسدود"
|
||||
un-checked-children="فعال"
|
||||
@change="onBlockChange"
|
||||
@@ -498,7 +516,14 @@ onMounted(async () => {
|
||||
placeholder="راهنمای واریز"
|
||||
/>
|
||||
</div>
|
||||
<a-button type="primary" :loading="chargeInfoSaving" @click="saveChargeInfo">ذخیره اطلاعات واریز</a-button>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
:loading="chargeInfoSaving"
|
||||
@click="saveChargeInfo"
|
||||
>
|
||||
ذخیره اطلاعات واریز
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="subcard thresholds">
|
||||
@@ -512,7 +537,15 @@ onMounted(async () => {
|
||||
<span class="tone-label"><i class="dot warning" /> آستانه زرد</span>
|
||||
<MoneyInput v-model:value="form.alertWarningRials" />
|
||||
</label>
|
||||
<a-button type="primary" size="large" :loading="saving" @click="saveThresholds">ذخیره آستانهها</a-button>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="saving"
|
||||
@click="saveThresholds"
|
||||
>
|
||||
ذخیره آستانهها
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -535,7 +568,13 @@ onMounted(async () => {
|
||||
placeholder="مثلاً -50,000 — خالی = غیرفعال"
|
||||
/>
|
||||
</label>
|
||||
<a-button type="primary" size="large" :loading="saving" @click="saveThresholds">
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="saving"
|
||||
@click="saveThresholds"
|
||||
>
|
||||
ذخیره آستانه
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
@@ -35,6 +36,8 @@ type SupportItem = {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canWrite = computed(() => auth.can('support:write'))
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const renewing = ref(false)
|
||||
@@ -306,7 +309,7 @@ onMounted(loadDetail)
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showCreate = true">
|
||||
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> پشتیبانی جدید
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -352,7 +355,7 @@ onMounted(loadDetail)
|
||||
<section class="form-section">
|
||||
<div class="section-head">
|
||||
<h3>مشخصات پشتیبانی</h3>
|
||||
<a-button danger type="text" @click="confirmDelete">
|
||||
<a-button v-if="canWrite" danger type="text" @click="confirmDelete">
|
||||
<DeleteOutlined /> حذف
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -415,13 +418,13 @@ onMounted(loadDetail)
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<div class="sticky-actions settings-save">
|
||||
<div v-if="canWrite" class="sticky-actions settings-save">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="saveSupport">
|
||||
ذخیره تنظیمات
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section class="form-section renew-section">
|
||||
<section v-if="canWrite" class="form-section renew-section">
|
||||
<h3>شارژ / تمدید جدید</h3>
|
||||
<p class="field-hint">
|
||||
با ثبت شارژ، تاریخ انقضا تمدید میشود و هشدارهای قبلی قطع میگردد تا دوباره نزدیک پایان برسد.
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
@@ -32,6 +34,9 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const productLabel = computed(() => (form.product === 'hamkar' ? 'همکار' : 'خانگی'))
|
||||
const canWrite = computed(() =>
|
||||
form.product === 'hamkar' ? auth.can('hamkar:write') : auth.can('khanegi:write'),
|
||||
)
|
||||
|
||||
function backTarget() {
|
||||
return {
|
||||
@@ -104,6 +109,10 @@ function clearLogo() {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canWrite.value) {
|
||||
message.warning('دسترسی نوشتن ندارید')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
@@ -256,7 +265,16 @@ onMounted(load)
|
||||
</section>
|
||||
|
||||
<div class="sticky-actions">
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="saving">ذخیره</a-button>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="saving"
|
||||
>
|
||||
ذخیره
|
||||
</a-button>
|
||||
<a-button size="large" @click="router.push(backTarget())">انصراف</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
|
||||
@@ -18,9 +18,12 @@ import {
|
||||
type KhanegiPresetId,
|
||||
} from '@/config/khanegiThemePresets'
|
||||
import { colorFieldLabels } from '@/theme/tokens'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canWrite = computed(() => auth.can('khanegi:write'))
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const resetting = ref(false)
|
||||
@@ -325,10 +328,18 @@ onMounted(load)
|
||||
{ value: 'modular', label: 'Modular' },
|
||||
{ value: 'newTheme', label: 'New theme' },
|
||||
]"
|
||||
:disabled="switching || saving || loading"
|
||||
:disabled="!canWrite || switching || saving || loading"
|
||||
@change="(v: string) => v === 'modular' && switchToModular()"
|
||||
/>
|
||||
<a-button type="primary" size="large" :loading="saving" @click="save">ذخیره ظاهر</a-button>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
ذخیره ظاهر
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
@@ -557,6 +568,7 @@ onMounted(load)
|
||||
|
||||
<div class="sticky-actions">
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
size="large"
|
||||
block
|
||||
:loading="resetting"
|
||||
@@ -565,7 +577,16 @@ onMounted(load)
|
||||
>
|
||||
بازگشت به ظاهر اولیه
|
||||
</a-button>
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="save">ذخیره ظاهر</a-button>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
ذخیره ظاهر
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="page-shell" style="display: grid; place-items: center; min-height: 240px">
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { PermissionCode } from '@/auth/permissions'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
public?: boolean
|
||||
permissions?: PermissionCode[]
|
||||
permissionMode?: 'all' | 'any'
|
||||
}
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -23,81 +33,97 @@ const router = createRouter({
|
||||
path: 'sms',
|
||||
name: 'sms-panel',
|
||||
component: () => import('@/pages/SmsPanelPage.vue'),
|
||||
meta: { permissions: ['sms:read'] },
|
||||
},
|
||||
{
|
||||
path: 'sms/:tenantId',
|
||||
name: 'sms-tenant',
|
||||
component: () => import('@/pages/SmsTenantPage.vue'),
|
||||
meta: { permissions: ['sms:read'] },
|
||||
},
|
||||
{
|
||||
path: 'servers',
|
||||
name: 'server-panel',
|
||||
component: () => import('@/pages/ServerPanelPage.vue'),
|
||||
meta: { permissions: ['servers:read'] },
|
||||
},
|
||||
{
|
||||
path: 'servers/:tenantId',
|
||||
name: 'server-tenant',
|
||||
component: () => import('@/pages/ServerTenantPage.vue'),
|
||||
meta: { permissions: ['servers:read'] },
|
||||
},
|
||||
{
|
||||
path: 'plugins',
|
||||
name: 'plugin-panel',
|
||||
component: () => import('@/pages/PluginPanelPage.vue'),
|
||||
meta: { permissions: ['plugins:read'] },
|
||||
},
|
||||
{
|
||||
path: 'plugins/:tenantId',
|
||||
name: 'plugin-tenant',
|
||||
component: () => import('@/pages/PluginTenantPage.vue'),
|
||||
meta: { permissions: ['plugins:read'] },
|
||||
},
|
||||
{
|
||||
path: 'support',
|
||||
name: 'support-panel',
|
||||
component: () => import('@/pages/SupportPanelPage.vue'),
|
||||
meta: { permissions: ['support:read'] },
|
||||
},
|
||||
{
|
||||
path: 'support/:tenantId',
|
||||
name: 'support-tenant',
|
||||
component: () => import('@/pages/SupportTenantPage.vue'),
|
||||
meta: { permissions: ['support:read'] },
|
||||
},
|
||||
{
|
||||
path: 'admins',
|
||||
name: 'admins',
|
||||
component: () => import('@/pages/AdminsPage.vue'),
|
||||
meta: { permissions: ['admins:read'] },
|
||||
},
|
||||
{
|
||||
path: 'products/:product',
|
||||
name: 'product-versions',
|
||||
component: () => import('@/pages/ProductVersionsPage.vue'),
|
||||
meta: { permissions: ['hamkar:read', 'khanegi:read'], permissionMode: 'any' },
|
||||
},
|
||||
{
|
||||
path: 'versions/new',
|
||||
name: 'version-create',
|
||||
component: () => import('@/pages/TenantFormPage.vue'),
|
||||
meta: { permissions: ['hamkar:write', 'khanegi:write'], permissionMode: 'any' },
|
||||
},
|
||||
{
|
||||
path: 'versions/:id',
|
||||
name: 'version-edit',
|
||||
component: () => import('@/pages/TenantFormPage.vue'),
|
||||
meta: { permissions: ['hamkar:read', 'khanegi:read'], permissionMode: 'any' },
|
||||
},
|
||||
{
|
||||
path: 'versions/:id/ui',
|
||||
name: 'version-ui',
|
||||
component: () => import('@/pages/UiConfigPage.vue'),
|
||||
meta: { permissions: ['khanegi:read'] },
|
||||
},
|
||||
{
|
||||
path: 'versions/:id/ui-modular',
|
||||
name: 'version-ui-modular',
|
||||
component: () => import('@/pages/KhanegiModularUiConfigPage.vue'),
|
||||
meta: { permissions: ['khanegi:read'] },
|
||||
},
|
||||
{
|
||||
path: 'versions/:id/ui-hamkar',
|
||||
name: 'version-ui-hamkar',
|
||||
component: () => import('@/pages/HamkarUiConfigPage.vue'),
|
||||
meta: { permissions: ['hamkar:read'] },
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'audit',
|
||||
component: () => import('@/pages/AuditPage.vue'),
|
||||
meta: { permissions: ['audit:read'] },
|
||||
},
|
||||
// legacy redirects
|
||||
{ path: 'tenants', redirect: { name: 'home' } },
|
||||
@@ -123,6 +149,32 @@ router.beforeEach(async (to) => {
|
||||
return { name: 'login' }
|
||||
}
|
||||
}
|
||||
|
||||
const required = to.meta.permissions
|
||||
if (required?.length) {
|
||||
const mode = to.meta.permissionMode || 'all'
|
||||
const ok =
|
||||
mode === 'any' ? auth.canAny(required) : auth.canAll(required)
|
||||
|
||||
// product-versions: also enforce exact product from params
|
||||
if (to.name === 'product-versions') {
|
||||
const product = String(to.params.product || '')
|
||||
if (product === 'hamkar' && !auth.can('hamkar:read')) {
|
||||
message.warning('به این بخش دسترسی ندارید')
|
||||
return { name: 'home' }
|
||||
}
|
||||
if (product === 'khanegi' && !auth.can('khanegi:read')) {
|
||||
message.warning('به این بخش دسترسی ندارید')
|
||||
return { name: 'home' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
message.warning('به این بخش دسترسی ندارید')
|
||||
return { name: 'home' }
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
+34
-1
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import api from '@/api/http'
|
||||
import type { PermissionCode } from '@/auth/permissions'
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
@@ -9,6 +10,8 @@ type User = {
|
||||
lastName?: string
|
||||
name: string
|
||||
role: string
|
||||
permissions?: string[]
|
||||
isFullAccess?: boolean
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -16,6 +19,24 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value))
|
||||
const isFullAccess = computed(
|
||||
() => Boolean(user.value?.isFullAccess) || user.value?.role === 'super_admin',
|
||||
)
|
||||
const permissions = computed(() => user.value?.permissions || [])
|
||||
|
||||
function can(code: PermissionCode | string) {
|
||||
if (!user.value) return false
|
||||
if (isFullAccess.value) return true
|
||||
return permissions.value.includes(code)
|
||||
}
|
||||
|
||||
function canAny(codes: Array<PermissionCode | string>) {
|
||||
return codes.some((code) => can(code))
|
||||
}
|
||||
|
||||
function canAll(codes: Array<PermissionCode | string>) {
|
||||
return codes.every((code) => can(code))
|
||||
}
|
||||
|
||||
async function login(mobile: string, password: string) {
|
||||
const { data } = await api.post('/auth/login', { mobile, password })
|
||||
@@ -37,5 +58,17 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('sa_token')
|
||||
}
|
||||
|
||||
return { token, user, isAuthenticated, login, fetchMe, logout }
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
isFullAccess,
|
||||
permissions,
|
||||
can,
|
||||
canAny,
|
||||
canAll,
|
||||
login,
|
||||
fetchMe,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user