From a28c90040f54f3813806b01a2bae0ef38faefef0 Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 7 May 2026 18:46:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(resources):=20slice=204=20=E2=80=94=20full?= =?UTF-8?q?=20CRUD=20for=20bindings=20+=20grants=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cc-haha/desktop/src/api/heicodeResources.ts | 87 ++- .../src/components/resources/Modals.tsx | 538 +++++++++++++++ cc-haha/desktop/src/i18n/locales/en.ts | 63 +- cc-haha/desktop/src/i18n/locales/zh.ts | 63 +- .../desktop/src/pages/ResourceBindings.tsx | 641 ++++++++++++++---- cc-haha/desktop/src/stores/resourceStore.ts | 171 ++++- 6 files changed, 1391 insertions(+), 172 deletions(-) create mode 100644 cc-haha/desktop/src/components/resources/Modals.tsx diff --git a/cc-haha/desktop/src/api/heicodeResources.ts b/cc-haha/desktop/src/api/heicodeResources.ts index de052e6..d0f2840 100644 --- a/cc-haha/desktop/src/api/heicodeResources.ts +++ b/cc-haha/desktop/src/api/heicodeResources.ts @@ -75,9 +75,44 @@ export type ApiEnvelope = { message?: string | null } +// ─── Create / Update payloads (mcp-server contract §2.1 / §2.4 / §3.1) ─── + +export type CreateBindingInput = { + type: ResourceType + name: string + external_ref?: string + metadata?: Record + permission_scope?: string[] + constraints?: Record + secret_ref?: string + status?: ResourceStatus +} + +export type UpdateBindingInput = Partial<{ + name: string + external_ref: string + metadata: Record + permission_scope: string[] + constraints: Record + secret_ref: string + status: ResourceStatus +}> + +export type CreateGrantInput = { + resource_id: string + binding_scope: string + role?: string + agent_id?: string | null + allowed_actions?: string[] + constraints?: Record + expires_at?: string | null + status?: GrantStatus +} + const BASE = '/api/heicode-resources' export const heicodeResourcesApi = { + // ── ResourceBindings ────────────────────────────────────────── listBindings(opts?: { type?: ResourceType; status?: ResourceStatus }) { const params = new URLSearchParams() if (opts?.type) params.set('type', opts.type) @@ -92,6 +127,54 @@ export const heicodeResourcesApi = { return api.get>(`${BASE}/${encodeURIComponent(id)}`) }, - // Slice 4 will add createBinding / updateBinding / deleteBinding / - // listGrants / createGrant / revokeGrant. + createBinding(input: CreateBindingInput) { + return api.post>(BASE, input) + }, + + updateBinding(id: string, input: UpdateBindingInput) { + return api.put>( + `${BASE}/${encodeURIComponent(id)}`, + input, + ) + }, + + deleteBinding(id: string) { + return api.delete>( + `${BASE}/${encodeURIComponent(id)}`, + ) + }, + + // ── ResourceGrants ──────────────────────────────────────────── + listGrants(opts?: { + resource_id?: string + role?: string + binding_scope?: string + status?: GrantStatus + }) { + const params = new URLSearchParams() + if (opts?.resource_id) params.set('resource_id', opts.resource_id) + if (opts?.role) params.set('role', opts.role) + if (opts?.binding_scope) params.set('binding_scope', opts.binding_scope) + if (opts?.status) params.set('status', opts.status) + const qs = params.toString() + return api.get>( + `${BASE}/grants${qs ? `?${qs}` : ''}`, + ) + }, + + getGrant(id: string) { + return api.get>( + `${BASE}/grants/${encodeURIComponent(id)}`, + ) + }, + + createGrant(input: CreateGrantInput) { + return api.post>(`${BASE}/grants`, input) + }, + + revokeGrant(id: string) { + return api.delete>( + `${BASE}/grants/${encodeURIComponent(id)}`, + ) + }, } diff --git a/cc-haha/desktop/src/components/resources/Modals.tsx b/cc-haha/desktop/src/components/resources/Modals.tsx new file mode 100644 index 0000000..1fe3dcd --- /dev/null +++ b/cc-haha/desktop/src/components/resources/Modals.tsx @@ -0,0 +1,538 @@ +// desktop/src/components/resources/Modals.tsx +// +// Modal dialogs for the Resources page (Slice 4): +// - ConfirmDialog — generic yes/no confirmation +// - BindingFormModal — create a new ResourceBinding +// - GrantFormModal — create a new ResourceGrant (allowed_actions +// constrained to picked binding's permission_scope) + +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from '../../i18n' +import { + type CreateBindingInput, + type CreateGrantInput, + type ResourceBinding, + type ResourceStatus, + type ResourceType, +} from '../../api/heicodeResources' + +// ─── ConfirmDialog ────────────────────────────────────────────── + +type ConfirmDialogProps = { + open: boolean + title: string + body: string + confirmLabel: string + danger?: boolean + busy?: boolean + errorMessage?: string | null + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmDialog({ + open, + title, + body, + confirmLabel, + danger, + busy, + errorMessage, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const t = useTranslation() + if (!open) return null + return ( + +
+
+

+ {title} +

+
+
+ {body} +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+ + +
+
+
+ ) +} + +// ─── BindingFormModal ────────────────────────────────────────── + +type BindingFormProps = { + open: boolean + busy?: boolean + errorMessage?: string | null + onSubmit: (input: CreateBindingInput) => void + onCancel: () => void +} + +export function BindingFormModal({ + open, + busy, + errorMessage, + onSubmit, + onCancel, +}: BindingFormProps) { + const t = useTranslation() + const [type, setType] = useState('git') + const [name, setName] = useState('') + const [externalRef, setExternalRef] = useState('') + const [permissionScope, setPermissionScope] = useState('') + const [secretRef, setSecretRef] = useState('') + const [status, setStatus] = useState('pending') + + // reset when reopened + useEffect(() => { + if (open) { + setType('git') + setName('') + setExternalRef('') + setPermissionScope('') + setSecretRef('') + setStatus('pending') + } + }, [open]) + + if (!open) return null + + const canSubmit = name.trim().length > 0 && !busy + + const handleSubmit = () => { + const scope = permissionScope + .split(/[\n,]/) + .map((s) => s.trim()) + .filter(Boolean) + const input: CreateBindingInput = { + type, + name: name.trim(), + ...(externalRef.trim() && { external_ref: externalRef.trim() }), + ...(scope.length > 0 && { permission_scope: scope }), + ...(secretRef.trim() && { secret_ref: secretRef.trim() }), + status, + } + onSubmit(input) + } + + return ( + +
+
+

+ {t('resources.modal.create.title')} +

+

+ {t('resources.modal.create.subtitle')} +

+
+ +
+ + + + + + setName(e.target.value)} + disabled={busy} + placeholder="my-repo" + maxLength={255} + className={inputClass} + /> + + + + setExternalRef(e.target.value)} + disabled={busy} + placeholder="https://example.com/org/repo.git" + className={inputClass} + /> + + + +