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