What ships:
Bindings tab:
- Create binding modal (type / name / external_ref /
permission_scope / secret_ref / status). Permission scope is a
newline-or-comma textarea that splits into a string[].
- Soft-delete (status = revoked) with red confirm dialog.
Grants tab:
- List with binding name + role + allowed actions + scope + status
+ expires.
- Create grant modal: pick binding → checkbox-select allowed
actions from THAT binding's permission_scope (auto-cleared when
binding changes), set binding_scope / role / expires_at.
- Revoke with red confirm dialog.
Shared:
- Tab switcher with active-state underline + count badge.
- Refresh button per tab (independent fetch state).
- Error banners with dismiss; mutation errors surface in modals.
- i18n: ~30 new keys per locale (zh + en).
Backend (no change):
Slice 3's /api/heicode-resources/* proxy already handles POST /
PUT / DELETE because it forwards verb + body verbatim.
The Authorization-header refresh logic (60s buffer) automatically
keeps mutations working across the 24h JWT boundary.
mcp-server safety nets the user can rely on (already enforced):
- 422 RESOURCE_GRANT_SECRET_REJECTED if metadata/constraints/scope
contains plaintext credential keys
- 400 RESOURCE_GRANT_INVALID if grant.allowed_actions ⊄ binding.scope
- 403 FORBIDDEN_SCOPE on cross-user binding/grant access
Cosmetic notes:
- GrantFormModal hooks were reordered to satisfy React's "hooks
before any early return" rule.
- useEffect that prunes allowed_actions when the picked binding
changes uses an internal `changed` flag to avoid a setState loop.
Slice 5 candidates (not in this commit):
- Edit binding (PUT)
- metadata + constraints power-user JSON editor
- Grant suspend/unsuspend
- inline filtering (type / status)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
5.2 KiB
TypeScript
179 lines
5.2 KiB
TypeScript
// desktop/src/stores/resourceStore.ts
|
|
//
|
|
// Zustand store for ResourceBinding / ResourceGrant. Talks to the local
|
|
// /api/heicode-resources/* proxy.
|
|
|
|
import { create } from 'zustand'
|
|
import {
|
|
heicodeResourcesApi,
|
|
type CreateBindingInput,
|
|
type CreateGrantInput,
|
|
type ResourceBinding,
|
|
type ResourceGrant,
|
|
} from '../api/heicodeResources'
|
|
|
|
type ResourceState = {
|
|
// Bindings
|
|
bindings: ResourceBinding[]
|
|
bindingsLoading: boolean
|
|
bindingsFetched: boolean
|
|
bindingsError: string | null
|
|
|
|
// Grants
|
|
grants: ResourceGrant[]
|
|
grantsLoading: boolean
|
|
grantsFetched: boolean
|
|
grantsError: string | null
|
|
|
|
// Mutation state (shared — only one create/delete in flight at a time)
|
|
isMutating: boolean
|
|
mutationError: string | null
|
|
|
|
// Actions
|
|
fetchBindings: () => Promise<void>
|
|
fetchGrants: () => Promise<void>
|
|
createBinding: (input: CreateBindingInput) => Promise<ResourceBinding | null>
|
|
deleteBinding: (id: string) => Promise<boolean>
|
|
createGrant: (input: CreateGrantInput) => Promise<ResourceGrant | null>
|
|
revokeGrant: (id: string) => Promise<boolean>
|
|
clearBindingsError: () => void
|
|
clearGrantsError: () => void
|
|
clearMutationError: () => void
|
|
}
|
|
|
|
function extractErrorMessage(err: unknown, fallback: string): string {
|
|
if (err instanceof Error) return err.message
|
|
return typeof err === 'string' ? err : fallback
|
|
}
|
|
|
|
export const useResourceStore = create<ResourceState>((set, get) => ({
|
|
bindings: [],
|
|
bindingsLoading: false,
|
|
bindingsFetched: false,
|
|
bindingsError: null,
|
|
|
|
grants: [],
|
|
grantsLoading: false,
|
|
grantsFetched: false,
|
|
grantsError: null,
|
|
|
|
isMutating: false,
|
|
mutationError: null,
|
|
|
|
fetchBindings: async () => {
|
|
set({ bindingsLoading: true, bindingsError: null })
|
|
try {
|
|
const res = await heicodeResourcesApi.listBindings()
|
|
set({
|
|
bindings: res.success ? (res.data?.items ?? []) : [],
|
|
bindingsLoading: false,
|
|
bindingsFetched: true,
|
|
bindingsError: res.success ? null : (res.message || '加载资源绑定失败'),
|
|
})
|
|
} catch (err) {
|
|
set({
|
|
bindingsLoading: false,
|
|
bindingsFetched: true,
|
|
bindingsError: extractErrorMessage(err, '加载资源绑定失败'),
|
|
})
|
|
}
|
|
},
|
|
|
|
fetchGrants: async () => {
|
|
set({ grantsLoading: true, grantsError: null })
|
|
try {
|
|
const res = await heicodeResourcesApi.listGrants()
|
|
set({
|
|
grants: res.success ? (res.data?.items ?? []) : [],
|
|
grantsLoading: false,
|
|
grantsFetched: true,
|
|
grantsError: res.success ? null : (res.message || '加载资源授权失败'),
|
|
})
|
|
} catch (err) {
|
|
set({
|
|
grantsLoading: false,
|
|
grantsFetched: true,
|
|
grantsError: extractErrorMessage(err, '加载资源授权失败'),
|
|
})
|
|
}
|
|
},
|
|
|
|
createBinding: async (input) => {
|
|
set({ isMutating: true, mutationError: null })
|
|
try {
|
|
const res = await heicodeResourcesApi.createBinding(input)
|
|
if (!res.success || !res.data) {
|
|
set({ isMutating: false, mutationError: res.message || '创建资源绑定失败' })
|
|
return null
|
|
}
|
|
// Optimistic add (server is source of truth, but UX wins).
|
|
set((s) => ({
|
|
isMutating: false,
|
|
bindings: [res.data!, ...s.bindings],
|
|
}))
|
|
return res.data
|
|
} catch (err) {
|
|
set({ isMutating: false, mutationError: extractErrorMessage(err, '创建资源绑定失败') })
|
|
return null
|
|
}
|
|
},
|
|
|
|
deleteBinding: async (id) => {
|
|
set({ isMutating: true, mutationError: null })
|
|
try {
|
|
const res = await heicodeResourcesApi.deleteBinding(id)
|
|
if (!res.success) {
|
|
set({ isMutating: false, mutationError: res.message || '删除失败' })
|
|
return false
|
|
}
|
|
// Server soft-deletes (status=revoked). Refetch is safest.
|
|
await get().fetchBindings()
|
|
set({ isMutating: false })
|
|
return true
|
|
} catch (err) {
|
|
set({ isMutating: false, mutationError: extractErrorMessage(err, '删除失败') })
|
|
return false
|
|
}
|
|
},
|
|
|
|
createGrant: async (input) => {
|
|
set({ isMutating: true, mutationError: null })
|
|
try {
|
|
const res = await heicodeResourcesApi.createGrant(input)
|
|
if (!res.success || !res.data) {
|
|
set({ isMutating: false, mutationError: res.message || '创建授权失败' })
|
|
return null
|
|
}
|
|
set((s) => ({
|
|
isMutating: false,
|
|
grants: [res.data!, ...s.grants],
|
|
}))
|
|
return res.data
|
|
} catch (err) {
|
|
set({ isMutating: false, mutationError: extractErrorMessage(err, '创建授权失败') })
|
|
return null
|
|
}
|
|
},
|
|
|
|
revokeGrant: async (id) => {
|
|
set({ isMutating: true, mutationError: null })
|
|
try {
|
|
const res = await heicodeResourcesApi.revokeGrant(id)
|
|
if (!res.success) {
|
|
set({ isMutating: false, mutationError: res.message || '撤销失败' })
|
|
return false
|
|
}
|
|
await get().fetchGrants()
|
|
set({ isMutating: false })
|
|
return true
|
|
} catch (err) {
|
|
set({ isMutating: false, mutationError: extractErrorMessage(err, '撤销失败') })
|
|
return false
|
|
}
|
|
},
|
|
|
|
clearBindingsError: () => set({ bindingsError: null }),
|
|
clearGrantsError: () => set({ grantsError: null }),
|
|
clearMutationError: () => set({ mutationError: null }),
|
|
}))
|