feat(ui,router): gate routes and sidebar by workspace capability

This commit is contained in:
matevip 2026-05-15 10:18:38 +08:00
parent aa27853712
commit 777e2ecee2
6 changed files with 296 additions and 56 deletions

View File

@ -0,0 +1,37 @@
import { computed } from 'vue'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import type { Capability } from '@/composables/capabilities'
/**
* Shared sidebar/nav filtering. The three layouts (Main / Settings / Security)
* all call this with their own scope so they cannot drift in which menu items
* a given role sees. Items with no `requiredCapability` are visible to anyone
* authenticated; items requiring `globalAdmin: true` only show for global
* admins regardless of workspace role.
*/
export interface NavItem {
/** Vue Router path (absolute) — used for both `:to` and active match. */
path: string
/** i18n key or literal label rendered in the menu. */
label: string
/** Lucide / pixel icon name or symbol. Layout decides how to render. */
icon?: string
/** Required workspace capability; absent means "any authenticated user". */
requiredCapability?: Capability
/** When true, only shows for global admins (mate_user.role='admin'). */
globalAdmin?: boolean
}
export function useNavItems(items: NavItem[]) {
const store = useWorkspaceStore()
return computed(() => {
// Default deny while capabilities are still loading — render nothing
// rather than flashing the full menu and snapping it back.
if (!store.accessLoaded) return []
return items.filter((item) => {
if (item.globalAdmin) return store.isGlobalAdmin
if (item.requiredCapability && !store.can(item.requiredCapability)) return false
return true
})
})
}

View File

@ -3382,4 +3382,11 @@ export default {
listAvailableSkills: 'List Skills',
readSkillFile: 'Read Skill File',
},
forbidden: {
title: 'Access denied',
message: 'Your role in this workspace does not include this page.',
currentRole: 'Current role: {role}',
goChat: 'Go to chat',
goBack: 'Go back',
},
} as const

View File

@ -3474,4 +3474,11 @@ export default {
listAvailableSkills: '列出可用技能',
readSkillFile: '读取技能文件',
},
forbidden: {
title: '无权访问',
message: '当前工作区中你的角色无法访问该页面。',
currentRole: '当前角色:{role}',
goChat: '返回对话',
goBack: '返回上一页',
},
} as const

View File

