From aa27853712f31f25aaff5a8ded47e0e5bd4c3f92 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 15 May 2026 10:18:30 +0800 Subject: [PATCH] feat(ui,workspace): consume backend access endpoint for capability state --- mateclaw-ui/src/api/index.ts | 1 + mateclaw-ui/src/composables/capabilities.ts | 31 +++++++++ mateclaw-ui/src/stores/useWorkspaceStore.ts | 72 ++++++++++++++++++++- 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 mateclaw-ui/src/composables/capabilities.ts diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 74469073..22c098a3 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -767,6 +767,7 @@ export const wikiApi = { export const workspaceTeamApi = { list: () => http.get('/workspaces'), get: (id: string | number) => http.get(`/workspaces/${id}`), + getAccess: (id: string | number) => http.get(`/workspaces/${id}/access`), create: (data: any) => http.post('/workspaces', data), update: (id: string | number, data: any) => http.put(`/workspaces/${id}`, data), delete: (id: string | number) => http.delete(`/workspaces/${id}`), diff --git a/mateclaw-ui/src/composables/capabilities.ts b/mateclaw-ui/src/composables/capabilities.ts new file mode 100644 index 00000000..152fe3e5 --- /dev/null +++ b/mateclaw-ui/src/composables/capabilities.ts @@ -0,0 +1,31 @@ +/** + * Capability type definitions consumed by route guards and nav filtering. + * + * Authoritative role -> capability mapping lives on the backend + * (`vip.mate.workspace.core.security.RoleCapabilities`). The frontend never + * derives the set locally; it only consumes what `/api/v1/workspaces/{id}/access` + * returns. Keeping the mapping single-source avoids the "frontend allows but + * backend rejects" drift that v1 reviewers flagged. + */ + +export type Capability = + | 'chat' + | 'view:wiki' + | 'view:memory' + | 'view:dashboard' + | 'manage:wiki' + | 'manage:agents' + | 'manage:skills' + | 'manage:channels' + | 'manage:models' + | 'manage:security' + | 'manage:settings' + +export type WorkspaceRole = 'viewer' | 'member' | 'admin' | 'owner' + +export const ROLE_LEVEL: Record = { + viewer: 1, + member: 2, + admin: 3, + owner: 4, +} diff --git a/mateclaw-ui/src/stores/useWorkspaceStore.ts b/mateclaw-ui/src/stores/useWorkspaceStore.ts index 7f7f53ed..01935f4b 100644 --- a/mateclaw-ui/src/stores/useWorkspaceStore.ts +++ b/mateclaw-ui/src/stores/useWorkspaceStore.ts @@ -1,6 +1,8 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { workspaceTeamApi } from '@/api/index' +import type { Capability, WorkspaceRole } from '@/composables/capabilities' +import { ROLE_LEVEL } from '@/composables/capabilities' export interface Workspace { id: number @@ -12,6 +14,12 @@ export interface Workspace { settingsJson?: string createTime?: string updateTime?: string + /** Real membership role; null for global admins viewing a workspace they have not joined. */ + memberRole?: WorkspaceRole | null + roleLevel?: number + isGlobalAdmin?: boolean + /** Equals memberRole for normal users; 'owner' for global admins. */ + effectiveRole?: WorkspaceRole | null } export const useWorkspaceStore = defineStore('workspace', () => { @@ -21,24 +29,72 @@ export const useWorkspaceStore = defineStore('workspace', () => { ) const loading = ref(false) + // RBAC: capabilities for the current workspace, sourced from the backend. + // accessLoaded gates router guards so we never render a protected route on a + // half-initialized store. + const currentCapabilities = ref>(new Set()) + const accessLoaded = ref(false) + let accessInFlight: Promise | null = null + const currentWorkspace = computed(() => workspaces.value.find((ws) => ws.id === currentWorkspaceId.value) || workspaces.value[0] || null ) + const currentRole = computed(() => { + const ws = currentWorkspace.value + return (ws?.effectiveRole as WorkspaceRole) || null + }) + + const isGlobalAdmin = computed(() => Boolean(currentWorkspace.value?.isGlobalAdmin)) + + function can(cap: Capability): boolean { + return accessLoaded.value && currentCapabilities.value.has(cap) + } + + function isAtLeast(role: WorkspaceRole): boolean { + if (!currentRole.value) return false + return ROLE_LEVEL[currentRole.value] >= ROLE_LEVEL[role] + } + + async function refreshAccess() { + const id = currentWorkspaceId.value + if (id == null) { + currentCapabilities.value = new Set() + accessLoaded.value = true + return + } + if (accessInFlight) return accessInFlight + accessInFlight = (async () => { + try { + const res: any = await workspaceTeamApi.getAccess(id) + const caps: string[] = res?.data?.capabilities || [] + currentCapabilities.value = new Set(caps as Capability[]) + } catch (e) { + console.warn('Failed to fetch workspace access:', e) + currentCapabilities.value = new Set() + } finally { + accessLoaded.value = true + accessInFlight = null + } + })() + return accessInFlight + } + async function fetchWorkspaces() { loading.value = true try { const res: any = await workspaceTeamApi.list() workspaces.value = res.data || [] - // If no workspace selected or selected workspace not in list, default to first if ( !currentWorkspaceId.value || !workspaces.value.find((ws) => ws.id === currentWorkspaceId.value) ) { if (workspaces.value.length > 0) { - switchWorkspace(workspaces.value[0].id) + await switchWorkspace(workspaces.value[0].id) + return } } + await refreshAccess() } catch (e) { console.warn('Failed to fetch workspaces:', e) } finally { @@ -46,17 +102,27 @@ export const useWorkspaceStore = defineStore('workspace', () => { } } - function switchWorkspace(id: number) { + async function switchWorkspace(id: number) { currentWorkspaceId.value = id localStorage.setItem('mc-workspace-id', String(id)) + accessLoaded.value = false + currentCapabilities.value = new Set() + await refreshAccess() } return { workspaces, currentWorkspaceId, currentWorkspace, + currentRole, + isGlobalAdmin, + currentCapabilities, + accessLoaded, loading, + can, + isAtLeast, fetchWorkspaces, switchWorkspace, + refreshAccess, } })