feat(ui,workspace): consume backend access endpoint for capability state

This commit is contained in:
matevip 2026-05-15 10:18:30 +08:00
parent 9fc2e5f04f
commit aa27853712
3 changed files with 101 additions and 3 deletions

View File

@ -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}`),

View File

@ -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<WorkspaceRole, number> = {
viewer: 1,
member: 2,
admin: 3,
owner: 4,
}

View File

@ -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<Set<Capability>>(new Set())
const accessLoaded = ref(false)
let accessInFlight: Promise<void> | null = null
const currentWorkspace = computed(() =>
workspaces.value.find((ws) => ws.id === currentWorkspaceId.value) || workspaces.value[0] || null
)
const currentRole = computed<WorkspaceRole | null>(() => {
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,
}
})