Files
heicode/cc-haha/desktop/src/pages/ResourceBindings.tsx
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

529 lines
21 KiB
TypeScript

// desktop/src/pages/ResourceBindings.tsx
//
// 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, useMemo, useState } from 'react'
import { useTranslation } from '../i18n'
import { useResourceStore } from '../stores/resourceStore'
import type {
GrantStatus,
ResourceBinding,
ResourceGrant,
ResourceStatus,
} from '../api/heicodeResources'
import {
BindingFormModal,
ConfirmDialog,
GrantFormModal,
} from '../components/resources/Modals'
type TabKey = 'bindings' | 'grants'
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 [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 (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()
}
}, [tab, bindingsFetched, grantsFetched, fetchBindings, fetchGrants])
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-6">
<h1
className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('resources.title')}
</h1>
<p className="mt-2 max-w-2xl text-sm text-[var(--color-text-secondary)]">
{t('resources.subtitle')}
</p>
</header>
{/* 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">{title}</div>
<div className="mt-1 text-xs opacity-80 break-words">{message}</div>
</div>
<button type="button" onClick={onDismiss} className="text-xs underline-offset-2 hover:underline">
{dismissLabel}
</button>
</div>
</div>
)
}
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)]">
{label}
</div>
)
}
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]">{icon}</span>
</div>
<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>
)
}
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)]">
<tr>
<th className="px-4 py-3 text-left font-medium">{t('resources.col.name')}</th>
<th className="px-4 py-3 text-left font-medium">{t('resources.col.type')}</th>
<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)]">
<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>
</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 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 ${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, 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 > 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>
))}
</tbody>
</table>
</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>
)
}