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>
This commit is contained in:
2026-05-07 18:46:30 +08:00
co-authored by Claude Opus 4.7
parent c0363be850
commit a28c90040f
6 changed files with 1391 additions and 172 deletions
+85 -2
View File
@@ -75,9 +75,44 @@ export type ApiEnvelope<T> = {
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<string, unknown>
permission_scope?: string[]
constraints?: Record<string, unknown>
secret_ref?: string
status?: ResourceStatus
}
export type UpdateBindingInput = Partial<{
name: string
external_ref: string
metadata: Record<string, unknown>
permission_scope: string[]
constraints: Record<string, unknown>
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<string, unknown>
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<ApiEnvelope<ResourceBinding>>(`${BASE}/${encodeURIComponent(id)}`)
},
// Slice 4 will add createBinding / updateBinding / deleteBinding /
// listGrants / createGrant / revokeGrant.
createBinding(input: CreateBindingInput) {
return api.post<ApiEnvelope<ResourceBinding>>(BASE, input)
},
updateBinding(id: string, input: UpdateBindingInput) {
return api.put<ApiEnvelope<ResourceBinding>>(
`${BASE}/${encodeURIComponent(id)}`,
input,
)
},
deleteBinding(id: string) {
return api.delete<ApiEnvelope<{ id: string; status: ResourceStatus }>>(
`${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<ApiEnvelope<ResourceGrantsList>>(
`${BASE}/grants${qs ? `?${qs}` : ''}`,
)
},
getGrant(id: string) {
return api.get<ApiEnvelope<ResourceGrant>>(
`${BASE}/grants/${encodeURIComponent(id)}`,
)
},
createGrant(input: CreateGrantInput) {
return api.post<ApiEnvelope<ResourceGrant>>(`${BASE}/grants`, input)
},
revokeGrant(id: string) {
return api.delete<ApiEnvelope<ResourceGrant>>(
`${BASE}/grants/${encodeURIComponent(id)}`,
)
},
}
@@ -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 (
<ModalScrim onCancel={onCancel}>
<div className="w-full max-w-md rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] shadow-[var(--shadow-dropdown)]">
<div className="border-b border-[var(--color-border-separator)] px-6 py-4">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
{title}
</h2>
</div>
<div className="px-6 py-5 text-sm leading-relaxed text-[var(--color-text-secondary)]">
{body}
</div>
{errorMessage ? (
<div className="mx-6 mb-4 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-3 py-2 text-xs text-[var(--color-error)]">
{errorMessage}
</div>
) : null}
<div className="flex items-center justify-end gap-2 border-t border-[var(--color-border-separator)] px-6 py-4">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-sm text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={onConfirm}
disabled={busy}
className={
danger
? 'rounded-[var(--radius-md)] border border-[var(--color-error)]/40 bg-[var(--color-error)] px-3 py-1.5 text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50'
: 'rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-[var(--color-on-primary)] transition-colors hover:bg-[var(--color-primary-fixed-dim)] disabled:opacity-50'
}
>
{busy ? t('common.processing') : confirmLabel}
</button>
</div>
</div>
</ModalScrim>
)
}
// ─── 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<ResourceType>('git')
const [name, setName] = useState('')
const [externalRef, setExternalRef] = useState('')
const [permissionScope, setPermissionScope] = useState('')
const [secretRef, setSecretRef] = useState('')
const [status, setStatus] = useState<ResourceStatus>('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 (
<ModalScrim onCancel={onCancel}>
<div className="w-full max-w-xl rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] shadow-[var(--shadow-dropdown)]">
<div className="border-b border-[var(--color-border-separator)] px-6 py-4">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
{t('resources.modal.create.title')}
</h2>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{t('resources.modal.create.subtitle')}
</p>
</div>
<div className="grid gap-4 px-6 py-5">
<Field label={t('resources.field.type')}>
<select
value={type}
onChange={(e) => setType(e.target.value as ResourceType)}
disabled={busy}
className={inputClass}
>
<option value="git">{t('resources.type.git')}</option>
<option value="sk">{t('resources.type.sk')}</option>
<option value="project_doc">{t('resources.type.project_doc')}</option>
<option value="cloud_account">{t('resources.type.cloud_account')}</option>
<option value="cloud_resource">{t('resources.type.cloud_resource')}</option>
</select>
</Field>
<Field label={t('resources.field.name')} required>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
placeholder="my-repo"
maxLength={255}
className={inputClass}
/>
</Field>
<Field label={t('resources.field.externalRef')}>
<input
type="text"
value={externalRef}
onChange={(e) => setExternalRef(e.target.value)}
disabled={busy}
placeholder="https://example.com/org/repo.git"
className={inputClass}
/>
</Field>
<Field
label={t('resources.field.permissionScope')}
hint={t('resources.field.permissionScope.hint')}
>
<textarea
value={permissionScope}
onChange={(e) => setPermissionScope(e.target.value)}
disabled={busy}
rows={3}
placeholder="repo:read&#10;repo:write:current-branch"
className={`${inputClass} resize-none font-mono text-xs`}
/>
</Field>
<Field
label={t('resources.field.secretRef')}
hint={t('resources.field.secretRef.hint')}
>
<input
type="text"
value={secretRef}
onChange={(e) => setSecretRef(e.target.value)}
disabled={busy}
placeholder="vault://secret/users/{user_id}/bindings/my-repo"
maxLength={500}
className={inputClass}
/>
</Field>
<Field label={t('resources.field.status')}>
<select
value={status}
onChange={(e) => setStatus(e.target.value as ResourceStatus)}
disabled={busy}
className={inputClass}
>
<option value="pending">pending</option>
<option value="active">active</option>
<option value="disabled">disabled</option>
</select>
</Field>
</div>
{errorMessage ? (
<div className="mx-6 mb-4 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-3 py-2 text-xs text-[var(--color-error)]">
{errorMessage}
</div>
) : null}
<div className="flex items-center justify-end gap-2 border-t border-[var(--color-border-separator)] px-6 py-4">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-[var(--color-on-primary)] hover:bg-[var(--color-primary-fixed-dim)] disabled:cursor-not-allowed disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)]"
>
{busy ? t('common.processing') : t('resources.modal.create.submit')}
</button>
</div>
</div>
</ModalScrim>
)
}
// ─── GrantFormModal ────────────────────────────────────────────
type GrantFormProps = {
open: boolean
bindings: ResourceBinding[]
busy?: boolean
errorMessage?: string | null
onSubmit: (input: CreateGrantInput) => void
onCancel: () => void
}
export function GrantFormModal({
open,
bindings,
busy,
errorMessage,
onSubmit,
onCancel,
}: GrantFormProps) {
const t = useTranslation()
const activeBindings = useMemo(
() => bindings.filter((b) => b.status !== 'revoked'),
[bindings],
)
const [resourceId, setResourceId] = useState('')
const [bindingScope, setBindingScope] = useState('main')
const [role, setRole] = useState('backend')
const [allowedActions, setAllowedActions] = useState<Set<string>>(new Set())
const [expiresAt, setExpiresAt] = useState('')
const selectedBinding = bindings.find((b) => b.id === resourceId)
const availableActions = selectedBinding?.permission_scope ?? []
useEffect(() => {
if (open) {
const first = activeBindings[0]
setResourceId(first?.id ?? '')
setBindingScope('main')
setRole('backend')
setAllowedActions(new Set())
setExpiresAt('')
}
}, [open, activeBindings])
// Auto-clean allowedActions when the selected binding changes — anything
// that is no longer in availableActions gets dropped.
useEffect(() => {
setAllowedActions((prev) => {
let changed = false
const next = new Set<string>()
for (const a of prev) {
if (availableActions.includes(a)) next.add(a)
else changed = true
}
return changed ? next : prev
})
}, [resourceId, availableActions])
if (!open) return null
const canSubmit =
!!resourceId && bindingScope.trim().length > 0 && !busy
const toggleAction = (action: string) => {
setAllowedActions((prev) => {
const next = new Set(prev)
if (next.has(action)) next.delete(action)
else next.add(action)
return next
})
}
const handleSubmit = () => {
const input: CreateGrantInput = {
resource_id: resourceId,
binding_scope: bindingScope.trim(),
role: role.trim() || undefined,
allowed_actions: [...allowedActions],
...(expiresAt && { expires_at: new Date(expiresAt).toISOString() }),
status: 'active',
}
onSubmit(input)
}
return (
<ModalScrim onCancel={onCancel}>
<div className="w-full max-w-xl rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] shadow-[var(--shadow-dropdown)]">
<div className="border-b border-[var(--color-border-separator)] px-6 py-4">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
{t('resources.modal.grant.title')}
</h2>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{t('resources.modal.grant.subtitle')}
</p>
</div>
<div className="grid gap-4 px-6 py-5">
<Field label={t('resources.field.binding')} required>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
disabled={busy || activeBindings.length === 0}
className={inputClass}
>
{activeBindings.length === 0 ? (
<option value="">{t('resources.modal.grant.noBindings')}</option>
) : (
activeBindings.map((b) => (
<option key={b.id} value={b.id}>
{b.name} · {b.type}
</option>
))
)}
</select>
</Field>
<Field
label={t('resources.field.allowedActions')}
hint={t('resources.field.allowedActions.hint')}
>
{availableActions.length === 0 ? (
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-tertiary)]">
{t('resources.field.allowedActions.empty')}
</div>
) : (
<div className="flex flex-wrap gap-2">
{availableActions.map((a) => {
const checked = allowedActions.has(a)
return (
<label
key={a}
className={`flex cursor-pointer items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors ${
checked
? 'border-[var(--color-primary)]/40 bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))]'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleAction(a)}
disabled={busy}
className="hidden"
/>
<code className="font-mono">{a}</code>
</label>
)
})}
</div>
)}
</Field>
<Field label={t('resources.field.bindingScope')} required>
<input
type="text"
value={bindingScope}
onChange={(e) => setBindingScope(e.target.value)}
disabled={busy}
placeholder="main"
maxLength={255}
className={inputClass}
/>
</Field>
<Field label={t('resources.field.role')}>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
disabled={busy}
className={inputClass}
>
<option value="product">product</option>
<option value="frontend">frontend</option>
<option value="backend">backend</option>
<option value="reviewer">reviewer</option>
<option value="ops">ops</option>
</select>
</Field>
<Field
label={t('resources.field.expiresAt')}
hint={t('resources.field.expiresAt.hint')}
>
<input
type="datetime-local"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
disabled={busy}
className={inputClass}
/>
</Field>
</div>
{errorMessage ? (
<div className="mx-6 mb-4 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-3 py-2 text-xs text-[var(--color-error)]">
{errorMessage}
</div>
) : null}
<div className="flex items-center justify-end gap-2 border-t border-[var(--color-border-separator)] px-6 py-4">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-[var(--color-on-primary)] hover:bg-[var(--color-primary-fixed-dim)] disabled:cursor-not-allowed disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)]"
>
{busy ? t('common.processing') : t('resources.modal.grant.submit')}
</button>
</div>
</div>
</ModalScrim>
)
}
// ─── Shared bits ──────────────────────────────────────────────
const inputClass =
'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none transition-colors focus:border-[var(--color-primary)] focus:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-60'
function Field({
label,
hint,
required,
children,
}: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
{label}
{required ? <span className="ml-1 text-[var(--color-primary)]">*</span> : null}
</span>
{children}
{hint ? (
<span className="text-[10px] text-[var(--color-text-tertiary)]">{hint}</span>
) : null}
</label>
)
}
function ModalScrim({
onCancel,
children,
}: {
onCancel: () => void
children: React.ReactNode
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--color-overlay-scrim)] px-4 py-8"
onClick={onCancel}
>
<div onClick={(e) => e.stopPropagation()} className="max-h-full overflow-auto">
{children}
</div>
</div>
)
}
+62 -1
View File
@@ -11,6 +11,7 @@ export const en = {
'common.retry': 'Retry',
'common.loading': 'Loading...',
'common.dismiss': 'Dismiss',
'common.processing': 'Processing…',
'common.select': 'Select',
'common.enable': 'Enable',
'common.disable': 'Disable',
@@ -971,7 +972,67 @@ export const en = {
'resources.col.externalRef': 'External ref',
'resources.col.status': 'Status',
'resources.col.scope': 'Permission scope',
'resources.footer.cruComingSoon': 'This version only lists existing bindings. Create / edit / delete / grants UI ships in the next slice.',
'resources.col.actions': 'Actions',
// tabs / actions
'resources.tab.bindings': 'Bindings',
'resources.tab.grants': 'Grants',
'resources.action.createBinding': 'New binding',
'resources.action.createGrant': 'New grant',
'resources.action.delete': 'Delete',
'resources.action.revoke': 'Revoke',
'resources.action.deleteBinding': 'Delete binding',
'resources.action.revokeGrant': 'Revoke grant',
// create binding modal
'resources.modal.create.title': 'New resource binding',
'resources.modal.create.subtitle': 'Once created, you can grant it to a role / sub-agent from the Grants tab.',
'resources.modal.create.submit': 'Create',
'resources.field.type': 'Type',
'resources.field.name': 'Name',
'resources.field.externalRef': 'External ref',
'resources.field.permissionScope': 'Permission scope',
'resources.field.permissionScope.hint': 'One per line or comma-separated, e.g. repo:read, repo:write:current-branch, cloud:list, vm:ssh:approved-window.',
'resources.field.secretRef': 'Secret ref',
'resources.field.secretRef.hint': 'Reference only (e.g. vault://...), never the actual secret. The server rejects plaintext credentials.',
'resources.field.status': 'Status',
// resource type labels
'resources.type.git': 'Git repo',
'resources.type.sk': 'SK',
'resources.type.project_doc': 'Project doc',
'resources.type.cloud_account': 'Cloud account',
'resources.type.cloud_resource': 'Cloud resource',
// create grant modal
'resources.modal.grant.title': 'New grant',
'resources.modal.grant.subtitle': 'Grant an existing binding to a role or sub-agent. Allowed actions must be a subset of the binding\'s permission scope.',
'resources.modal.grant.submit': 'Grant',
'resources.modal.grant.noBindings': 'No grantable bindings yet',
'resources.field.binding': 'Binding',
'resources.field.allowedActions': 'Allowed actions',
'resources.field.allowedActions.hint': 'Select a subset of the binding\'s permission scope; leaving empty grants metadata-read only.',
'resources.field.allowedActions.empty': 'This binding has no permission scope defined, so no action can be granted. Edit the binding to add scopes first.',
'resources.field.bindingScope': 'Binding scope',
'resources.field.role': 'Role',
'resources.field.expiresAt': 'Expires at',
'resources.field.expiresAt.hint': 'Leave empty for long-lived; recommended for high-risk resources.',
// grants table
'resources.grants.col.binding': 'Binding',
'resources.grants.col.role': 'Role',
'resources.grants.col.actions': 'Allowed actions',
'resources.grants.col.scope': 'Scope',
'resources.grants.col.status': 'Status',
'resources.grants.col.expires': 'Expires',
'resources.grants.empty.title': 'No grants yet',
'resources.grants.empty.body': 'Grant a binding from the Bindings tab to a role and sub-agents will be able to use it once deployed.',
// confirm dialogs
'resources.confirm.delete.title': 'Delete resource binding?',
'resources.confirm.delete.body': 'Binding "{name}" will be marked as revoked (soft-delete). Existing grants on this binding are NOT auto-revoked — handle them manually.',
'resources.confirm.revoke.title': 'Revoke grant?',
'resources.confirm.revoke.body': 'After revoke, the role / sub-agent can no longer use the resource. This action cannot be undone.',
// ─── HeiCode Login ──────────────────────────────────────
'login.subtitle': 'Sign in to start using Heicode',
+62 -1
View File
@@ -13,6 +13,7 @@ export const zh: Record<TranslationKey, string> = {
'common.retry': '重试',
'common.loading': '加载中...',
'common.dismiss': '关闭',
'common.processing': '处理中…',
'common.select': '选择',
'common.enable': '启用',
'common.disable': '禁用',
@@ -973,7 +974,67 @@ export const zh: Record<TranslationKey, string> = {
'resources.col.externalRef': '外部引用',
'resources.col.status': '状态',
'resources.col.scope': '权限范围',
'resources.footer.cruComingSoon': '当前版本仅展示绑定列表。新建 / 编辑 / 删除 / 授权管理将在下一版本提供。',
'resources.col.actions': '操作',
// tabs / actions
'resources.tab.bindings': '资源绑定',
'resources.tab.grants': '授权',
'resources.action.createBinding': '新建绑定',
'resources.action.createGrant': '新建授权',
'resources.action.delete': '删除',
'resources.action.revoke': '撤销',
'resources.action.deleteBinding': '删除绑定',
'resources.action.revokeGrant': '撤销授权',
// create binding modal
'resources.modal.create.title': '新建资源绑定',
'resources.modal.create.subtitle': '绑定后可在"授权"标签页把它授给某个角色 / 子 Agent 使用。',
'resources.modal.create.submit': '创建',
'resources.field.type': '类型',
'resources.field.name': '名称',
'resources.field.externalRef': '外部引用',
'resources.field.permissionScope': '权限范围',
'resources.field.permissionScope.hint': '每行或逗号分隔一项,例如:repo:read、repo:write:current-branch、cloud:list、vm:ssh:approved-window 等',
'resources.field.secretRef': '凭据引用 (secret_ref)',
'resources.field.secretRef.hint': '只填引用(如 vault://...),不要填密钥本身。后端会拒绝任何明文凭据。',
'resources.field.status': '状态',
// resource type labels
'resources.type.git': 'Git 仓库',
'resources.type.sk': 'SK',
'resources.type.project_doc': '项目文档',
'resources.type.cloud_account': '云账号',
'resources.type.cloud_resource': '云资源',
// create grant modal
'resources.modal.grant.title': '新建授权',
'resources.modal.grant.subtitle': '把已有绑定授权给一个角色或子 Agent 使用。允许动作必须是绑定权限范围的子集。',
'resources.modal.grant.submit': '授权',
'resources.modal.grant.noBindings': '暂无可授权的绑定',
'resources.field.binding': '绑定',
'resources.field.allowedActions': '允许动作',
'resources.field.allowedActions.hint': '从绑定的权限范围中勾选;不勾代表不授任何动作(仅允许读取元数据)。',
'resources.field.allowedActions.empty': '该绑定没有定义权限范围,无法授任何动作。建议先编辑该绑定补充权限。',
'resources.field.bindingScope': '作用域 (binding_scope)',
'resources.field.role': '角色',
'resources.field.expiresAt': '过期时间',
'resources.field.expiresAt.hint': '留空表示长期有效;高危资源建议设置。',
// grants table
'resources.grants.col.binding': '绑定',
'resources.grants.col.role': '角色',
'resources.grants.col.actions': '允许动作',
'resources.grants.col.scope': '作用域',
'resources.grants.col.status': '状态',
'resources.grants.col.expires': '过期',
'resources.grants.empty.title': '还没有任何授权',
'resources.grants.empty.body': '把"资源绑定"标签页里的某个绑定授给一个角色,子 Agent 部署时就能使用它。',
// confirm dialogs
'resources.confirm.delete.title': '删除资源绑定?',
'resources.confirm.delete.body': '将把绑定 "{name}" 标记为已撤销(软删除)。基于该绑定的授权不会自动撤销,需要手动处理。',
'resources.confirm.revoke.title': '撤销授权?',
'resources.confirm.revoke.body': '撤销后该角色 / 子 Agent 不再能使用对应的资源。撤销不可逆。',
// ─── HeiCode 登录 ──────────────────────────────────────
'login.subtitle': '登录以开始使用 Heicode',
+430 -81
View File
@@ -1,47 +1,80 @@
// desktop/src/pages/ResourceBindings.tsx
//
// 资源绑定页面。Slice 3:列出当前用户的所有 ResourceBinding(接 mcp-server
// /api/resources,通过本地 server /api/heicode-resources 代理)。
// Slice 4 再加 Create/Delete + Grants。
// Resources page (slice 4):
// - Tab switcher: Bindings / Grants
// - Bindings tab: list + Create + Delete (soft-revoke) + Refresh
// - Grants tab: list + Create + Revoke
// - All operations go through local server proxy → mcp-server.
import { useEffect } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from '../i18n'
import { useResourceStore } from '../stores/resourceStore'
import type { ResourceBinding, ResourceStatus } from '../api/heicodeResources'
import type {
GrantStatus,
ResourceBinding,
ResourceGrant,
ResourceStatus,
} from '../api/heicodeResources'
import {
BindingFormModal,
ConfirmDialog,
GrantFormModal,
} from '../components/resources/Modals'
const TYPE_LABELS: Record<ResourceBinding['type'], string> = {
git: 'Git',
sk: 'SK',
project_doc: '项目文档',
cloud_account: '云账号',
cloud_resource: '云资源',
}
type TabKey = 'bindings' | 'grants'
const STATUS_TONE: Record<ResourceStatus, string> = {
const BINDING_STATUS_TONE: Record<ResourceStatus, string> = {
active: 'text-[var(--color-success)] border-[var(--color-success)]/30 bg-[var(--color-success)]/10',
pending: 'text-[var(--color-warning)] border-[var(--color-warning)]/30 bg-[var(--color-warning)]/10',
disabled: 'text-[var(--color-text-tertiary)] border-[var(--color-border)] bg-[var(--color-surface-container-low)]',
revoked: 'text-[var(--color-error)] border-[var(--color-error)]/30 bg-[var(--color-error)]/10',
}
const GRANT_STATUS_TONE: Record<GrantStatus, string> = {
active: 'text-[var(--color-success)] border-[var(--color-success)]/30 bg-[var(--color-success)]/10',
suspended: 'text-[var(--color-warning)] border-[var(--color-warning)]/30 bg-[var(--color-warning)]/10',
revoked: 'text-[var(--color-error)] border-[var(--color-error)]/30 bg-[var(--color-error)]/10',
expired: 'text-[var(--color-text-tertiary)] border-[var(--color-border)] bg-[var(--color-surface-container-low)]',
}
export function ResourceBindings() {
const t = useTranslation()
const { bindings, isLoading, hasFetched, error, fetchBindings, clearError } =
useResourceStore()
const [tab, setTab] = useState<TabKey>('bindings')
const [showCreateBinding, setShowCreateBinding] = useState(false)
const [bindingToDelete, setBindingToDelete] = useState<ResourceBinding | null>(null)
const [showCreateGrant, setShowCreateGrant] = useState(false)
const [grantToRevoke, setGrantToRevoke] = useState<ResourceGrant | null>(null)
const {
bindings, bindingsLoading, bindingsFetched, bindingsError,
grants, grantsLoading, grantsFetched, grantsError,
isMutating, mutationError,
fetchBindings, fetchGrants,
createBinding, deleteBinding,
createGrant, revokeGrant,
clearBindingsError, clearGrantsError, clearMutationError,
} = useResourceStore()
useEffect(() => {
if (!hasFetched) {
void fetchBindings()
if (tab === 'bindings' && !bindingsFetched) void fetchBindings()
if (tab === 'grants' && !grantsFetched) {
void fetchGrants()
// Always need bindings list available for the Grant create modal,
// even when the user opens the Grants tab first.
if (!bindingsFetched) void fetchBindings()
}
}, [hasFetched, fetchBindings])
}, [tab, bindingsFetched, grantsFetched, fetchBindings, fetchGrants])
const showEmpty = hasFetched && !isLoading && !error && bindings.length === 0
const bindingMap = useMemo(() => {
const m = new Map<string, ResourceBinding>()
for (const b of bindings) m.set(b.id, b)
return m
}, [bindings])
return (
<div className="relative flex flex-1 flex-col overflow-auto bg-[var(--color-surface)]">
<div className="mx-auto w-full max-w-5xl px-8 py-10">
<header className="mb-8 flex items-start justify-between gap-4">
<div>
<header className="mb-6">
<h1
className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
@@ -51,59 +84,299 @@ export function ResourceBindings() {
<p className="mt-2 max-w-2xl text-sm text-[var(--color-text-secondary)]">
{t('resources.subtitle')}
</p>
</div>
<button
type="button"
onClick={() => fetchBindings()}
disabled={isLoading}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
{isLoading ? t('resources.refreshing') : t('resources.refresh')}
</button>
</header>
{error ? (
{/* Tabs */}
<div className="mb-6 flex items-center justify-between border-b border-[var(--color-border)]">
<div className="flex gap-1">
<TabButton active={tab === 'bindings'} onClick={() => setTab('bindings')}>
{t('resources.tab.bindings')}
<span className="ml-1.5 text-[10px] text-[var(--color-text-tertiary)]">
{bindings.length}
</span>
</TabButton>
<TabButton active={tab === 'grants'} onClick={() => setTab('grants')}>
{t('resources.tab.grants')}
<span className="ml-1.5 text-[10px] text-[var(--color-text-tertiary)]">
{grants.length}
</span>
</TabButton>
</div>
<div className="flex items-center gap-2 pb-2">
<button
type="button"
onClick={() => (tab === 'bindings' ? fetchBindings() : fetchGrants())}
disabled={tab === 'bindings' ? bindingsLoading : grantsLoading}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
{(tab === 'bindings' ? bindingsLoading : grantsLoading)
? t('resources.refreshing')
: t('resources.refresh')}
</button>
<button
type="button"
onClick={() => {
clearMutationError()
if (tab === 'bindings') setShowCreateBinding(true)
else setShowCreateGrant(true)
}}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1 text-xs font-medium text-[var(--color-on-primary)] hover:bg-[var(--color-primary-fixed-dim)]"
>
{tab === 'bindings'
? t('resources.action.createBinding')
: t('resources.action.createGrant')}
</button>
</div>
</div>
{/* Error banners */}
{tab === 'bindings' && bindingsError ? (
<ErrorBanner
title={t('resources.error.title')}
message={bindingsError}
onDismiss={clearBindingsError}
dismissLabel={t('common.dismiss')}
/>
) : null}
{tab === 'grants' && grantsError ? (
<ErrorBanner
title={t('resources.error.title')}
message={grantsError}
onDismiss={clearGrantsError}
dismissLabel={t('common.dismiss')}
/>
) : null}
{/* Initial loading */}
{tab === 'bindings' && !bindingsFetched && bindingsLoading ? (
<LoadingCard label={t('common.loading')} />
) : null}
{tab === 'grants' && !grantsFetched && grantsLoading ? (
<LoadingCard label={t('common.loading')} />
) : null}
{/* Bindings tab */}
{tab === 'bindings' && bindingsFetched ? (
bindings.length === 0 ? (
<EmptyCard
icon="link_off"
title={t('resources.empty.title')}
body={t('resources.empty.body')}
ctaLabel={t('resources.action.createBinding')}
onCta={() => setShowCreateBinding(true)}
/>
) : (
<BindingsTable
bindings={bindings}
onDelete={(b) => setBindingToDelete(b)}
/>
)
) : null}
{/* Grants tab */}
{tab === 'grants' && grantsFetched ? (
grants.length === 0 ? (
<EmptyCard
icon="key_off"
title={t('resources.grants.empty.title')}
body={t('resources.grants.empty.body')}
ctaLabel={t('resources.action.createGrant')}
onCta={() => setShowCreateGrant(true)}
/>
) : (
<GrantsTable
grants={grants}
bindingMap={bindingMap}
onRevoke={(g) => setGrantToRevoke(g)}
/>
)
) : null}
</div>
{/* Modals */}
<BindingFormModal
open={showCreateBinding}
busy={isMutating}
errorMessage={mutationError}
onCancel={() => {
setShowCreateBinding(false)
clearMutationError()
}}
onSubmit={async (input) => {
const created = await createBinding(input)
if (created) {
setShowCreateBinding(false)
}
}}
/>
<GrantFormModal
open={showCreateGrant}
bindings={bindings}
busy={isMutating}
errorMessage={mutationError}
onCancel={() => {
setShowCreateGrant(false)
clearMutationError()
}}
onSubmit={async (input) => {
const created = await createGrant(input)
if (created) setShowCreateGrant(false)
}}
/>
<ConfirmDialog
open={!!bindingToDelete}
title={t('resources.confirm.delete.title')}
body={t('resources.confirm.delete.body', { name: bindingToDelete?.name ?? '' })}
confirmLabel={t('resources.action.deleteBinding')}
danger
busy={isMutating}
errorMessage={mutationError}
onCancel={() => {
setBindingToDelete(null)
clearMutationError()
}}
onConfirm={async () => {
if (!bindingToDelete) return
const ok = await deleteBinding(bindingToDelete.id)
if (ok) setBindingToDelete(null)
}}
/>
<ConfirmDialog
open={!!grantToRevoke}
title={t('resources.confirm.revoke.title')}
body={t('resources.confirm.revoke.body')}
confirmLabel={t('resources.action.revokeGrant')}
danger
busy={isMutating}
errorMessage={mutationError}
onCancel={() => {
setGrantToRevoke(null)
clearMutationError()
}}
onConfirm={async () => {
if (!grantToRevoke) return
const ok = await revokeGrant(grantToRevoke.id)
if (ok) setGrantToRevoke(null)
}}
/>
</div>
)
}
// ─── Sub-components ────────────────────────────────────────────
function TabButton({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
className={`relative px-4 py-2.5 text-sm transition-colors ${
active
? 'font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
>
{children}
{active ? (
<span className="absolute -bottom-px left-0 right-0 h-px bg-[var(--color-primary)]" />
) : null}
</button>
)
}
function ErrorBanner({
title,
message,
onDismiss,
dismissLabel,
}: {
title: string
message: string
onDismiss: () => void
dismissLabel: string
}) {
return (
<div className="mb-6 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-4 py-3 text-sm text-[var(--color-error)]">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-medium">{t('resources.error.title')}</div>
<div className="mt-1 text-xs opacity-80">{error}</div>
<div className="font-medium">{title}</div>
<div className="mt-1 text-xs opacity-80 break-words">{message}</div>
</div>
<button
type="button"
onClick={clearError}
className="text-xs underline-offset-2 hover:underline"
>
{t('common.dismiss')}
<button type="button" onClick={onDismiss} className="text-xs underline-offset-2 hover:underline">
{dismissLabel}
</button>
</div>
</div>
) : null}
)
}
{!hasFetched && isLoading ? (
function LoadingCard({ label }: { label: string }) {
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-8 text-center text-sm text-[var(--color-text-tertiary)]">
{t('common.loading')}
{label}
</div>
) : null}
)
}
{showEmpty ? (
function EmptyCard({
icon,
title,
body,
ctaLabel,
onCta,
}: {
icon: string
title: string
body: string
ctaLabel: string
onCta: () => void
}) {
return (
<section className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-8 text-center shadow-[var(--shadow-dropdown)]">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[22px]">link_off</span>
<span className="material-symbols-outlined text-[22px]">{icon}</span>
</div>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
{t('resources.empty.title')}
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-[var(--color-text-secondary)]">
{t('resources.empty.body')}
</p>
<p className="mx-auto mt-4 max-w-md text-xs text-[var(--color-text-tertiary)]">
{t('resources.empty.comingSoon')}
</p>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{title}</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-[var(--color-text-secondary)]">{body}</p>
<button
type="button"
onClick={onCta}
className="mt-5 rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-[var(--color-on-primary)] hover:bg-[var(--color-primary-fixed-dim)]"
>
{ctaLabel}
</button>
</section>
) : null}
)
}
{bindings.length > 0 ? (
const TYPE_LABELS: Record<ResourceBinding['type'], string> = {
git: 'Git',
sk: 'SK',
project_doc: 'Doc',
cloud_account: 'Cloud',
cloud_resource: 'Resource',
}
function BindingsTable({
bindings,
onDelete,
}: {
bindings: ResourceBinding[]
onDelete: (b: ResourceBinding) => void
}) {
const t = useTranslation()
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)]">
<table className="w-full text-sm">
<thead className="border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[11px] uppercase tracking-wider text-[var(--color-text-tertiary)]">
@@ -113,54 +386,54 @@ export function ResourceBindings() {
<th className="px-4 py-3 text-left font-medium">{t('resources.col.externalRef')}</th>
<th className="px-4 py-3 text-left font-medium">{t('resources.col.status')}</th>
<th className="px-4 py-3 text-left font-medium">{t('resources.col.scope')}</th>
<th className="px-4 py-3 text-right font-medium">{t('resources.col.actions')}</th>
</tr>
</thead>
<tbody>
{bindings.map((b) => (
<tr
key={b.id}
className="border-b border-[var(--color-border-separator)] last:border-0 transition-colors hover:bg-[var(--color-surface-hover)]"
>
<tr key={b.id} className="border-b border-[var(--color-border-separator)] last:border-0 transition-colors hover:bg-[var(--color-surface-hover)]">
<td className="px-4 py-3 align-top">
<div className="font-medium text-[var(--color-text-primary)]">{b.name}</div>
<div className="mt-0.5 font-mono text-[10px] text-[var(--color-text-tertiary)]">
{b.id.slice(0, 8)}…
</div>
<div className="mt-0.5 font-mono text-[10px] text-[var(--color-text-tertiary)]">{b.id.slice(0, 8)}…</div>
</td>
<td className="px-4 py-3 align-top">
<span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2 py-0.5 text-xs text-[var(--color-text-secondary)]">
{TYPE_LABELS[b.type] ?? b.type}
</span>
</td>
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-secondary)] break-all">
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-secondary)] break-all max-w-[220px]">
{b.external_ref ?? '—'}
</td>
<td className="px-4 py-3 align-top">
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${STATUS_TONE[b.status]}`}
>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${BINDING_STATUS_TONE[b.status]}`}>
{b.status}
</span>
</td>
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-secondary)]">
{b.permission_scope.length > 0 ? (
<div className="flex flex-wrap gap-1">
{b.permission_scope.slice(0, 3).map((s) => (
<code
key={s}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]"
>
{b.permission_scope.slice(0, 2).map((s) => (
<code key={s} className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">
{s}
</code>
))}
{b.permission_scope.length > 3 ? (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
+{b.permission_scope.length - 3}
</span>
{b.permission_scope.length > 2 ? (
<span className="text-[10px] text-[var(--color-text-tertiary)]">+{b.permission_scope.length - 2}</span>
) : null}
</div>
) : '—'}
</td>
<td className="px-4 py-3 align-top text-right">
{b.status !== 'revoked' ? (
<button
type="button"
onClick={() => onDelete(b)}
className="text-xs text-[var(--color-error)] hover:underline"
>
{t('resources.action.delete')}
</button>
) : (
'—'
<span className="text-xs text-[var(--color-text-tertiary)]">—</span>
)}
</td>
</tr>
@@ -168,12 +441,88 @@ export function ResourceBindings() {
</tbody>
</table>
</div>
) : null}
)
}
<div className="mt-8 text-xs text-[var(--color-text-tertiary)]">
{t('resources.footer.cruComingSoon')}
</div>
function GrantsTable({
grants,
bindingMap,
onRevoke,
}: {
grants: ResourceGrant[]
bindingMap: Map<string, ResourceBinding>
onRevoke: (g: ResourceGrant) => void
}) {
const t = useTranslation()
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)]">
<table className="w-full text-sm">
<thead className="border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[11px] uppercase tracking-wider text-[var(--color-text-tertiary)]">
<tr>
<th className="px-4 py-3 text-left font-medium">{t('resources.grants.col.binding')}</th>
<th className="px-4 py-3 text-left font-medium">{t('resources.grants.col.role')}</th>
<th className="px-4 py-3 text-left font-medium">{t('resources.grants.col.actions')}</th>
<th className="px-4 py-3 text-left font-medium">{t('resources.grants.col.scope')}</th>
<th className="px-4 py-3 text-left font-medium">{t('resources.grants.col.status')}</th>
<th className="px-4 py-3 text-left font-medium">{t('resources.grants.col.expires')}</th>
<th className="px-4 py-3 text-right font-medium">{t('resources.col.actions')}</th>
</tr>
</thead>
<tbody>
{grants.map((g) => {
const b = bindingMap.get(g.resource_id)
return (
<tr key={g.id} className="border-b border-[var(--color-border-separator)] last:border-0 transition-colors hover:bg-[var(--color-surface-hover)]">
<td className="px-4 py-3 align-top">
<div className="font-medium text-[var(--color-text-primary)]">{b?.name ?? g.resource_id.slice(0, 8) + '…'}</div>
{b ? (
<div className="mt-0.5 text-[10px] text-[var(--color-text-tertiary)]">{TYPE_LABELS[b.type]}</div>
) : null}
</td>
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-secondary)]">
{g.role || '—'}
</td>
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-secondary)]">
{g.allowed_actions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{g.allowed_actions.slice(0, 2).map((a) => (
<code key={a} className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">
{a}
</code>
))}
{g.allowed_actions.length > 2 ? (
<span className="text-[10px] text-[var(--color-text-tertiary)]">+{g.allowed_actions.length - 2}</span>
) : null}
</div>
) : '—'}
</td>
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-secondary)]">{g.binding_scope}</td>
<td className="px-4 py-3 align-top">
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${GRANT_STATUS_TONE[g.status]}`}>
{g.status}
</span>
</td>
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-tertiary)]">
{g.expires_at ? new Date(g.expires_at).toLocaleDateString() : '—'}
</td>
<td className="px-4 py-3 align-top text-right">
{g.status === 'active' ? (
<button
type="button"
onClick={() => onRevoke(g)}
className="text-xs text-[var(--color-error)] hover:underline"
>
{t('resources.action.revoke')}
</button>
) : (
<span className="text-xs text-[var(--color-text-tertiary)]">—</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
+149 -22
View File
@@ -1,51 +1,178 @@
// desktop/src/stores/resourceStore.ts
//
// Zustand store for ResourceBinding / ResourceGrant lists. Talks to the local
// /api/heicode-resources/* proxy (which forwards to mcp-server with refresh).
//
// Slice 3 scope: list bindings only. Slice 4 adds create/delete/grants.
// 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[]
isLoading: boolean
hasFetched: boolean
error: string | null
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>
clearError: () => 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
}
export const useResourceStore = create<ResourceState>((set) => ({
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: [],
isLoading: false,
hasFetched: false,
error: null,
bindingsLoading: false,
bindingsFetched: false,
bindingsError: null,
grants: [],
grantsLoading: false,
grantsFetched: false,
grantsError: null,
isMutating: false,
mutationError: null,
fetchBindings: async () => {
set({ isLoading: true, error: null })
set({ bindingsLoading: true, bindingsError: null })
try {
const res = await heicodeResourcesApi.listBindings()
const items = res.success ? (res.data?.items ?? []) : []
set({
bindings: items,
isLoading: false,
hasFetched: true,
error: res.success ? null : (res.message || '加载资源绑定失败'),
bindings: res.success ? (res.data?.items ?? []) : [],
bindingsLoading: false,
bindingsFetched: true,
bindingsError: res.success ? null : (res.message || '加载资源绑定失败'),
})
} catch (err) {
set({
isLoading: false,
hasFetched: true,
error: err instanceof Error ? err.message : String(err),
bindingsLoading: false,
bindingsFetched: true,
bindingsError: extractErrorMessage(err, '加载资源绑定失败'),
})
}
},
clearError: () => set({ error: null }),
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 }),
}))