Files
heicode/cc-haha/desktop/src/stores/resourceStore.ts
T
chenchenandClaude Opus 4.7 a28c90040f feat(resources): slice 4 — full CRUD for bindings + grants UI
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>
2026-05-07 18:46:30 +08:00

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