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>
529 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|