Files
heicode/cc-haha/desktop/src/components/resources/Modals.tsx
T
chenchenandClaude Opus 4.7 a28c90040f 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>
2026-05-07 18:46:30 +08:00

539 lines
19 KiB
TypeScript

// 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&#10;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>
)
}