3 Commits

Author SHA1 Message Date
Saghar Mohammadi 274284dc05 feat(certificates): admin certificate management UI
Admin can view, issue, and delete certificates. Student certificate
query now normalises the response shape (works with both paginated
and non-paginated API responses).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:16:20 +04:30
Saghar Mohammadi 0caea18eb8 feat(admin/users): support multiple roles in user table and form
UsersTable now shows all role labels joined (not just the first role),
and its CSS badge class is based on the primary role. UserFormPage
uses a multi-select for roles instead of a single-select, and the
form validation requires at least one role. TermItem layout fix for
inactive badge alignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:16:04 +04:30
Saghar Mohammadi 9b61fff128 fix: branding, 401 logout, and blocked-user message on register page
- Copyright.vue / AuthCopyright.vue: change link from mugweb.ir to madresebanoo.ir
- SelectField.vue: add min-width:0 to prevent flex overflow in narrow layouts
- useRedirectAfterLogin: check roles array (multi-role support) instead of single role
- StudentRegisterPage: show block reason when user's status is blocked
- http.js: only clear session on 401 if a token already existed (login
  form 401 should not trigger logout); clear via authStore so WordPress
  cookie is also cleaned up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:15:46 +04:30
17 changed files with 741 additions and 49 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="copyright"> <div class="copyright">
<div class="copyright__text"> <div class="copyright__text">
<a href="https://mugweb.ir/" target="_blank" rel="noopener"> <a href="https://madresebanoo.ir/" target="_blank" rel="noopener">
<p class="copyright__en">All Rights Reserved. 2026</p> <p class="copyright__en">All Rights Reserved. 2026</p>
<p class="copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست.</p> <p class="copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست.</p>
</a> </a>
+1
View File
@@ -283,6 +283,7 @@ onBeforeUnmount(() => unmountFloating())
&__value { &__value {
flex: 1; flex: 1;
min-width: 0;
text-align: start; text-align: start;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -0,0 +1,137 @@
<template>
<div class="certificate-item">
<div class="certificate-item__main">
<p class="certificate-item__title">{{ certificate.title || '—' }}</p>
<p class="certificate-item__student">{{ certificate.user?.name || '—' }}</p>
</div>
<div class="certificate-item__meta">
<div class="certificate-item__pill">
<span class="certificate-item__pill-label">تاریخ صدور:</span>
<span class="certificate-item__pill-value">{{ issuedAt }}</span>
</div>
<div class="certificate-item__pill">
<span class="certificate-item__pill-label">فایل‌ها:</span>
<span class="certificate-item__pill-value">{{ filesLabel }}</span>
</div>
</div>
<div class="certificate-item__actions">
<CircleButton
tooltip="حذف"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
@click="emit('delete', certificate)"
>
<template #icon>
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({
certificate: { type: Object, required: true },
})
const emit = defineEmits(['delete'])
const issuedAt = computed(() => formatJalaaliDate(props.certificate.issuedAt) || '—')
const filesLabel = computed(() => {
const parts = []
if (props.certificate.hasImage) parts.push('تصویر')
if (props.certificate.hasPdf) parts.push('PDF')
return parts.length > 0 ? parts.join(' / ') : '—'
})
</script>
<style lang="scss" scoped>
.certificate-item {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 50%);
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
border-radius: 0.875rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-flow: row wrap;
align-items: center;
}
&__main {
flex: 1 1 33%;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-size: 1rem;
color: #4b4b4b;
margin: 0 0 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__student {
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #848484;
margin: 0;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
flex: 1 1 33%;
}
&__pill {
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #535353;
margin-inline-end: 0.25rem;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.7rem;
font-weight: 500;
color: #535353;
}
&__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
flex: 1 1 100%;
@media (min-width: 1024px) {
flex: 1 1 auto;
}
}
}
</style>
@@ -0,0 +1,292 @@
<template>
<BasicModal
title="صدور گواهینامه"
title-en="Issue Certificate"
width="95%"
max-width="56rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="issue-certificate" @submit.prevent="onSubmit(close)">
<div class="issue-certificate__grid">
<SelectField
v-model="form.userId"
name="userId"
label="طلبه"
placeholder="نام طلبه را جستجو کنید"
:options="userOptions"
option-label="name"
option-value="id"
:searchable="true"
:on-search="searchUsers"
:error="errors.userId"
/>
<SelectField
v-model="form.termId"
name="termId"
label="ترم گذرانده‌شده"
placeholder="ترم را انتخاب کنید"
:options="termOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTerms"
:error="errors.termId"
/>
<SelectField
v-model="form.courseId"
name="courseId"
label="دوره گذرانده‌شده"
placeholder="دوره را انتخاب کنید"
:options="courseOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchCourses"
/>
<DatePickerField v-model="form.issuedAt" name="issuedAt" label="تاریخ صدور" />
<DatePickerField v-model="form.startedAt" name="startedAt" label="تاریخ شروع" />
<DatePickerField v-model="form.endedAt" name="endedAt" label="تاریخ پایان" />
</div>
<p v-if="errors.termId" class="issue-certificate__hint">{{ errors.termId }}</p>
<div class="issue-certificate__files">
<div class="issue-certificate__file-col">
<label class="issue-certificate__file-label">تصویر گواهینامه (JPG/PNG)</label>
<FileUploader
v-model="imageFiles"
accept="image/*"
:multiple="false"
:max-files="1"
@select="onImageSelect"
@error="onFileError"
/>
</div>
<div class="issue-certificate__file-col">
<label class="issue-certificate__file-label">فایل PDF گواهینامه</label>
<FileUploader
v-model="pdfFiles"
accept="application/pdf"
:multiple="false"
:max-files="1"
@select="onPdfSelect"
@error="onFileError"
/>
</div>
</div>
<p v-if="errors.imageMediaId" class="issue-certificate__hint">{{ errors.imageMediaId }}</p>
<div class="issue-certificate__divider" />
<div class="issue-certificate__actions">
<BaseButton
variant="transparent"
text="منصرف شدم"
custom-class="issue-certificate__btn-cancel"
@click="close"
>
<template #prependIcon>
<SvgIcon name="caret-right" :size="14" color="var(--color-sec-gray)" />
</template>
</BaseButton>
<BaseButton
type="submit"
text="صدور گواهینامه"
:loading="submitting"
custom-class="issue-certificate__btn-submit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { certificateSchema } from '@/features/admin/certificates/schema'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import {
adminCertificatesKeys,
useAddAdminCertificateMutation,
} from '@/services/query/admin-certificates'
defineOptions({ name: 'IssueCertificateModal' })
const queryClient = useQueryClient()
const form = ref({
userId: '',
termId: '',
courseId: '',
issuedAt: '',
startedAt: '',
endedAt: '',
})
const { validate, errors, resetErrors } = useYup(certificateSchema)
const userSearch = ref('')
const userFilters = computed(() => ({ search: userSearch.value, roles: 'student' }))
const userPagination = ref({ page: 1, perPage: 10 })
const { data: usersResponse } = useAdminUsersListQuery(userFilters, userPagination)
const userOptions = computed(() => usersResponse.value?.data ?? [])
const searchUsers = useDebounce((q) => {
userSearch.value = q || ''
}, 400)
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const searchTerms = useDebounce((q) => {
termSearch.value = q || ''
}, 400)
const courseSearch = ref('')
const courseFilters = computed(() => ({ title: courseSearch.value }))
const coursePagination = ref({ page: 1, perPage: 10 })
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination)
const courseOptions = computed(() => coursesResponse.value?.data ?? [])
const searchCourses = useDebounce((q) => {
courseSearch.value = q || ''
}, 400)
const imageFiles = ref([])
const pdfFiles = ref([])
const uploadMediaMutation = useUploadMediaMutation()
const onImageSelect = async (files) => {
const file = files[0]
if (!file) return
try {
const fd = objectToFormData({ file, purpose: 'certificate_image' })
const response = await uploadMediaMutation.mutateAsync(fd)
const payload = response?.data ?? response
imageFiles.value = [{ id: payload?.id, name: file.name, size: file.size, url: payload?.url }]
} catch {
toast.error('بارگذاری تصویر گواهینامه با خطا مواجه شد.')
}
}
const onPdfSelect = async (files) => {
const file = files[0]
if (!file) return
try {
const fd = objectToFormData({ file, purpose: 'certificate_pdf' })
const response = await uploadMediaMutation.mutateAsync(fd)
const payload = response?.data ?? response
pdfFiles.value = [{ id: payload?.id, name: file.name, size: file.size, url: payload?.url }]
} catch {
toast.error('بارگذاری فایل PDF گواهینامه با خطا مواجه شد.')
}
}
const onFileError = (msg) => toast.error(msg)
const addMutation = useAddAdminCertificateMutation()
const submitting = computed(() => addMutation.isPending.value)
const onSubmit = async (close) => {
const payload = {
...form.value,
imageMediaId: imageFiles.value[0]?.id ?? null,
pdfMediaId: pdfFiles.value[0]?.id ?? null,
}
const { isValid } = await validate(payload)
if (!isValid) return
await addMutation.mutateAsync(payload)
await queryClient.invalidateQueries({ queryKey: adminCertificatesKeys.all })
resetErrors()
close()
}
</script>
<style lang="scss" scoped>
.issue-certificate {
width: 100%;
text-align: start;
padding: 0.5rem 0;
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
margin-block: 0.75rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1280px) {
grid-template-columns: repeat(3, 1fr);
}
}
&__hint {
color: var(--color-error);
font-family: var(--font-family-fa);
font-size: 0.75rem;
margin: -0.25rem 0 0.5rem;
}
&__files {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 0.5rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__file-label {
display: block;
font-family: var(--font-family-fa);
font-size: 0.8125rem;
color: #4b5563;
margin-bottom: 0.375rem;
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1.5rem;
}
&__actions {
display: flex;
align-items: center;
justify-content: space-between;
}
&__btn-cancel {
min-width: 9rem;
}
&__btn-submit {
min-width: 12rem;
}
}
</style>
@@ -0,0 +1,129 @@
<template>
<div class="certificates-page">
<BoxedIconTitleBlock
class="certificates-page__heading"
title="مدیریت گواهینامه‌ها"
desc="در این قسمت می‌توانید برای طلبه‌ها گواهینامه صادر کنید"
>
<template #icon>
<SvgIcon name="certificate" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<div class="certificates-page__list-header">
<SimpleTitleIconBlock
title="لیست گواهینامه‌های صادرشده"
class="certificates-page__list-title"
>
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<BaseButton
text="صدور گواهینامه جدید"
custom-class="certificates-page__add-btn"
@click="onAdd"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
<div v-else-if="certificates.length > 0">
<CertificateItem
v-for="certificate in certificates"
:key="certificate.id"
:certificate="certificate"
@delete="onAskDelete"
/>
</div>
<NoItems v-else title="متاسفیم" desc="گواهینامه‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
<IssueCertificateModal v-if="isModal('IssueCertificateModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import CertificateItem from '@/features/admin/certificates/components/CertificateItem.vue'
import IssueCertificateModal from '@/features/admin/certificates/components/modals/IssueCertificateModal.vue'
import {
adminCertificatesKeys,
useAdminCertificatesListQuery,
useDeleteAdminCertificateMutation,
} from '@/services/query/admin-certificates'
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const filters = ref({})
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminCertificatesListQuery(filters, pagination, {
keepPreviousData: true,
})
const certificates = computed(() => data.value?.data ?? [])
const paginationMeta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const onAdd = () => openModal('IssueCertificateModal')
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCertificatesKeys.all })
const deleteMutation = useDeleteAdminCertificateMutation()
const onAskDelete = (certificate) => {
openModal('ConfirmModal', {
title: `حذف گواهینامه ${certificate.user?.name || ''}`,
message:
'بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید این گواهینامه را حذف کنید؟',
onConfirm: () => deleteMutation.mutate(certificate.id, { onSuccess: invalidate }),
})
}
</script>
<style lang="scss" scoped>
.certificates-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-header {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.375rem;
}
&__list-title {
flex: 1;
margin-bottom: 0;
}
&__add-btn {
min-width: fit-content;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,24 @@
import { mixed, number, object, string } from 'yup'
export const certificateSchema = object()
.shape({
userId: number().typeError('طلبه را انتخاب کنید').required('طلبه را انتخاب کنید'),
termId: mixed().nullable().notRequired(),
courseId: mixed().nullable().notRequired(),
issuedAt: string().nullable().notRequired(),
startedAt: string().nullable().notRequired(),
endedAt: string().nullable().notRequired(),
imageMediaId: number().nullable().notRequired(),
pdfMediaId: number().nullable().notRequired(),
})
.test('term-or-course', function (value) {
if (value?.termId || value?.courseId) return true
return this.createError({ path: 'termId', message: 'یکی از ترم یا دوره را انتخاب کنید' })
})
.test('image-or-pdf', function (value) {
if (value?.imageMediaId || value?.pdfMediaId) return true
return this.createError({
path: 'imageMediaId',
message: 'حداقل یک فایل (تصویر یا PDF) بارگذاری کنید',
})
})
@@ -17,14 +17,13 @@
</div> </div>
</div> </div>
<div v-if="!term.isActive" class="term-item__status">
<span class="term-item__status-badge">
<span class="term-item__status-dot" />
غیرفعال
</span>
</div>
<div class="term-item__actions"> <div class="term-item__actions">
<div v-if="!term.isActive" class="term-item__status">
<span class="term-item__status-badge">
<span class="term-item__status-dot" />
غیرفعال
</span>
</div>
<ToggleSwitch <ToggleSwitch
:model-value="!!term.isActive" :model-value="!!term.isActive"
@update:model-value="emit('change-status', { id: term.id, isActive: !term.isActive })" @update:model-value="emit('change-status', { id: term.id, isActive: !term.isActive })"
@@ -166,10 +165,6 @@ const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || ''
font-size: 0.75rem; font-size: 0.75rem;
} }
&__status {
flex: 0 0 auto;
}
&__status-badge { &__status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -191,6 +186,7 @@ const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || ''
&__actions { &__actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
@@ -25,12 +25,12 @@
<button <button
type="button" type="button"
class="users-table__role-badge" class="users-table__role-badge"
:class="`users-table__role-badge--${roleName(user) || 'unknown'}`" :class="`users-table__role-badge--${roleNames(user)[0] || 'unknown'}`"
:disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id" :disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id"
@click="onRoleClick($event, user)" @click="onRoleClick($event, user)"
> >
<span class="users-table__role-dot" /> <span class="users-table__role-dot" />
<span>{{ roleLabel(user) || 'بدون نقش' }}</span> <span>{{ roleLabelsText(user) || 'بدون نقش' }}</span>
<SvgIcon name="caret-down" :size="12" color="currentColor" /> <SvgIcon name="caret-down" :size="12" color="currentColor" />
</button> </button>
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot> <Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot>
@@ -96,15 +96,13 @@ const queryClient = useQueryClient()
const emit = defineEmits(['edit', 'delete', 'block', 'unblock', 'show-details']) const emit = defineEmits(['edit', 'delete', 'block', 'unblock', 'show-details'])
const roleName = (user) => { const roleNames = (user) =>
const first = user.roles?.[0] (user.roles || []).map((r) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
return typeof first === 'string' ? first : first?.name
}
const roleLabel = (user) => { const roleLabelsText = (user) => {
const name = roleName(user) const names = roleNames(user)
if (!name) return '' if (names.length === 0) return ''
return ROLES.find((r) => r.value === name)?.label || name return names.map((name) => ROLES.find((r) => r.value === name)?.label || name).join('، ')
} }
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -140,23 +138,39 @@ const onRoleClick = (event, user) => {
roleMenuOpen.value = true roleMenuOpen.value = true
} }
const onChangeRole = (targetRoleName) => { // Toggles one role's membership without touching the user's other roles —
// access must stay additive, so this can never collapse into a single-role
// replace the way the old "switch to X" action used to.
const onToggleRole = (targetRoleName, isAssigned) => {
const user = activeUser.value const user = activeUser.value
if (!user || !targetRoleName) return if (!user || !targetRoleName) return
const current = roleNames(user)
const nextRoles = isAssigned
? current.filter((r) => r !== targetRoleName)
: [...current, targetRoleName]
if (nextRoles.length === 0) return
updateRoleMutation.mutate( updateRoleMutation.mutate(
{ id: user.id, payload: { roles: [targetRoleName] } }, { id: user.id, payload: { roles: nextRoles } },
{ onSuccess: invalidateUsers } { onSuccess: invalidateUsers }
) )
} }
const roleMenuItems = computed(() => { const roleMenuItems = computed(() => {
const current = activeUser.value ? roleName(activeUser.value) : null const user = activeUser.value
return ROLES.filter((r) => r.changeable && r.value !== current).map((r) => ({ if (!user) return []
key: `role-${r.value}`, const current = roleNames(user)
label: `تغییر به ${r.label}`, return ROLES.filter((r) => r.changeable).map((r) => {
icon: 'arrows-clockwise', const isAssigned = current.includes(r.value)
onClick: () => onChangeRole(r.value), const isLastRole = isAssigned && current.length <= 1
})) return {
key: `role-${r.value}`,
label: isAssigned ? `حذف نقش ${r.label}` : `افزودن نقش ${r.label}`,
icon: isAssigned ? 'close' : 'check',
danger: isAssigned,
disabled: isLastRole,
onClick: () => onToggleRole(r.value, isAssigned),
}
})
}) })
const menuItems = computed(() => { const menuItems = computed(() => {
+12 -10
View File
@@ -19,14 +19,15 @@
<div class="user-form__row"> <div class="user-form__row">
<div class="user-form__cell user-form__cell--third"> <div class="user-form__cell user-form__cell--third">
<SelectField <SelectField
v-model="form.role" v-model="form.roles"
name="role" name="roles"
label="نقش" label="نقش‌ها"
:multiple="true"
:options="ROLES" :options="ROLES"
option-label="label" option-label="label"
option-value="value" option-value="value"
:error="errors.role" :error="errors.roles"
@change="validateAt('role', form.role)" @change="validateAt('roles', form.roles)"
/> />
</div> </div>
<div class="user-form__cell user-form__cell--third"> <div class="user-form__cell user-form__cell--third">
@@ -265,7 +266,7 @@ const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
const initialNationalCode = ref('') const initialNationalCode = ref('')
const form = ref({ const form = ref({
role: '', roles: [],
name: '', name: '',
birthday: '', birthday: '',
nationalCode: '', nationalCode: '',
@@ -307,11 +308,12 @@ watch(
existingUser, existingUser,
(user) => { (user) => {
if (!user) return if (!user) return
const firstRole = Array.isArray(user.roles) ? user.roles[0] : null const roleValues = Array.isArray(user.roles)
const roleValue = typeof firstRole === 'object' ? firstRole?.name : firstRole ? user.roles.map((r) => (typeof r === 'object' ? r?.name : r)).filter(Boolean)
: []
form.value = { form.value = {
...form.value, ...form.value,
role: roleValue || '', roles: roleValues,
name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim(), name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim(),
birthday: user.birthday || '', birthday: user.birthday || '',
nationalCode: user.nationalCode || '', nationalCode: user.nationalCode || '',
@@ -360,7 +362,7 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
const buildUpdatePayload = () => { const buildUpdatePayload = () => {
return { return {
name: form.value.name, name: form.value.name,
roles: form.value.role ? [form.value.role] : [], roles: form.value.roles,
provinceId: form.value.provinceId || null, provinceId: form.value.provinceId || null,
cityId: form.value.cityId || null, cityId: form.value.cityId || null,
marriageStatus: form.value.maritalStatus || null, marriageStatus: form.value.maritalStatus || null,
+2 -2
View File
@@ -1,4 +1,4 @@
import { number, object, string } from 'yup' import { array, number, object, string } from 'yup'
import { passwordRule } from '@/constants/rules/passwordRule' import { passwordRule } from '@/constants/rules/passwordRule'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule' import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule' import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
@@ -9,7 +9,7 @@ export const rejectionReasonSchema = object().shape({
}) })
const baseUserShape = { const baseUserShape = {
role: string().required(), roles: array().of(string().required()).min(1, 'حداقل یک نقش را انتخاب کنید').required(),
name: string().required().min(3), name: string().required().min(3),
phone: phoneNumberRule, phone: phoneNumberRule,
nationalCode: nationalCodeRule, nationalCode: nationalCodeRule,
@@ -1,6 +1,6 @@
<template> <template>
<div class="auth-copyright"> <div class="auth-copyright">
<a href="https://mugweb.ir/" target="_blank" rel="noopener"> <a href="https://madresebanoo.ir/" target="_blank" rel="noopener">
<p class="auth-copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست</p> <p class="auth-copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست</p>
<p class="auth-copyright__en">All Rights Reserved. 2026</p> <p class="auth-copyright__en">All Rights Reserved. 2026</p>
</a> </a>
@@ -2,7 +2,8 @@ import { useAuthStore } from '@/store/auth'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { MAIN_SITE_URL, ROLE_HOME_ROUTES } from '@/enums' import { MAIN_SITE_URL, ROLE_HOME_ROUTES } from '@/enums'
const shouldLandOnMainSite = (auth) => auth.role !== 'admin' && auth.user?.status === 'approved' const shouldLandOnMainSite = (auth) =>
!auth.roles.includes('admin') && auth.user?.status === 'approved'
export const useRedirectAfterLogin = () => { export const useRedirectAfterLogin = () => {
const router = useRouter() const router = useRouter()
@@ -18,6 +18,17 @@
<p class="student-register__desc">{{ stepLabel.desc }}</p> <p class="student-register__desc">{{ stepLabel.desc }}</p>
</header> </header>
<div
v-if="auth.user?.status === 'blocked'"
class="student-register__rejection"
role="alert"
>
<p class="student-register__rejection-title">احراز هویت شما توسط ادمین رد شده است.</p>
<p class="student-register__rejection-reason">
{{ auth.user?.blockReason || 'دلیلی ثبت نشده است.' }}
</p>
</div>
<div v-if="isRegistrationDataPending" class="student-register__loading" aria-live="polite"> <div v-if="isRegistrationDataPending" class="student-register__loading" aria-live="polite">
در حال بازیابی اطلاعات ثبت‌نام… در حال بازیابی اطلاعات ثبت‌نام…
</div> </div>
@@ -30,6 +41,7 @@
</template> </template>
<script setup> <script setup>
import { useAuthStore } from '@/store/auth'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
import { computed, markRaw, watch } from 'vue' import { computed, markRaw, watch } from 'vue'
import { STUDENT_REGISTRATION } from '@/enums' import { STUDENT_REGISTRATION } from '@/enums'
@@ -49,6 +61,7 @@ import EducationalInformation from '@/features/auth/components/studentRegister/E
import ProfessionalInformation from '@/features/auth/components/studentRegister/ProfessionalInformation.vue' import ProfessionalInformation from '@/features/auth/components/studentRegister/ProfessionalInformation.vue'
const store = useStudentRegistrationStore() const store = useStudentRegistrationStore()
const auth = useAuthStore()
const { isModal, openModal } = useModal() const { isModal, openModal } = useModal()
const { data: registerData, isPending: isRegistrationDataPending } = useGetRegisterDataQuery() const { data: registerData, isPending: isRegistrationDataPending } = useGetRegisterDataQuery()
@@ -273,6 +286,32 @@ watch(
text-align: center; text-align: center;
} }
&__rejection {
background: #fdecec;
border: 1px solid #f5b8b8;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
&__rejection-title {
font-family: var(--font-family-fa);
font-weight: 600;
font-size: 0.9375rem;
color: #c62828;
margin: 0 0 0.375rem;
}
&__rejection-reason {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
line-height: 1.8;
color: #7a2f2f;
margin: 0;
text-align: justify;
}
&__main :deep(.text-field__label), &__main :deep(.text-field__label),
&__main :deep(.textarea-field__label), &__main :deep(.textarea-field__label),
&__main :deep(.select-field__label), &__main :deep(.select-field__label),
+10
View File
@@ -0,0 +1,10 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetAdminCertificates = (params) =>
http.get(endpoints.getAdminCertificates, { params })
export const apiAddAdminCertificate = (payload) => http.post(endpoints.addAdminCertificate, payload)
export const apiDeleteAdminCertificate = (id) =>
http.delete(buildUrl(endpoints.deleteAdminCertificate, { id }))
+17 -4
View File
@@ -61,12 +61,25 @@ const addCamelizeInterceptor = (instance) => {
) )
} }
const _addUnauthorizeInterceptor = (instance) => { const addUnauthorizeInterceptor = (instance) => {
instance.interceptors.response.use( instance.interceptors.response.use(
(res) => res, (res) => res,
async (e) => { async (e) => {
if (e?.response?.status === 401) { // Login/OTP-verify endpoints also answer 401 for a plain wrong
tokenService.clearAll() // password or code — that's a credential failure the login form
// should handle itself, not a session expiring. Only treat this as
// "the session died" when we actually had a token going in.
if (e?.response?.status === 401 && tokenService.hasToken()) {
// Route through the auth store, not tokenService directly, so the
// reactive user state and the WordPress-facing mb_auth_status cookie
// both get cleared along with the token — an expired/invalid session
// must not leave WordPress showing a stale "logged in" state.
try {
const { useAuthStore } = await import('@/store/auth')
useAuthStore().clearUser()
} catch {
tokenService.clearAll()
}
toast('نشست شما تایید نشد، لطفا مجدد وارد شوید') toast('نشست شما تایید نشد، لطفا مجدد وارد شوید')
try { try {
const { default: router } = await import('@/router') const { default: router } = await import('@/router')
@@ -89,7 +102,7 @@ const createInstance = (baseUrl, { headers = {}, ...configs } = {}) => {
addResponseUnwrapInterceptor(instance) addResponseUnwrapInterceptor(instance)
addCamelizeInterceptor(instance) addCamelizeInterceptor(instance)
addSnakizeInterceptor(instance) addSnakizeInterceptor(instance)
// _addUnauthorizeInterceptor(instance) addUnauthorizeInterceptor(instance)
return instance return instance
} }
+30
View File
@@ -0,0 +1,30 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiAddAdminCertificate,
apiDeleteAdminCertificate,
apiGetAdminCertificates,
} from '@/services/api/admin-certificates'
export const adminCertificatesKeys = {
all: ['admin', 'certificates'],
list: (filters, pagination) => ['admin', 'certificates', 'list', filters, pagination],
}
export const useAdminCertificatesListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['admin', 'certificates', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetAdminCertificates({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
export const useAddAdminCertificateMutation = () =>
useMutation({ mutationFn: (payload) => apiAddAdminCertificate(payload) })
export const useDeleteAdminCertificateMutation = () =>
useMutation({ mutationFn: (id) => apiDeleteAdminCertificate(id) })
@@ -18,6 +18,10 @@ export const useStudentCertificatesQuery = (filtersRef, paginationRef, options =
...cleanFilters(filtersRef.value), ...cleanFilters(filtersRef.value),
...paginationRef.value, ...paginationRef.value,
}), }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options, ...options,
}) })