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:
@@ -75,9 +75,44 @@ export type ApiEnvelope<T> = {
|
|||||||
message?: string | null
|
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'
|
const BASE = '/api/heicode-resources'
|
||||||
|
|
||||||
export const heicodeResourcesApi = {
|
export const heicodeResourcesApi = {
|
||||||
|
// ── ResourceBindings ──────────────────────────────────────────
|
||||||
listBindings(opts?: { type?: ResourceType; status?: ResourceStatus }) {
|
listBindings(opts?: { type?: ResourceType; status?: ResourceStatus }) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (opts?.type) params.set('type', opts.type)
|
if (opts?.type) params.set('type', opts.type)
|
||||||
@@ -92,6 +127,54 @@ export const heicodeResourcesApi = {
|
|||||||
return api.get<ApiEnvelope<ResourceBinding>>(`${BASE}/${encodeURIComponent(id)}`)
|
return api.get<ApiEnvelope<ResourceBinding>>(`${BASE}/${encodeURIComponent(id)}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Slice 4 will add createBinding / updateBinding / deleteBinding /
|
createBinding(input: CreateBindingInput) {
|
||||||
// listGrants / createGrant / revokeGrant.
|
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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export const en = {
|
|||||||
'common.retry': 'Retry',
|
'common.retry': 'Retry',
|
||||||
'common.loading': 'Loading...',
|
'common.loading': 'Loading...',
|
||||||
'common.dismiss': 'Dismiss',
|
'common.dismiss': 'Dismiss',
|
||||||
|
'common.processing': 'Processing…',
|
||||||
'common.select': 'Select',
|
'common.select': 'Select',
|
||||||
'common.enable': 'Enable',
|
'common.enable': 'Enable',
|
||||||
'common.disable': 'Disable',
|
'common.disable': 'Disable',
|
||||||
@@ -971,7 +972,67 @@ export const en = {
|
|||||||
'resources.col.externalRef': 'External ref',
|
'resources.col.externalRef': 'External ref',
|
||||||
'resources.col.status': 'Status',
|
'resources.col.status': 'Status',
|
||||||
'resources.col.scope': 'Permission scope',
|
'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 ──────────────────────────────────────
|
// ─── HeiCode Login ──────────────────────────────────────
|
||||||
'login.subtitle': 'Sign in to start using Heicode',
|
'login.subtitle': 'Sign in to start using Heicode',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'common.retry': '重试',
|
'common.retry': '重试',
|
||||||
'common.loading': '加载中...',
|
'common.loading': '加载中...',
|
||||||
'common.dismiss': '关闭',
|
'common.dismiss': '关闭',
|
||||||
|
'common.processing': '处理中…',
|
||||||
'common.select': '选择',
|
'common.select': '选择',
|
||||||
'common.enable': '启用',
|
'common.enable': '启用',
|
||||||
'common.disable': '禁用',
|
'common.disable': '禁用',
|
||||||
@@ -973,7 +974,67 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'resources.col.externalRef': '外部引用',
|
'resources.col.externalRef': '外部引用',
|
||||||
'resources.col.status': '状态',
|
'resources.col.status': '状态',
|
||||||
'resources.col.scope': '权限范围',
|
'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 登录 ──────────────────────────────────────
|
// ─── HeiCode 登录 ──────────────────────────────────────
|
||||||
'login.subtitle': '登录以开始使用 Heicode',
|
'login.subtitle': '登录以开始使用 Heicode',
|
||||||
|
|||||||
@@ -1,179 +1,528 @@
|
|||||||
// desktop/src/pages/ResourceBindings.tsx
|
// desktop/src/pages/ResourceBindings.tsx
|
||||||
//
|
//
|
||||||
// 资源绑定页面。Slice 3:列出当前用户的所有 ResourceBinding(接 mcp-server
|
// Resources page (slice 4):
|
||||||
// /api/resources,通过本地 server /api/heicode-resources 代理)。
|
// - Tab switcher: Bindings / Grants
|
||||||
// Slice 4 再加 Create/Delete + 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 { useTranslation } from '../i18n'
|
||||||
import { useResourceStore } from '../stores/resourceStore'
|
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> = {
|
type TabKey = 'bindings' | 'grants'
|
||||||
git: 'Git',
|
|
||||||
sk: 'SK',
|
|
||||||
project_doc: '项目文档',
|
|
||||||
cloud_account: '云账号',
|
|
||||||
cloud_resource: '云资源',
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
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',
|
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)]',
|
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',
|
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() {
|
export function ResourceBindings() {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const { bindings, isLoading, hasFetched, error, fetchBindings, clearError } =
|
const [tab, setTab] = useState<TabKey>('bindings')
|
||||||
useResourceStore()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!hasFetched) {
|
if (tab === 'bindings' && !bindingsFetched) void fetchBindings()
|
||||||
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 (
|
return (
|
||||||
<div className="relative flex flex-1 flex-col overflow-auto bg-[var(--color-surface)]">
|
<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">
|
<div className="mx-auto w-full max-w-5xl px-8 py-10">
|
||||||
<header className="mb-8 flex items-start justify-between gap-4">
|
<header className="mb-6">
|
||||||
<div>
|
<h1
|
||||||
<h1
|
className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]"
|
||||||
className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]"
|
style={{ fontFamily: 'var(--font-headline)' }}
|
||||||
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')}
|
{t('resources.title')}
|
||||||
</button>
|
</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-[var(--color-text-secondary)]">
|
||||||
|
{t('resources.subtitle')}
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
{/* Tabs */}
|
||||||
<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="mb-6 flex items-center justify-between border-b border-[var(--color-border)]">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex gap-1">
|
||||||
<div className="flex-1">
|
<TabButton active={tab === 'bindings'} onClick={() => setTab('bindings')}>
|
||||||
<div className="font-medium">{t('resources.error.title')}</div>
|
{t('resources.tab.bindings')}
|
||||||
<div className="mt-1 text-xs opacity-80">{error}</div>
|
<span className="ml-1.5 text-[10px] text-[var(--color-text-tertiary)]">
|
||||||
</div>
|
{bindings.length}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
</TabButton>
|
||||||
onClick={clearError}
|
<TabButton active={tab === 'grants'} onClick={() => setTab('grants')}>
|
||||||
className="text-xs underline-offset-2 hover:underline"
|
{t('resources.tab.grants')}
|
||||||
>
|
<span className="ml-1.5 text-[10px] text-[var(--color-text-tertiary)]">
|
||||||
{t('common.dismiss')}
|
{grants.length}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</TabButton>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="flex items-center gap-2 pb-2">
|
||||||
|
<button
|
||||||
{!hasFetched && isLoading ? (
|
type="button"
|
||||||
<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)]">
|
onClick={() => (tab === 'bindings' ? fetchBindings() : fetchGrants())}
|
||||||
{t('common.loading')}
|
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>
|
||||||
) : 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,178 @@
|
|||||||
// desktop/src/stores/resourceStore.ts
|
// desktop/src/stores/resourceStore.ts
|
||||||
//
|
//
|
||||||
// Zustand store for ResourceBinding / ResourceGrant lists. Talks to the local
|
// Zustand store for ResourceBinding / ResourceGrant. Talks to the local
|
||||||
// /api/heicode-resources/* proxy (which forwards to mcp-server with refresh).
|
// /api/heicode-resources/* proxy.
|
||||||
//
|
|
||||||
// Slice 3 scope: list bindings only. Slice 4 adds create/delete/grants.
|
|
||||||
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import {
|
import {
|
||||||
heicodeResourcesApi,
|
heicodeResourcesApi,
|
||||||
|
type CreateBindingInput,
|
||||||
|
type CreateGrantInput,
|
||||||
type ResourceBinding,
|
type ResourceBinding,
|
||||||
|
type ResourceGrant,
|
||||||
} from '../api/heicodeResources'
|
} from '../api/heicodeResources'
|
||||||
|
|
||||||
type ResourceState = {
|
type ResourceState = {
|
||||||
|
// Bindings
|
||||||
bindings: ResourceBinding[]
|
bindings: ResourceBinding[]
|
||||||
isLoading: boolean
|
bindingsLoading: boolean
|
||||||
hasFetched: boolean
|
bindingsFetched: boolean
|
||||||
error: string | null
|
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>
|
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: [],
|
bindings: [],
|
||||||
isLoading: false,
|
bindingsLoading: false,
|
||||||
hasFetched: false,
|
bindingsFetched: false,
|
||||||
error: null,
|
bindingsError: null,
|
||||||
|
|
||||||
|
grants: [],
|
||||||
|
grantsLoading: false,
|
||||||
|
grantsFetched: false,
|
||||||
|
grantsError: null,
|
||||||
|
|
||||||
|
isMutating: false,
|
||||||
|
mutationError: null,
|
||||||
|
|
||||||
fetchBindings: async () => {
|
fetchBindings: async () => {
|
||||||
set({ isLoading: true, error: null })
|
set({ bindingsLoading: true, bindingsError: null })
|
||||||
try {
|
try {
|
||||||
const res = await heicodeResourcesApi.listBindings()
|
const res = await heicodeResourcesApi.listBindings()
|
||||||
const items = res.success ? (res.data?.items ?? []) : []
|
|
||||||
set({
|
set({
|
||||||
bindings: items,
|
bindings: res.success ? (res.data?.items ?? []) : [],
|
||||||
isLoading: false,
|
bindingsLoading: false,
|
||||||
hasFetched: true,
|
bindingsFetched: true,
|
||||||
error: res.success ? null : (res.message || '加载资源绑定失败'),
|
bindingsError: res.success ? null : (res.message || '加载资源绑定失败'),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
isLoading: false,
|
bindingsLoading: false,
|
||||||
hasFetched: true,
|
bindingsFetched: true,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
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 }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user