// 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 ( {title} {body} {errorMessage ? ( {errorMessage} ) : null} {t('common.cancel')} {busy ? t('common.processing') : confirmLabel} ) } // ─── 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('git') const [name, setName] = useState('') const [externalRef, setExternalRef] = useState('') const [permissionScope, setPermissionScope] = useState('') const [secretRef, setSecretRef] = useState('') const [status, setStatus] = useState('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 ( {t('resources.modal.create.title')} {t('resources.modal.create.subtitle')} setType(e.target.value as ResourceType)} disabled={busy} className={inputClass} > {t('resources.type.git')} {t('resources.type.sk')} {t('resources.type.project_doc')} {t('resources.type.cloud_account')} {t('resources.type.cloud_resource')} setName(e.target.value)} disabled={busy} placeholder="my-repo" maxLength={255} className={inputClass} /> setExternalRef(e.target.value)} disabled={busy} placeholder="https://example.com/org/repo.git" className={inputClass} /> setPermissionScope(e.target.value)} disabled={busy} rows={3} placeholder="repo:read repo:write:current-branch" className={`${inputClass} resize-none font-mono text-xs`} /> setSecretRef(e.target.value)} disabled={busy} placeholder="vault://secret/users/{user_id}/bindings/my-repo" maxLength={500} className={inputClass} /> setStatus(e.target.value as ResourceStatus)} disabled={busy} className={inputClass} > pending active disabled {errorMessage ? ( {errorMessage} ) : null} {t('common.cancel')} {busy ? t('common.processing') : t('resources.modal.create.submit')} ) } // ─── 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>(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() 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 ( {t('resources.modal.grant.title')} {t('resources.modal.grant.subtitle')} setResourceId(e.target.value)} disabled={busy || activeBindings.length === 0} className={inputClass} > {activeBindings.length === 0 ? ( {t('resources.modal.grant.noBindings')} ) : ( activeBindings.map((b) => ( {b.name} · {b.type} )) )} {availableActions.length === 0 ? ( {t('resources.field.allowedActions.empty')} ) : ( {availableActions.map((a) => { const checked = allowedActions.has(a) return ( toggleAction(a)} disabled={busy} className="hidden" /> {a} ) })} )} setBindingScope(e.target.value)} disabled={busy} placeholder="main" maxLength={255} className={inputClass} /> setRole(e.target.value)} disabled={busy} className={inputClass} > product frontend backend reviewer ops setExpiresAt(e.target.value)} disabled={busy} className={inputClass} /> {errorMessage ? ( {errorMessage} ) : null} {t('common.cancel')} {busy ? t('common.processing') : t('resources.modal.grant.submit')} ) } // ─── 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} {required ? * : null} {children} {hint ? ( {hint} ) : null} ) } function ModalScrim({ onCancel, children, }: { onCancel: () => void children: React.ReactNode }) { return ( e.stopPropagation()} className="max-h-full overflow-auto"> {children} ) }
{t('resources.modal.create.subtitle')}
{t('resources.modal.grant.subtitle')}
{a}