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>
180 lines
8.5 KiB
TypeScript
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>
|
|
)
|
|
}
|