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>
539 lines
19 KiB
TypeScript
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 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>
|
|
)
|
|
}
|