Files
heicode-win/cc-haha/desktop/src/pages/ResourceBindings.tsx
T
chenchenandClaude Opus 4.7 c0363be850 feat(resources): slice 3 — persist mcp JWT, proxy, list bindings
Backend:

  1. Extend SavedProvider schema with optional mcpAuth field
     (accessToken / refreshToken / accessExpiresAt / refreshExpiresAt /
     managerLoginUrl / userId / channelId). Wired through
     CreateProviderInput and UpdateProviderInput so providerService
     persists tokens to providers.json.

  2. handleLoginWithCredentials (Path A) now decodes the JWT exp claim
     of both tokens (no signature verification — issuer just authed us)
     and stores the resulting mcpAuth object on the saved provider.
     Documented TTL (24h access / 7d refresh) used as fallback if exp
     claim missing.

  3. New handler api/heicode-resources.ts — proxy for the local server
     route /api/heicode-resources/{*path}. It:
       - Reads mcpAuth from the active provider (401 if missing)
       - Refreshes the access token if < 60s from expiry by calling
         <managerLoginUrl>/api/auth/refresh; persists the new pair
         back to providers.json before forwarding
       - Returns 401 if refresh token is also expired (re-login needed)
       - Forwards request to <managerLoginUrl>/api/resources or
         /api/resource-grants with Authorization: Bearer <accessToken>
       - Passes status + body through

  4. router.ts: register case 'heicode-resources'.

  5. errorHandler: add ApiError.unauthorized(401) and badGateway(502)
     factories used by the proxy.

Desktop:

  6. New api/heicodeResources.ts client + types (ResourceBinding,
     ResourceGrant, etc. mirroring mcp-server contract). Slice 3 only
     exposes listBindings + getBinding.

  7. New stores/resourceStore.ts (zustand) with bindings, isLoading,
     hasFetched, error + fetchBindings action.

  8. pages/ResourceBindings.tsx upgraded from shell to a real list:
       - Auto-fetches on mount
       - Shows loading skeleton, error banner with dismiss, empty state,
         or a 5-column table (Name / Type / External ref / Status /
         Permission scope)
       - Refresh button in the header
       - Footer note about CRUD coming in slice 4

  9. i18n: 14 new keys (common.dismiss + resources.refresh / refreshing
     / col.* / error.title / footer.cruComingSoon) in both zh + en.

E2E behaviour after install: log in via 55@55.com / By@123456., open
Resources tab — local server proxies to apimtaiji and lists whatever
ResourceBindings the user has on mcp-server. New test account 55@55.com
has 0 bindings, so empty state shows up.

Slice 4 next: Create / Edit / Delete binding modals + Grants UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:04:59 +08:00

180 lines
8.5 KiB
TypeScript

// desktop/src/pages/ResourceBindings.tsx
//
// 资源绑定页面。Slice 3:列出当前用户的所有 ResourceBinding(接 mcp-server
// /api/resources,通过本地 server /api/heicode-resources 代理)。
// Slice 4 再加 Create/Delete + Grants。
import { useEffect } from 'react'
import { useTranslation } from '../i18n'
import { useResourceStore } from '../stores/resourceStore'
import type { ResourceBinding, ResourceStatus } from '../api/heicodeResources'
const TYPE_LABELS: Record<ResourceBinding['type'], string> = {
git: 'Git',
sk: 'SK',
project_doc: '项目文档',
cloud_account: '云账号',
cloud_resource: '云资源',
}
const 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',
}
export function ResourceBindings() {
const t = useTranslation()
const { bindings, isLoading, hasFetched, error, fetchBindings, clearError } =
useResourceStore()
useEffect(() => {
if (!hasFetched) {
void fetchBindings()
}
}, [hasFetched, fetchBindings])
const showEmpty = hasFetched && !isLoading && !error && bindings.length === 0
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>
<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>
</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 ? (
<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>
<button
type="button"
onClick={clearError}
className="text-xs underline-offset-2 hover:underline"
>
{t('common.dismiss')}
</button>
</div>
</div>
) : null}
{!hasFetched && isLoading ? (
<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')}
</div>
) : null}
{showEmpty ? (
<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>
</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>
</section>
) : null}
{bindings.length > 0 ? (
<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>
</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">
{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]}`}
>
{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]"
>
{s}
</code>
))}
{b.permission_scope.length > 3 ? (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
+{b.permission_scope.length - 3}
</span>
) : null}
</div>
) : (
'—'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<div className="mt-8 text-xs text-[var(--color-text-tertiary)]">
{t('resources.footer.cruComingSoon')}
</div>
</div>
</div>
)
}