99 * shows an OAuth or MCP-key configure form plus per-agent grants.
1010 */
1111
12- import { useEffect , useState , useCallback , useMemo } from 'react' ;
12+ import { useEffect , useState , useCallback } from 'react' ;
1313import {
1414 CheckCircle2 ,
1515 AlertCircle ,
1616 Loader2 ,
1717 ExternalLink ,
1818 ChevronDown ,
1919 ChevronUp ,
20- X ,
2120} from 'lucide-react' ;
2221
2322// Human-readable labels for well-known OAuth scope URIs.
@@ -511,83 +510,76 @@ function MCPServerConfigureBody({
511510
512511// ── ConnectorAgentGrants ─────────────────────────────────────────────────────
513512
514- /** Inline scope-picker for a single pending agent. */
515- function PendingAgentRow ( {
513+ /**
514+ * Unified scope-toggle card for one agent (granted or not).
515+ * Toggling a scope auto-saves immediately — no explicit button needed.
516+ * Granted scopes start ON; not-yet-granted scopes start OFF.
517+ */
518+ function AgentGrantCard ( {
516519 agent,
517520 connectorId,
518- allScopes,
519- reason,
520- onGranted,
521+ grantedScopes,
522+ onChanged,
521523} : {
522- agent : { namespaced_agent_id ?: string ; name : string } ;
524+ agent : { namespaced_agent_id ?: string ; name : string ; required_connections ?: Array < { connector_id : string ; scopes : string [ ] ; reason : string } > } ;
523525 connectorId : string ;
524- allScopes : string [ ] ;
525- reason : string ;
526- onGranted : ( ) => void ;
526+ grantedScopes : string [ ] ;
527+ onChanged : ( ) => void ;
527528} ) {
528- const [ selected , setSelected ] = useState < Set < string > > ( ( ) => new Set ( allScopes ) ) ;
529- const [ busy , setBusy ] = useState ( false ) ;
530- const [ err , setErr ] = useState < string | null > ( null ) ;
529+ const req = agent . required_connections ?. find ( ( rc ) => rc . connector_id === connectorId ) ;
530+ const agentId = agent . namespaced_agent_id ;
531+ if ( ! req || ! agentId ) return null ;
531532
532- const toggle = ( scope : string ) =>
533- setSelected ( ( prev ) => {
534- const next = new Set ( prev ) ;
535- next . has ( scope ) ? next . delete ( scope ) : next . add ( scope ) ;
536- return next ;
537- } ) ;
533+ const [ localScopes , setLocalScopes ] = useState < Set < string > > ( ( ) => new Set ( grantedScopes ) ) ;
534+ const [ busyScope , setBusyScope ] = useState < string | null > ( null ) ;
535+ const [ err , setErr ] = useState < string | null > ( null ) ;
538536
539- const grant = async ( ) => {
540- const agentId = agent . namespaced_agent_id ;
541- if ( ! agentId || selected . size === 0 ) return ;
542- setBusy ( true ) ;
537+ // Sync when the parent reloads grants after a successful API call.
538+ useEffect ( ( ) => {
539+ setLocalScopes ( new Set ( grantedScopes ) ) ;
540+ } , [ grantedScopes . join ( ',' ) ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
541+
542+ const toggleScope = async ( scope : string ) => {
543+ if ( busyScope !== null ) return ; // one request at a time
544+ const next = new Set ( localScopes ) ;
545+ next . has ( scope ) ? next . delete ( scope ) : next . add ( scope ) ;
546+ setLocalScopes ( next ) ; // optimistic
547+ setBusyScope ( scope ) ;
543548 setErr ( null ) ;
544549 try {
545- await api . grantConnectorAgent ( connectorId , agentId , [ ...selected ] ) ;
546- onGranted ( ) ;
550+ if ( next . size === 0 ) {
551+ await api . revokeConnectorAgentGrant ( connectorId , agentId ) ;
552+ } else {
553+ await api . grantConnectorAgent ( connectorId , agentId , [ ...next ] ) ;
554+ }
555+ onChanged ( ) ;
547556 } catch ( e ) {
557+ setLocalScopes ( new Set ( grantedScopes ) ) ; // revert
548558 setErr ( e instanceof Error ? e . message : String ( e ) ) ;
549559 } finally {
550- setBusy ( false ) ;
560+ setBusyScope ( null ) ;
551561 }
552562 } ;
553563
554- const missingRequired = allScopes . some ( ( s ) => ! selected . has ( s ) ) ;
555-
556564 return (
557- < div className = "grant-pending-card" >
558- < div className = "grant-pending-header" >
559- < span className = "grant-agent" > { agent . name } </ span >
560- < button
561- className = "btn-grant-confirm"
562- disabled = { busy || selected . size === 0 }
563- onClick = { ( ) => void grant ( ) }
564- title = { reason }
565- >
566- { busy ? < Loader2 size = { 11 } className = "spin" /> : null }
567- Grant selected
568- </ button >
569- </ div >
565+ < div className = "grant-agent-card" >
566+ < div className = "grant-agent-card-name" > { agent . name } </ div >
570567 < div className = "grant-scope-list" >
571- { allScopes . map ( ( scope ) => (
568+ { req . scopes . map ( ( scope ) => (
572569 < label key = { scope } className = "grant-scope-item" >
573570 < span className = "grant-scope-label" > { scopeLabel ( scope ) } </ span >
574571 < span className = "toggle-switch" >
575572 < input
576573 type = "checkbox"
577- checked = { selected . has ( scope ) }
578- onChange = { ( ) => toggle ( scope ) }
574+ checked = { localScopes . has ( scope ) }
575+ onChange = { ( ) => void toggleScope ( scope ) }
576+ disabled = { busyScope !== null }
579577 />
580578 < span className = "toggle-track" />
581579 </ span >
582580 </ label >
583581 ) ) }
584582 </ div >
585- { missingRequired && (
586- < div className = "grant-scope-warning" >
587- < AlertCircle size = { 11 } />
588- This agent may not work correctly without all scopes.
589- </ div >
590- ) }
591583 { err && (
592584 < div className = "grant-scope-warning grant-scope-warning--error" >
593585 < AlertCircle size = { 11 } /> { err }
@@ -601,8 +593,6 @@ function ConnectorAgentGrants({ connectorId }: { connectorId: string }) {
601593 const { agents } = useChatStore ( ) ;
602594 const [ grants , setGrants ] = useState < Record < string , string [ ] > > ( { } ) ;
603595 const [ loading , setLoading ] = useState ( true ) ;
604- const [ revoking , setRevoking ] = useState < string | null > ( null ) ;
605- const [ actionErr , setActionErr ] = useState < string | null > ( null ) ;
606596
607597 const load = useCallback ( async ( ) => {
608598 try {
@@ -617,86 +607,29 @@ function ConnectorAgentGrants({ connectorId }: { connectorId: string }) {
617607
618608 useEffect ( ( ) => { void load ( ) ; } , [ load ] ) ;
619609
620- const revoke = async ( agentId : string ) => {
621- setRevoking ( agentId ) ;
622- setActionErr ( null ) ;
623- try {
624- await api . revokeConnectorAgentGrant ( connectorId , agentId ) ;
625- void load ( ) ;
626- } catch ( e ) {
627- setActionErr ( e instanceof Error ? e . message : String ( e ) ) ;
628- } finally {
629- setRevoking ( null ) ;
630- }
631- } ;
632-
633- // Agents that declare a requirement for this connector but have no grant yet.
634- const pendingAgents = useMemo ( ( ) =>
635- agents . filter ( ( a ) => {
636- if ( ! a . namespaced_agent_id ) return false ;
637- if ( a . namespaced_agent_id in grants ) return false ;
638- return a . required_connections ?. some ( ( rc ) => rc . connector_id === connectorId ) ?? false ;
639- } ) ,
640- [ agents , grants , connectorId ] ,
641- ) ;
642-
643610 if ( loading ) return null ;
644611
645- const grantedEntries = Object . entries ( grants ) ;
646- const hasAnything = grantedEntries . length > 0 || pendingAgents . length > 0 ;
612+ // Every agent that declares a requirement for this connector — granted or not.
613+ const relevantAgents = agents . filter (
614+ ( a ) => a . namespaced_agent_id && a . required_connections ?. some ( ( rc ) => rc . connector_id === connectorId ) ,
615+ ) ;
647616
648617 return (
649618 < div className = "connection-grants" >
650619 < div className = "grants-header" > Per-agent grants</ div >
651- { actionErr && (
652- < div className = "configure-error" style = { { marginBottom : 6 } } >
653- < AlertCircle size = { 12 } /> { actionErr }
654- </ div >
655- ) }
656- { ! hasAnything && (
657- < div className = "grants-empty" > No agents have been granted access yet.</ div >
658- ) }
659-
660- { /* Granted agents — compact rows */ }
661- { grantedEntries . map ( ( [ agentId , scopes ] ) => {
662- const agent = agents . find ( ( a ) => a . namespaced_agent_id === agentId ) ;
663- return (
664- < div key = { agentId } className = "grant-row" >
665- < span className = "grant-agent" > { agent ? agent . name : agentId } </ span >
666- < span className = "grant-scopes" >
667- { scopes . map ( scopeLabel ) . join ( ', ' ) }
668- </ span >
669- < button
670- className = "btn-grant-revoke"
671- disabled = { revoking === agentId }
672- onClick = { ( ) => void revoke ( agentId ) }
673- aria-label = { `Revoke ${ agentId } ` }
674- >
675- { revoking === agentId
676- ? < Loader2 size = { 11 } className = "spin" />
677- : < X size = { 11 } /> }
678- </ button >
679- </ div >
680- ) ;
681- } ) }
682-
683- { /* Pending agents — scope-picker cards */ }
684- { pendingAgents . map ( ( agent ) => {
685- const req = agent . required_connections ?. find (
686- ( rc ) => rc . connector_id === connectorId ,
687- ) ;
688- if ( ! req ) return null ;
689- return (
690- < PendingAgentRow
620+ { relevantAgents . length === 0 ? (
621+ < div className = "grants-empty" > No agents require access to this connector.</ div >
622+ ) : (
623+ relevantAgents . map ( ( agent ) => (
624+ < AgentGrantCard
691625 key = { agent . namespaced_agent_id }
692626 agent = { agent }
693627 connectorId = { connectorId }
694- allScopes = { req . scopes }
695- reason = { req . reason }
696- onGranted = { ( ) => void load ( ) }
628+ grantedScopes = { grants [ agent . namespaced_agent_id ! ] ?? [ ] }
629+ onChanged = { ( ) => void load ( ) }
697630 />
698- ) ;
699- } ) }
631+ ) )
632+ ) }
700633 </ div >
701634 ) ;
702635}
0 commit comments