chore: initial commit
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
decideRouteAccess,
|
||||
resolvePreviewTarget,
|
||||
sanitizeInternalPath,
|
||||
} from './accessDecision'
|
||||
|
||||
describe('sanitizeInternalPath', () => {
|
||||
it.each([
|
||||
[undefined, '/'],
|
||||
[null, '/'],
|
||||
[42, '/'],
|
||||
['', '/'],
|
||||
['customers', '/'],
|
||||
['//example.com', '/'],
|
||||
['/\\example.com', '/'],
|
||||
['/\n/example.com', '/'],
|
||||
['/\r/example.com', '/'],
|
||||
['/\t/example.com', '/'],
|
||||
['/\u0000/example.com', '/'],
|
||||
['/\u007f/example.com', '/'],
|
||||
['https://example.com', '/'],
|
||||
])('sanitizes %s to %s', (value, expected) => {
|
||||
expect(sanitizeInternalPath(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('accepts an internal path with its query string', () => {
|
||||
expect(sanitizeInternalPath('/customers?status=pending')).toBe('/customers?status=pending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideRouteAccess', () => {
|
||||
it('checks authentication before route permission', () => {
|
||||
expect(decideRouteAccess({
|
||||
requiresAuth: true,
|
||||
isAuthenticated: false,
|
||||
fullPath: '/system/roles?tab=enabled',
|
||||
permission: 'system:role:list',
|
||||
hasPermission: false,
|
||||
})).toEqual({ type: 'login', redirect: '/system/roles?tab=enabled' })
|
||||
})
|
||||
|
||||
it('sanitizes an unsafe login redirect', () => {
|
||||
expect(decideRouteAccess({
|
||||
requiresAuth: true,
|
||||
isAuthenticated: false,
|
||||
fullPath: '//example.com',
|
||||
permission: 'system:role:list',
|
||||
hasPermission: false,
|
||||
})).toEqual({ type: 'login', redirect: '/' })
|
||||
})
|
||||
|
||||
it('sends an authenticated user without permission to forbidden and preserves the internal source', () => {
|
||||
expect(decideRouteAccess({
|
||||
requiresAuth: true,
|
||||
isAuthenticated: true,
|
||||
fullPath: '/customers?status=pending',
|
||||
permission: 'customer:list',
|
||||
hasPermission: false,
|
||||
})).toEqual({ type: 'forbidden', from: '/customers?status=pending' })
|
||||
})
|
||||
|
||||
it('allows the authenticated forbidden route because it has no permission metadata', () => {
|
||||
expect(decideRouteAccess({
|
||||
requiresAuth: true,
|
||||
isAuthenticated: true,
|
||||
fullPath: '/403?from=%2Fcustomers',
|
||||
hasPermission: false,
|
||||
})).toEqual({ type: 'allow' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePreviewTarget', () => {
|
||||
it('recovers from forbidden to the first accessible route', () => {
|
||||
expect(resolvePreviewTarget({
|
||||
currentPath: '/403',
|
||||
currentPermission: undefined,
|
||||
candidateCodes: new Set(['customer:list']),
|
||||
firstPath: '/customers',
|
||||
})).toBe('/customers')
|
||||
})
|
||||
|
||||
it('stays on forbidden when the candidate has no accessible route', () => {
|
||||
expect(resolvePreviewTarget({
|
||||
currentPath: '/403',
|
||||
currentPermission: undefined,
|
||||
candidateCodes: new Set<string>(),
|
||||
firstPath: null,
|
||||
})).toBe('/403')
|
||||
})
|
||||
|
||||
it('falls back when the current route permission is lost', () => {
|
||||
expect(resolvePreviewTarget({
|
||||
currentPath: '/system/roles',
|
||||
currentPermission: 'system:role:list',
|
||||
candidateCodes: new Set(['customer:list']),
|
||||
firstPath: '/customers',
|
||||
})).toBe('/customers')
|
||||
})
|
||||
|
||||
it('keeps the current route when its permission remains available', () => {
|
||||
expect(resolvePreviewTarget({
|
||||
currentPath: '/customers',
|
||||
currentPermission: 'customer:list',
|
||||
candidateCodes: new Set(['customer:list']),
|
||||
firstPath: '/customers',
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
export type RouteAccessDecision =
|
||||
| { type: 'allow' }
|
||||
| { type: 'login'; redirect: string }
|
||||
| { type: 'forbidden'; from: string }
|
||||
|
||||
export interface RouteAccessInput {
|
||||
requiresAuth?: boolean
|
||||
isAuthenticated: boolean
|
||||
fullPath: string
|
||||
permission?: string
|
||||
hasPermission: boolean
|
||||
}
|
||||
|
||||
export interface PreviewTargetInput {
|
||||
currentPath: string
|
||||
currentPermission?: string
|
||||
candidateCodes: ReadonlySet<string>
|
||||
firstPath: string | null
|
||||
}
|
||||
|
||||
export function sanitizeInternalPath(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string'
|
||||
|| !value.startsWith('/')
|
||||
|| value[1] === '/'
|
||||
|| value[1] === '\\'
|
||||
|| /[\u0000-\u001F\u007F]/.test(value)
|
||||
) return '/'
|
||||
return value
|
||||
}
|
||||
|
||||
export function decideRouteAccess(input: RouteAccessInput): RouteAccessDecision {
|
||||
if (input.requiresAuth && !input.isAuthenticated) {
|
||||
return { type: 'login', redirect: sanitizeInternalPath(input.fullPath) }
|
||||
}
|
||||
if (input.permission && !input.hasPermission) {
|
||||
return { type: 'forbidden', from: sanitizeInternalPath(input.fullPath) }
|
||||
}
|
||||
return { type: 'allow' }
|
||||
}
|
||||
|
||||
export function resolvePreviewTarget(input: PreviewTargetInput): string | null {
|
||||
if (input.currentPath === '/403') return input.firstPath ?? '/403'
|
||||
if (input.currentPermission && !input.candidateCodes.has(input.currentPermission)) {
|
||||
return input.firstPath ?? '/403'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import router from './index'
|
||||
|
||||
describe('admin router access metadata', () => {
|
||||
it('uses the root route only as a permission-aware entry point', () => {
|
||||
const home = router.getRoutes().find((route) => route.name === 'home')
|
||||
|
||||
expect(home?.path).toBe('/')
|
||||
expect(home?.meta.permission).toBeUndefined()
|
||||
expect(router.getRoutes().some((route) => route.name === 'dashboard')).toBe(false)
|
||||
})
|
||||
|
||||
it('protects warehouse management with its stable list permission', () => {
|
||||
const warehouseRoute = router.getRoutes().find((route) => route.name === 'warehouses')
|
||||
|
||||
expect(warehouseRoute?.path).toBe('/warehouses')
|
||||
expect(warehouseRoute?.meta.permission).toBe('platform:warehouse:list')
|
||||
})
|
||||
|
||||
it('protects express company records with a stable master-data permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'express-companies')
|
||||
|
||||
expect(route?.path).toBe('/master-data/express-companies')
|
||||
expect(route?.meta.permission).toBe('platform:express:list')
|
||||
})
|
||||
|
||||
it('protects counterparty records with a stable master-data permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'counterparties')
|
||||
expect(route?.path).toBe('/master-data/counterparties')
|
||||
expect(route?.meta.permission).toBe('platform:counterparty:list')
|
||||
})
|
||||
|
||||
it('protects enterprise reviews with the backend review menu permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews')
|
||||
|
||||
expect(route?.path).toBe('/customers/reviews')
|
||||
expect(route?.meta.permission).toBe('platform:customer:review')
|
||||
})
|
||||
|
||||
it('keeps forbidden inside the authenticated layout without a business permission', () => {
|
||||
const authenticatedLayout = router.options.routes.find((route) => route.path === '/')
|
||||
const forbidden = authenticatedLayout?.children?.find((route) => route.name === 'forbidden')
|
||||
const resolved = router.resolve('/403')
|
||||
|
||||
expect(authenticatedLayout?.meta?.requiresAuth).toBe(true)
|
||||
expect(forbidden?.path).toBe('403')
|
||||
expect(forbidden?.meta?.permission).toBeUndefined()
|
||||
expect(resolved.matched.some((record) => record.meta.requiresAuth)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AppLayout from '@/layouts/AppLayout.vue'
|
||||
import { decideRouteAccess } from '@/router/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/views/ModuleView.vue'),
|
||||
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面' },
|
||||
},
|
||||
{
|
||||
path: 'customers',
|
||||
name: 'customers',
|
||||
component: () => import('@/views/CustomersView.vue'),
|
||||
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list' },
|
||||
},
|
||||
{
|
||||
path: 'customers/reviews',
|
||||
name: 'enterprise-reviews',
|
||||
component: () => import('@/views/EnterpriseReviewsView.vue'),
|
||||
meta: { title: '企业审核', description: '核验企业资质并维护客户下单资格', permission: 'platform:customer:review' },
|
||||
},
|
||||
{
|
||||
path: 'warehouses',
|
||||
name: 'warehouses',
|
||||
component: () => import('@/views/WarehousesView.vue'),
|
||||
meta: { title: '仓库管理', description: '维护客户头程下单可选择的平台收货仓', permission: 'platform:warehouse:list' },
|
||||
},
|
||||
{
|
||||
path: 'orders/first-leg',
|
||||
name: 'first-leg-orders',
|
||||
component: () => import('@/views/FirstLegOrdersView.vue'),
|
||||
meta: { title: '头程订单', description: '创建、受理和跟进客户提交的头程运输订单', permission: 'platform:order:list' },
|
||||
},
|
||||
{
|
||||
path: 'master-data/express-companies',
|
||||
name: 'express-companies',
|
||||
component: () => import('@/views/ExpressCompaniesView.vue'),
|
||||
meta: { title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' },
|
||||
},
|
||||
{
|
||||
path: 'master-data/counterparties',
|
||||
name: 'counterparties',
|
||||
component: () => import('@/views/CounterpartiesView.vue'),
|
||||
meta: { title: '往来单位', description: '维护供应商、客户、承运商、报关行及收发货单位档案', permission: 'platform:counterparty:list' },
|
||||
},
|
||||
{
|
||||
path: 'system/users',
|
||||
name: 'users',
|
||||
component: () => import('@/views/InternalUsersView.vue'),
|
||||
meta: { title: '内部用户', description: '管理平台内部员工账号、状态和角色', permission: 'platform:user:list' },
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
name: 'roles',
|
||||
component: () => import('@/views/RolesView.vue'),
|
||||
meta: { title: '角色管理', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' },
|
||||
},
|
||||
{
|
||||
path: 'system/permissions',
|
||||
name: 'permissions',
|
||||
component: () => import('@/views/PermissionsView.vue'),
|
||||
meta: { title: '菜单权限', description: '维护菜单、页面、按钮和接口权限编码', permission: 'platform:permission:list' },
|
||||
},
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
component: () => import('@/views/ForbiddenView.vue'),
|
||||
meta: { title: '权限不足', description: '当前权限预览角色无法访问此页面' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} - 虫虫出海管理后台` : '虫虫出海管理后台'
|
||||
const auth = useAuthStore()
|
||||
if (to.name === 'login' && auth.isAuthenticated) {
|
||||
return { name: 'home' }
|
||||
}
|
||||
if (!auth.isAuthenticated && !to.meta.requiresAuth) return true
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
if (auth.isAuthenticated) access.initializeAfterLogin()
|
||||
const decision = decideRouteAccess({
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
fullPath: to.fullPath,
|
||||
permission: to.meta.permission,
|
||||
hasPermission: to.meta.permission ? access.can(to.meta.permission) : true,
|
||||
})
|
||||
|
||||
if (decision.type === 'login') {
|
||||
return { name: 'login', query: { redirect: decision.redirect } }
|
||||
}
|
||||
if (decision.type === 'forbidden') {
|
||||
return { name: 'forbidden', query: { from: decision.from }, replace: true }
|
||||
}
|
||||
if (to.name === 'home') {
|
||||
return access.firstAccessiblePath
|
||||
? { path: access.firstAccessiblePath, replace: true }
|
||||
: { name: 'forbidden', replace: true }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
description?: string
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user