feat(rbac): 按钮级权限控制(v-permission + 权限 store + 用户页导出)
- 新增 permission store + v-permission 指令(响应式,异步权限加载后可恢复) - ty-crud 支持 createPermission 与动作 permission 过滤 - 用户页:分配角色/重置密码按权限码显示,新增导出按钮 + CSV 导出 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7ae220581a
commit
2f7f83b8b1
@ -5,3 +5,8 @@ import type { ResourceNode } from '@/types/resource'
|
|||||||
export function fetchMenus(): Promise<ResourceNode[]> {
|
export function fetchMenus(): Promise<ResourceNode[]> {
|
||||||
return http.get<ResourceNode[]>('/auth/menus')
|
return http.get<ResourceNode[]>('/auth/menus')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前用户权限码与超管标志(供按钮/路由级权限控制)。 */
|
||||||
|
export function fetchPermissions(): Promise<{ super: boolean; permissions: string[] }> {
|
||||||
|
return http.get<{ super: boolean; permissions: string[] }>('/auth/permissions')
|
||||||
|
}
|
||||||
|
|||||||
@ -71,3 +71,13 @@ export function deleteUser(id: string): Promise<unknown> {
|
|||||||
export function resetPassword(id: string, password: string): Promise<unknown> {
|
export function resetPassword(id: string, password: string): Promise<unknown> {
|
||||||
return http.patch(`/users/${id}/password`, { password })
|
return http.patch(`/users/${id}/password`, { password })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用户已分配的角色 code 列表。 */
|
||||||
|
export function listUserRoles(userId: string, signal?: AbortSignal): Promise<string[]> {
|
||||||
|
return http.get<string[]>(`/users/${userId}/roles`, { signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分配角色(整体替换)。 */
|
||||||
|
export function assignUserRoles(userId: string, roleCodes: string[]): Promise<unknown> {
|
||||||
|
return http.put(`/users/${userId}/roles`, { roleCodes })
|
||||||
|
}
|
||||||
|
|||||||
@ -12,7 +12,9 @@
|
|||||||
<el-card class="ty-crud__table" shadow="never">
|
<el-card class="ty-crud__table" shadow="never">
|
||||||
<div class="ty-crud__toolbar">
|
<div class="ty-crud__toolbar">
|
||||||
<div class="ty-crud__toolbar-left">
|
<div class="ty-crud__toolbar-left">
|
||||||
<el-button type="primary" @click="openCreate">新增{{ entityName }}</el-button>
|
<el-button v-if="!createPermission || permissionStore.has(createPermission)" type="primary" @click="openCreate">
|
||||||
|
新增{{ entityName }}
|
||||||
|
</el-button>
|
||||||
<slot name="toolbar" />
|
<slot name="toolbar" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ty-crud__toolbar-right">
|
<div class="ty-crud__toolbar-right">
|
||||||
@ -80,9 +82,12 @@ import type {
|
|||||||
import TyForm from '@/components/ty-form.vue'
|
import TyForm from '@/components/ty-form.vue'
|
||||||
import { showDialog } from '@/utils/dialog'
|
import { showDialog } from '@/utils/dialog'
|
||||||
import { useRequest } from '@/composables/useRequest'
|
import { useRequest } from '@/composables/useRequest'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
|
|
||||||
defineOptions({ name: 'TyCrud' })
|
defineOptions({ name: 'TyCrud' })
|
||||||
|
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 实体名(用于标题与删除确认,如「用户」)。 */
|
/** 实体名(用于标题与删除确认,如「用户」)。 */
|
||||||
@ -115,6 +120,8 @@ const props = withDefaults(
|
|||||||
collapseCount?: number
|
collapseCount?: number
|
||||||
/** 是否启用多选列(配合 #toolbar-right 做批量操作)。 */
|
/** 是否启用多选列(配合 #toolbar-right 做批量操作)。 */
|
||||||
selection?: boolean
|
selection?: boolean
|
||||||
|
/** 新增按钮所需权限码(无该权限且非超管时隐藏新增按钮)。 */
|
||||||
|
createPermission?: string
|
||||||
actionsWidth?: number | string
|
actionsWidth?: number | string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@ -229,7 +236,11 @@ const resolvedActions = computed<CrudActionItem[]>(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function visibleActions(row: Record<string, any>): CrudActionItem[] {
|
function visibleActions(row: Record<string, any>): CrudActionItem[] {
|
||||||
return resolvedActions.value.filter((a) => !a.show || a.show(row))
|
return resolvedActions.value.filter((a) => {
|
||||||
|
if (a.show && !a.show(row)) return false
|
||||||
|
if (a.permission && !permissionStore.has(a.permission)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayActions(row: Record<string, any>): CrudActionItem[] {
|
function displayActions(row: Record<string, any>): CrudActionItem[] {
|
||||||
|
|||||||
25
src/directives/permission.ts
Normal file
25
src/directives/permission.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { watchEffect, type Directive } from 'vue'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
|
|
||||||
|
const stops = new WeakMap<HTMLElement, () => void>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v-permission 指令:当前用户不具备指定权限码(且非超管)时隐藏元素。
|
||||||
|
* 用法:v-permission="'user:create'" 或 v-permission="['user:create', 'user:update']"(任一满足即可)。
|
||||||
|
* 权限异步加载:用 watchEffect 响应式隐藏/恢复,权限加载完成后自动显示。
|
||||||
|
*/
|
||||||
|
export const vPermission: Directive<HTMLElement, string | string[]> = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
const store = usePermissionStore()
|
||||||
|
const required = (Array.isArray(binding.value) ? binding.value : [binding.value]).filter(Boolean) as string[]
|
||||||
|
if (required.length === 0) return
|
||||||
|
const stop = watchEffect(() => {
|
||||||
|
el.style.display = store.hasAny(required) ? '' : 'none'
|
||||||
|
})
|
||||||
|
stops.set(el, stop)
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
stops.get(el)?.()
|
||||||
|
stops.delete(el)
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -74,6 +74,7 @@ import { HOME_PATH, useTabsStore } from '@/stores/modules/tabs'
|
|||||||
import { useAuthStore } from '@/stores/modules/auth'
|
import { useAuthStore } from '@/stores/modules/auth'
|
||||||
import { useMenuStore } from '@/stores/modules/menu'
|
import { useMenuStore } from '@/stores/modules/menu'
|
||||||
import { useDictStore } from '@/stores/modules/dict'
|
import { useDictStore } from '@/stores/modules/dict'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
import logo from '@/assets/images/logo.png'
|
import logo from '@/assets/images/logo.png'
|
||||||
import type { LayoutSettings } from '@/config/layout'
|
import type { LayoutSettings } from '@/config/layout'
|
||||||
import type { ResourceNode } from '@/types/resource'
|
import type { ResourceNode } from '@/types/resource'
|
||||||
@ -85,6 +86,7 @@ const tabsStore = useTabsStore()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const menuStore = useMenuStore()
|
const menuStore = useMenuStore()
|
||||||
const dictStore = useDictStore()
|
const dictStore = useDictStore()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||||
@ -94,10 +96,11 @@ const HEADER_HEIGHT = 56
|
|||||||
const menus = computed(() => menuStore.menus)
|
const menus = computed(() => menuStore.menus)
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
// 登录后拉取菜单与字典
|
// 登录后拉取菜单、字典与权限码
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
menuStore.fetch()
|
menuStore.fetch()
|
||||||
dictStore.fetch()
|
dictStore.fetch()
|
||||||
|
permissionStore.fetch()
|
||||||
})
|
})
|
||||||
|
|
||||||
const activePath = computed(() => route.path)
|
const activePath = computed(() => route.path)
|
||||||
@ -223,6 +226,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
await auth.logout()
|
await auth.logout()
|
||||||
menuStore.reset()
|
menuStore.reset()
|
||||||
dictStore.reset()
|
dictStore.reset()
|
||||||
|
permissionStore.reset()
|
||||||
tabsStore.removeAll()
|
tabsStore.removeAll()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,14 @@ import { installI18n } from '@/i18n'
|
|||||||
import { setAuthExpiredHandler } from '@/api/http'
|
import { setAuthExpiredHandler } from '@/api/http'
|
||||||
import { useMenuStore } from '@/stores/modules/menu'
|
import { useMenuStore } from '@/stores/modules/menu'
|
||||||
import { useDictStore } from '@/stores/modules/dict'
|
import { useDictStore } from '@/stores/modules/dict'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
import { useTabsStore } from '@/stores/modules/tabs'
|
import { useTabsStore } from '@/stores/modules/tabs'
|
||||||
|
import { vPermission } from '@/directives/permission'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.directive('permission', vPermission)
|
||||||
|
|
||||||
installRouter(app)
|
installRouter(app)
|
||||||
installStore(app)
|
installStore(app)
|
||||||
|
|
||||||
@ -21,6 +25,7 @@ installStore(app)
|
|||||||
setAuthExpiredHandler(() => {
|
setAuthExpiredHandler(() => {
|
||||||
useMenuStore(pinia).reset()
|
useMenuStore(pinia).reset()
|
||||||
useDictStore(pinia).reset()
|
useDictStore(pinia).reset()
|
||||||
|
usePermissionStore(pinia).reset()
|
||||||
useTabsStore(pinia).removeAll()
|
useTabsStore(pinia).removeAll()
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
if (router.currentRoute.value.path !== '/login') {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
|
|||||||
55
src/pages/system/user/assign-roles-form.vue
Normal file
55
src/pages/system/user/assign-roles-form.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div v-loading="!ready" class="assign-roles">
|
||||||
|
<el-checkbox-group v-model="model.roleCodes" class="assign-roles__group">
|
||||||
|
<el-checkbox v-for="r in roles" :key="r.code" :value="r.code">
|
||||||
|
<span>{{ r.name }}</span>
|
||||||
|
<span class="assign-roles__code">{{ r.code }}</span>
|
||||||
|
</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
<el-empty v-if="ready && roles.length === 0" description="暂无可用角色" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import { useRequest } from '@/composables/useRequest'
|
||||||
|
import { listRoles } from '@/api/role'
|
||||||
|
import { listUserRoles } from '@/api/user'
|
||||||
|
|
||||||
|
defineOptions({ name: 'AssignRolesForm' })
|
||||||
|
|
||||||
|
const props = defineProps<{ userId: string }>()
|
||||||
|
|
||||||
|
const { data: rolePage } = useRequest((signal: AbortSignal) => listRoles({ page: 1, pageSize: 1000 }, signal))
|
||||||
|
const { data: assigned } = useRequest((signal: AbortSignal) => listUserRoles(props.userId, signal))
|
||||||
|
|
||||||
|
const roles = computed(() => rolePage.value?.list ?? [])
|
||||||
|
const ready = computed(() => rolePage.value !== undefined && assigned.value !== undefined)
|
||||||
|
|
||||||
|
const model = reactive<{ roleCodes: string[] }>({ roleCodes: [] })
|
||||||
|
|
||||||
|
// 回显已分配角色
|
||||||
|
watch(assigned, (v) => {
|
||||||
|
if (v) model.roleCodes = [...v]
|
||||||
|
})
|
||||||
|
|
||||||
|
function submit(): { roleCodes: string[] } {
|
||||||
|
return { roleCodes: model.roleCodes }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ submit })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.assign-roles {
|
||||||
|
@apply min-h-[120px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-roles__group {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-roles__code {
|
||||||
|
@apply ml-1 text-xs text-text-secondary;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -11,10 +11,14 @@
|
|||||||
:update="update"
|
:update="update"
|
||||||
:remove="remove"
|
:remove="remove"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
|
create-permission="user:create"
|
||||||
id-key="id"
|
id-key="id"
|
||||||
name-key="username"
|
name-key="username"
|
||||||
:edit-readonly-keys="['username']"
|
:edit-readonly-keys="['username']"
|
||||||
>
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<el-button v-permission="'user:export'" @click="exportUsers">导出</el-button>
|
||||||
|
</template>
|
||||||
<template #toolbar-right="{ selectedRows }">
|
<template #toolbar-right="{ selectedRows }">
|
||||||
<el-button
|
<el-button
|
||||||
type="danger"
|
type="danger"
|
||||||
@ -34,6 +38,7 @@ import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/cru
|
|||||||
import { showDialog } from '@/utils/dialog'
|
import { showDialog } from '@/utils/dialog'
|
||||||
import { useRequest } from '@/composables/useRequest'
|
import { useRequest } from '@/composables/useRequest'
|
||||||
import {
|
import {
|
||||||
|
assignUserRoles,
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
listUsers,
|
listUsers,
|
||||||
@ -44,6 +49,7 @@ import {
|
|||||||
} from '@/api/user'
|
} from '@/api/user'
|
||||||
import { listOrgs } from '@/api/org'
|
import { listOrgs } from '@/api/org'
|
||||||
import ResetPasswordForm from './reset-password-form.vue'
|
import ResetPasswordForm from './reset-password-form.vue'
|
||||||
|
import AssignRolesForm from './assign-roles-form.vue'
|
||||||
|
|
||||||
// 组织列表(下拉 + 名称回显)
|
// 组织列表(下拉 + 名称回显)
|
||||||
const { data: orgs } = useRequest((signal: AbortSignal) => listOrgs(signal))
|
const { data: orgs } = useRequest((signal: AbortSignal) => listOrgs(signal))
|
||||||
@ -112,7 +118,8 @@ const formSchema = computed<JsonSchema>(() => ({
|
|||||||
const actions: CrudAction[] = [
|
const actions: CrudAction[] = [
|
||||||
'edit',
|
'edit',
|
||||||
'delete',
|
'delete',
|
||||||
{ label: '重置密码', type: 'warning', onClick: (row) => resetPwd(row) },
|
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
|
||||||
|
{ label: '重置密码', type: 'warning', permission: 'user:reset-pwd', onClick: (row) => resetPwd(row) },
|
||||||
]
|
]
|
||||||
|
|
||||||
function toListQuery(query: PageQuery): UserListQuery {
|
function toListQuery(query: PageQuery): UserListQuery {
|
||||||
@ -144,6 +151,18 @@ async function resetPwd(row: Record<string, any>): Promise<void> {
|
|||||||
ElMessage.success('密码已重置')
|
ElMessage.success('密码已重置')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assignRolesAction(row: Record<string, any>): Promise<void> {
|
||||||
|
const result = await showDialog({
|
||||||
|
component: AssignRolesForm,
|
||||||
|
props: { userId: String(row.id) },
|
||||||
|
title: `分配角色(${row.username})`,
|
||||||
|
width: '480px',
|
||||||
|
})
|
||||||
|
if (result == null) return
|
||||||
|
await assignUserRoles(String(row.id), result.roleCodes)
|
||||||
|
ElMessage.success('角色已保存')
|
||||||
|
}
|
||||||
|
|
||||||
const crudRef = ref()
|
const crudRef = ref()
|
||||||
|
|
||||||
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
||||||
@ -154,4 +173,25 @@ async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
|||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
crudRef.value?.reload()
|
crudRef.value?.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeCsv(v: unknown): string {
|
||||||
|
const s = String(v ?? '')
|
||||||
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportUsers(): Promise<void> {
|
||||||
|
const result = await listUsers({ page: 1, pageSize: 1000 })
|
||||||
|
const headers = ['用户名', '昵称', '邮箱', '手机', '状态', '所属组织', '创建时间']
|
||||||
|
const lines = [headers.join(',')]
|
||||||
|
for (const u of result.list) {
|
||||||
|
lines.push([u.username, u.nickname, u.email, u.phone, u.status, u.orgCode, u.createdAt].map(escapeCsv).join(','))
|
||||||
|
}
|
||||||
|
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = '用户列表.csv'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
40
src/stores/modules/permission.ts
Normal file
40
src/stores/modules/permission.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { fetchPermissions } from '@/api/auth'
|
||||||
|
|
||||||
|
/** 当前用户权限码与超管标志(登录后拉取,供 v-permission 指令使用,非持久化)。 */
|
||||||
|
export const usePermissionStore = defineStore('permissionStore', () => {
|
||||||
|
const permissions = ref<string[]>([])
|
||||||
|
const isSuper = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetch(): Promise<void> {
|
||||||
|
if (loaded.value || loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchPermissions()
|
||||||
|
permissions.value = data.permissions
|
||||||
|
isSuper.value = data.super
|
||||||
|
loaded.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(code: string): boolean {
|
||||||
|
return isSuper.value || permissions.value.includes(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAny(codes: string[]): boolean {
|
||||||
|
return isSuper.value || codes.some((code) => permissions.value.includes(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
permissions.value = []
|
||||||
|
isSuper.value = false
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { permissions, isSuper, loaded, loading, fetch, has, hasAny, reset }
|
||||||
|
})
|
||||||
@ -86,6 +86,8 @@ export interface CrudActionItem {
|
|||||||
onClick: (row: Record<string, any>) => void | Promise<void>
|
onClick: (row: Record<string, any>) => void | Promise<void>
|
||||||
/** 是否对当前行显示(缺省显示)。 */
|
/** 是否对当前行显示(缺省显示)。 */
|
||||||
show?: (row: Record<string, any>) => boolean
|
show?: (row: Record<string, any>) => boolean
|
||||||
|
/** 所需权限码(无该权限且非超管时隐藏)。 */
|
||||||
|
permission?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 操作列动作:内置 'edit'/'delete' 或自定义动作。 */
|
/** 操作列动作:内置 'edit'/'delete' 或自定义动作。 */
|
||||||
|
|||||||
13
typed-router.d.ts
vendored
13
typed-router.d.ts
vendored
@ -135,6 +135,13 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/system/user/assign-roles-form': RouteRecordInfo<
|
||||||
|
'/system/user/assign-roles-form',
|
||||||
|
'/system/user/assign-roles-form',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'/system/user/reset-password-form': RouteRecordInfo<
|
'/system/user/reset-password-form': RouteRecordInfo<
|
||||||
'/system/user/reset-password-form',
|
'/system/user/reset-password-form',
|
||||||
'/system/user/reset-password-form',
|
'/system/user/reset-password-form',
|
||||||
@ -251,6 +258,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/system/user/assign-roles-form.vue': {
|
||||||
|
routes:
|
||||||
|
| '/system/user/assign-roles-form'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/system/user/reset-password-form.vue': {
|
'src/pages/system/user/reset-password-form.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/system/user/reset-password-form'
|
| '/system/user/reset-password-form'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user