@ -1,4 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { Capability } from '@/composables/capabilities'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
// Augment vue-router's RouteMeta so each route can declare its capability gate.
declare module 'vue-router' {
interface RouteMeta {
title?: string
keepAlive?: boolean
requireAdmin?: boolean
requiredCapability?: Capability
}
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -13,19 +25,19 @@ const router = createRouter({
path: 'chat',
name: 'Chat',
component: () => import('@/views/ChatConsole.vue'),
meta: { title: 'Chat' },
meta: { title: 'Chat', requiredCapability: 'chat' },
},
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: 'Dashboard' },
meta: { title: 'Dashboard', requiredCapability: 'view:dashboard' },
},
{
path: 'agents',
name: 'Agents',
component: () => import('@/views/Agents.vue'),
meta: { title: 'Agents' },
meta: { title: 'Agents', requiredCapability: 'manage:agents' },
},
{
path: 'backstage',
@ -37,19 +49,19 @@ const router = createRouter({
path: 'wiki',
name: 'Wiki',
component: () => import('@/views/Wiki/index.vue'),
meta: { title: 'Wiki' },
meta: { title: 'Wiki', requiredCapability: 'view:wiki' },
},
{
path: 'enterprise',
name: 'Enterprise',
component: () => import('@/views/Enterprise/index.vue'),
meta: { title: 'Enterprise Scenarios' },
meta: { title: 'Enterprise Scenarios', requiredCapability: 'manage:agents' },
},
{
path: 'memory',
name: 'Memory',
component: () => import('@/views/Memory/index.vue'),
meta: { title: 'Memory' },
meta: { title: 'Memory', requiredCapability: 'view:memory' },
},
// ==================== Connect ====================
{
@ -59,13 +71,13 @@ const router = createRouter({
// keepAlive: cache the component instance so navigating away and
// back doesn't re-mount + re-fetch the list. Channels.vue must
// pause polling in onDeactivated to avoid a leaked timer.
meta: { title: 'Channels', keepAlive: true },
meta: { title: 'Channels', keepAlive: true, requiredCapability: 'manage:channels' },
},
{
path: 'skills',
name: 'Skills',
component: () => import('@/views/SkillMarket.vue'),
meta: { title: 'Skills' },
meta: { title: 'Skills', requiredCapability: 'manage:skills' },
},
// Tools 顶层入口已降级到 Settings ▸ Tools (Catalog) (RFC-090 Phase 1)
// 旧路径 /tools 由下方 redirect 兼容
@ -73,20 +85,20 @@ const router = createRouter({
path: 'activity',
name: 'Activity',
component: () => import('@/views/Security/Activity/index.vue'),
meta: { title: 'Activity' },
meta: { title: 'Activity', requiredCapability: 'manage:security' },
},
// RFC-091: Skill 模板库 + 创作向导
{
path: 'skills/templates',
name: 'SkillTemplates',
component: () => import('@/views/SkillTemplates.vue'),
meta: { title: 'Skill Templates' },
meta: { title: 'Skill Templates', requiredCapability: 'manage:skills' },
},
{
path: 'plugins',
name: 'Plugins',
component: () => import('@/views/Plugins.vue'),
meta: { title: 'Plugins' },
meta: { title: 'Plugins', requiredCapability: 'manage:settings' },
},
// ==================== Settings (absorbs advanced pages) ====================
{
@ -98,62 +110,62 @@ const router = createRouter({
path: 'models',
name: 'SettingsModels',
component: () => import('@/views/Settings/Models/index.vue'),
meta: { title: 'Settings - Models' },
meta: { title: 'Settings - Models', requiredCapability: 'manage:models' },
},
{
path: 'system',
name: 'SettingsSystem',
component: () => import('@/views/Settings/System/index.vue'),
meta: { title: 'Settings - System' },
meta: { title: 'Settings - System', requiredCapability: 'manage:settings' },
},
{
path: 'image',
name: 'SettingsImage',
component: () => import('@/views/Settings/Image/index.vue'),
meta: { title: 'Settings - Image' },
meta: { title: 'Settings - Image', requiredCapability: 'manage:models' },
},
{
path: 'tts',
name: 'SettingsTts',
component: () => import('@/views/Settings/Tts/index.vue'),
meta: { title: 'Settings - TTS' },
meta: { title: 'Settings - TTS', requiredCapability: 'manage:models' },
},
{
path: 'stt',
name: 'SettingsStt',
component: () => import('@/views/Settings/Stt/index.vue'),
meta: { title: 'Settings - STT' },
meta: { title: 'Settings - STT', requiredCapability: 'manage:models' },
},
{
path: 'music',
name: 'SettingsMusic',
component: () => import('@/views/Settings/Music/index.vue'),
meta: { title: 'Settings - Music' },
meta: { title: 'Settings - Music', requiredCapability: 'manage:models' },
},
{
path: 'video',
name: 'SettingsVideo',
component: () => import('@/views/Settings/Video/index.vue'),
meta: { title: 'Settings - Video' },
meta: { title: 'Settings - Video', requiredCapability: 'manage:models' },
},
{
path: 'model3d',
name: 'SettingsModel3D',
component: () => import('@/views/Settings/Model3D/index.vue'),
meta: { title: 'Settings - 3D Model' },
meta: { title: 'Settings - 3D Model', requiredCapability: 'manage:models' },
},
// Workspace management
{
path: 'workspaces',
name: 'SettingsWorkspaces',
component: () => import('@/views/Security/Workspaces/index.vue'),
meta: { title: 'Settings - Workspaces' },
meta: { title: 'Settings - Workspaces', requiredCapability: 'manage:settings' },
},
{
path: 'members',
name: 'SettingsMembers',
component: () => import('@/views/Security/Members/index.vue'),
meta: { title: 'Settings - Members' },
meta: { title: 'Settings - Members', requiredCapability: 'manage:settings' },
},
// RFC-090 Phase 4: Activity 提升到顶层 /activity下方 children-out
// 的 settings/activity redirect 兼容旧链接,此处不再注册子路由)
@ -162,62 +174,62 @@ const router = createRouter({
path: 'agent-context',
name: 'SettingsAgentContext',
component: () => import('@/views/AgentContext.vue'),
meta: { title: 'Settings - Agent Context' },
meta: { title: 'Settings - Agent Context', requiredCapability: 'manage:agents' },
},
{
path: 'cron-jobs',
name: 'SettingsCronJobs',
component: () => import('@/views/CronJobs.vue'),
meta: { title: 'Settings - Cron Jobs' },
meta: { title: 'Settings - Cron Jobs', requiredCapability: 'manage:agents' },
},
{
path: 'workflows',
name: 'SettingsWorkflows',
component: () => import('@/views/Workflows.vue'),
meta: { title: 'Settings - Workflows' },
meta: { title: 'Settings - Workflows', requiredCapability: 'manage:settings' },
},
{
path: 'triggers',
name: 'SettingsTriggers',
component: () => import('@/views/Triggers.vue'),
meta: { title: 'Settings - Triggers' },
meta: { title: 'Settings - Triggers', requiredCapability: 'manage:settings' },
},
{
path: 'datasources',
name: 'SettingsDatasources',
component: () => import('@/views/Datasources.vue'),
meta: { title: 'Settings - Datasources' },
meta: { title: 'Settings - Datasources', requiredCapability: 'manage:models' },
},
{
path: 'mcp-servers',
name: 'SettingsMcpServers',
component: () => import('@/views/McpServers.vue'),
meta: { title: 'Settings - MCP Connections' },
meta: { title: 'Settings - MCP Connections', requiredCapability: 'manage:settings' },
},
{
path: 'tools',
name: 'SettingsTools',
component: () => import('@/views/Tools.vue'),
meta: { title: 'Settings - Tools Catalog' },
meta: { title: 'Settings - Tools Catalog', requiredCapability: 'manage:settings' },
},
// RFC-090 Phase 7: ACP endpoints (External coding agents)
{
path: 'acp',
name: 'SettingsAcpEndpoints',
component: () => import('@/views/AcpEndpoints.vue'),
meta: { title: 'Settings - ACP Endpoints' },
meta: { title: 'Settings - ACP Endpoints', requiredCapability: 'manage:settings' },
},
{
path: 'token-usage',
name: 'SettingsTokenUsage',
component: () => import('@/views/TokenUsage.vue'),
meta: { title: 'Settings - Token Usage' },
meta: { title: 'Settings - Token Usage', requiredCapability: 'view:dashboard' },
},
{
path: 'feature-flags',
name: 'SettingsFeatureFlags',
component: () => import('@/views/Settings/FeatureFlags/index.vue'),
meta: { title: 'Settings - Feature Flags' },
meta: { title: 'Settings - Feature Flags', requiredCapability: 'manage:settings' },
},
{
path: 'about',
@ -237,22 +249,29 @@ const router = createRouter({
path: 'tool-guard',
name: 'SecurityToolGuard',
component: () => import('@/views/Security/ToolGuard/index.vue'),
meta: { title: 'Security - Tool Guard' },
meta: { title: 'Security - Tool Guard', requiredCapability: 'manage:security' },
},
{
path: 'file-guard',
name: 'SecurityFileGuard',
component: () => import('@/views/Security/FileGuard/index.vue'),
meta: { title: 'Security - File Guard' },
meta: { title: 'Security - File Guard', requiredCapability: 'manage:security' },
},
{
path: 'audit-logs',
name: 'SecurityAuditLogs',
component: () => import('@/views/Security/AuditLogs/index.vue'),
meta: { title: 'Security - Audit Logs' },
meta: { title: 'Security - Audit Logs', requiredCapability: 'manage:security' },
},
],
},
// ==================== Forbidden ====================
{
path: 'forbidden',
name: 'Forbidden',
component: () => import('@/views/Forbidden.vue'),
meta: { title: 'Forbidden' },
},
// ==================== Redirects (backward compatibility) ====================
{ path: 'sessions', redirect: '/chat' },
{ path: 'workspace', redirect: '/settings/agent-context' },
@ -281,21 +300,37 @@ const router = createRouter({
],
})
// 路由守卫:未登录跳转到登录页(开发环境可通过 VITE_SKIP_AUTH=true 跳过)
router.beforeEach((to, _from, next) => {
if (import.meta.env.VITE_SKIP_AUTH === 'true') {
next()
return
}
// Auth + capability guard. Order matters: bail to /login before we touch the
// workspace store, and never let an uninitialized capability set fall through
// to a protected route (the store enforces default-deny while accessLoaded is
// false; we await refreshAccess so the decision is made on real data).
router.beforeEach(async (to) => {
if (import.meta.env.VITE_SKIP_AUTH === 'true') return true
const token = localStorage.getItem('token')
if (to.name === 'Login' && token) {
// Already logged in — skip login page
next({ path: '/' })
} else if (to.name !== 'Login' && !token) {
next({ name: 'Login' })
} else {
next()
if (to.name === 'Login' && token) return { path: '/' }
if (to.name !== 'Login' && !token) return { name: 'Login' }
if (to.name === 'Login' || to.name === 'Forbidden') return true
const store = useWorkspaceStore()
if (!store.accessLoaded) {
if (!store.workspaces.length) {
await store.fetchWorkspaces()
} else {
await store.refreshAccess()
}
}
const requireAdmin = to.meta.requireAdmin === true
if (requireAdmin && !store.isGlobalAdmin) {
return store.can('chat') ? { path: '/chat' } : { path: '/forbidden' }
}
const required = to.meta.requiredCapability
if (required && !store.can(required)) {
return store.can('chat') ? { path: '/chat' } : { path: '/forbidden' }
}
return true
})
export default router

View File

@ -0,0 +1,107 @@
<template>
<div class="forbidden-page">
<div class="forbidden-card">
<div class="forbidden-icon">🚫</div>
<h1 class="forbidden-title">{{ t('forbidden.title') }}</h1>
<p class="forbidden-message">{{ t('forbidden.message') }}</p>
<p v-if="role" class="forbidden-meta">
{{ t('forbidden.currentRole', { role }) }}
</p>
<div class="forbidden-actions">
<button class="btn-primary" @click="goHome">{{ t('forbidden.goChat') }}</button>
<button class="btn-secondary" @click="goBack">{{ t('forbidden.goBack') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
const router = useRouter()
const { t } = useI18n()
const store = useWorkspaceStore()
const role = computed(() => store.currentRole || '')
function goHome() {
router.replace('/chat')
}
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
router.replace('/chat')
}
}
</script>
<style scoped>
.forbidden-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.forbidden-card {
max-width: 480px;
text-align: center;
padding: 3rem 2rem;
background: var(--el-bg-color, #fff);
border: 1px solid var(--el-border-color, #e5e7eb);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.forbidden-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.forbidden-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
color: var(--el-text-color-primary);
}
.forbidden-message {
color: var(--el-text-color-regular);
margin: 0 0 0.5rem;
line-height: 1.6;
}
.forbidden-meta {
color: var(--el-text-color-secondary);
font-size: 0.875rem;
margin: 0 0 1.5rem;
}
.forbidden-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1.5rem;
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.25rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: opacity 0.15s;
}
.btn-primary {
background: var(--el-color-primary, #3b82f6);
color: #fff;
}
.btn-secondary {
background: transparent;
border-color: var(--el-border-color, #e5e7eb);
color: var(--el-text-color-primary);
}
.btn-primary:hover,
.btn-secondary:hover {
opacity: 0.85;
}
</style>

View File

@ -317,93 +317,140 @@ const localeOptions = computed<{ value: AppLocale; label: string; short: string
{ value: 'en-US', label: t('settings.languageOptions.enUS'), short: 'EN' },
])
// Capability-gated nav. Each item declares a capability or globalAdmin flag;
// useWorkspaceStore.can() decides visibility from the backend access set so
// the sidebar can't drift from the route guard or controller annotations.
type NavItem = {
path: string
label: string
icon: string
tooltip?: string
requiredCapability?:
| 'chat'
| 'view:wiki'
| 'view:memory'
| 'view:dashboard'
| 'manage:wiki'
| 'manage:agents'
| 'manage:skills'
| 'manage:channels'
| 'manage:models'
| 'manage:security'
| 'manage:settings'
globalAdmin?: boolean
}
function filterNav(items: NavItem[]): NavItem[] {
// Default deny while access is still loading render an empty group rather
// than flashing the full menu before refreshAccess() returns.
if (!workspaceStore.accessLoaded) return []
return items.filter((item) => {
if (item.globalAdmin) return workspaceStore.isGlobalAdmin
if (item.requiredCapability && !workspaceStore.can(item.requiredCapability as never)) return false
return true
})
}
const navGroups = computed(() => [
{
key: 'core',
label: t('nav.core'),
items: [
items: filterNav([
{
path: '/dashboard',
label: t('nav.dashboard', 'Dashboard'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
requiredCapability: 'view:dashboard',
},
{
path: '/chat',
label: t('nav.chat'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
requiredCapability: 'chat',
},
{
path: '/agents',
label: t('nav.agents'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`,
requiredCapability: 'manage:agents',
},
...(isAdminRole.value ? [{
{
path: '/backstage',
label: t('nav.backstage'),
tooltip: t('nav.backstageTooltip'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`,
}] : []),
globalAdmin: true,
},
{
path: '/wiki',
label: t('nav.wiki'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
requiredCapability: 'view:wiki',
},
{
path: '/memory',
label: t('nav.memory'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M16 14H8a4 4 0 0 0-4 4v2h16v-2a4 4 0 0 0-4-4z"/><line x1="12" y1="11" x2="12" y2="14"/></svg>`,
requiredCapability: 'view:memory',
},
{
path: '/enterprise',
label: t('nav.enterprise'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 9h.01"/><path d="M9 12h.01"/><path d="M9 15h.01"/><path d="M9 18h.01"/><path d="M15 9h.01"/><path d="M15 12h.01"/><path d="M15 15h.01"/><path d="M15 18h.01"/></svg>`,
requiredCapability: 'manage:agents',
},
],
] as NavItem[]),
},
{
key: 'connect',
label: t('nav.connect'),
items: [
items: filterNav([
{
path: '/channels',
label: t('nav.channels'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 1.18h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.73a16 16 0 0 0 6.29 6.29l1.62-1.62a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`,
requiredCapability: 'manage:channels',
},
{
path: '/skills',
label: t('nav.skills'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
requiredCapability: 'manage:skills',
},
{
path: '/plugins',
label: t('nav.plugins'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 3h-8v4h8V3z"/></svg>`,
requiredCapability: 'manage:settings',
},
// RFC-090 Phase 4: Activity
{
path: '/activity',
label: t('nav.activity'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
requiredCapability: 'manage:security',
},
],
] as NavItem[]),
},
{
key: 'system',
label: t('nav.system'),
items: [
items: filterNav([
{
path: '/settings/models',
label: t('nav.settings'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>`,
requiredCapability: 'manage:models',
},
{
path: '/security',
label: t('nav.security'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
requiredCapability: 'manage:security',
},
],
] as NavItem[]),
},
])
].filter((group) => group.items.length > 0))
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value