From 777e2ecee2453dc9a886c842d483bd32dd57c76d Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 15 May 2026 10:18:38 +0800 Subject: [PATCH] feat(ui,router): gate routes and sidebar by workspace capability --- mateclaw-ui/src/composables/useNavItems.ts | 37 ++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 7 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 7 ++ mateclaw-ui/src/router/index.ts | 129 +++++++++++++------- mateclaw-ui/src/views/Forbidden.vue | 107 ++++++++++++++++ mateclaw-ui/src/views/layout/MainLayout.vue | 65 ++++++++-- 6 files changed, 296 insertions(+), 56 deletions(-) create mode 100644 mateclaw-ui/src/composables/useNavItems.ts create mode 100644 mateclaw-ui/src/views/Forbidden.vue diff --git a/mateclaw-ui/src/composables/useNavItems.ts b/mateclaw-ui/src/composables/useNavItems.ts new file mode 100644 index 00000000..7b46bc19 --- /dev/null +++ b/mateclaw-ui/src/composables/useNavItems.ts @@ -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 + }) + }) +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index b20604e9..3d8f1d7a 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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 diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 7a3fabad..43b3776e 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -3474,4 +3474,11 @@ export default { listAvailableSkills: '列出可用技能', readSkillFile: '读取技能文件', }, + forbidden: { + title: '无权访问', + message: '当前工作区中你的角色无法访问该页面。', + currentRole: '当前角色:{role}', + goChat: '返回对话', + goBack: '返回上一页', + }, } as const diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 4fb32f22..e2061a85 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -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 diff --git a/mateclaw-ui/src/views/Forbidden.vue b/mateclaw-ui/src/views/Forbidden.vue new file mode 100644 index 00000000..82249674 --- /dev/null +++ b/mateclaw-ui/src/views/Forbidden.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue index 8a108f16..49175a07 100644 --- a/mateclaw-ui/src/views/layout/MainLayout.vue +++ b/mateclaw-ui/src/views/layout/MainLayout.vue @@ -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: ``, + requiredCapability: 'view:dashboard', }, { path: '/chat', label: t('nav.chat'), icon: ``, + requiredCapability: 'chat', }, { path: '/agents', label: t('nav.agents'), icon: ``, + requiredCapability: 'manage:agents', }, - ...(isAdminRole.value ? [{ + { path: '/backstage', label: t('nav.backstage'), tooltip: t('nav.backstageTooltip'), icon: ``, - }] : []), + globalAdmin: true, + }, { path: '/wiki', label: t('nav.wiki'), icon: ``, + requiredCapability: 'view:wiki', }, { path: '/memory', label: t('nav.memory'), icon: ``, + requiredCapability: 'view:memory', }, { path: '/enterprise', label: t('nav.enterprise'), icon: ``, + requiredCapability: 'manage:agents', }, - ], + ] as NavItem[]), }, { key: 'connect', label: t('nav.connect'), - items: [ + items: filterNav([ { path: '/channels', label: t('nav.channels'), icon: ``, + requiredCapability: 'manage:channels', }, { path: '/skills', label: t('nav.skills'), icon: ``, + requiredCapability: 'manage:skills', }, { path: '/plugins', label: t('nav.plugins'), icon: ``, + requiredCapability: 'manage:settings', }, // RFC-090 Phase 4: Activity 提升到顶层 { path: '/activity', label: t('nav.activity'), icon: ``, + requiredCapability: 'manage:security', }, - ], + ] as NavItem[]), }, { key: 'system', label: t('nav.system'), - items: [ + items: filterNav([ { path: '/settings/models', label: t('nav.settings'), icon: ``, + requiredCapability: 'manage:models', }, { path: '/security', label: t('nav.security'), icon: ``, + requiredCapability: 'manage:security', }, - ], + ] as NavItem[]), }, -]) +].filter((group) => group.items.length > 0)) function toggleSidebar() { sidebarCollapsed.value = !sidebarCollapsed.value