diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx index 9e69bd2ed4..da1a87adec 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx @@ -38,7 +38,7 @@ import { useGatewayDeploy, } from '../../../../contexts/GatewayDeployContext'; import { GatewayDeployMainSection } from '../../../../Components/GatewayDeploy'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import LLLMStepBanner, { type LLLMStepBannerStepId, } from '../quickStart/lllmStepBanner'; @@ -47,6 +47,9 @@ import { buildProjectPath, } from '../../../../utils/projectRouting'; import { useAppShell } from '../../../../contexts/AppShellContext'; +import { useAppAuth } from '../../../../contexts/AppAuthContext'; +import { SCOPES } from '../../../../auth/permissions'; +import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; import { AIEntityProvider } from '../../../../contexts/AIEntitiesContext'; type ServiceProviderDeployLayoutProps = { @@ -58,7 +61,15 @@ function ServiceProviderDeployLayout({ providerId }: ServiceProviderDeployLayout const { provider } = useLLMProvider(); const { deployments, isLoadingDeployments } = useGatewayDeploy(); const { currentOrganization, currentProject } = useAppShell(); + const { hasPermission } = useAppAuth(); + const showSnackbar = useAIWorkspaceSnackbar(); + const intl = useIntl(); const isProjectLevel = Boolean(currentProject?.id); + // Mirrors `isAdminOrgLevel` on the overview page: it only renders the tabbed + // layout containing the API Keys section for org-level callers holding + // provider management permission. + const canManageApiKeys = + hasPermission(SCOPES.LLM_PROVIDER_MANAGE) && !isProjectLevel; const handleLLLMStepBannerClick = (stepId: LLLMStepBannerStepId) => { if (!providerId) return; @@ -66,6 +77,21 @@ function ServiceProviderDeployLayout({ providerId }: ServiceProviderDeployLayout return; } + if (stepId === 'consume' && !canManageApiKeys) { + // The overview page renders no API Keys section in this context, so + // navigating there would look like a dead button. Explain instead, and + // don't create a focus intent that nothing can honour. + showSnackbar( + intl.formatMessage({ + id: 'aiWorkspace.pages.appShell.appShellPages.serviceProvider.ServiceProviderDeploy.api.key.management.unavailable', + defaultMessage: + 'API key management is available only at the organization level with LLM provider management permission.', + }), + 'info' + ); + return; + } + const overviewPath = isProjectLevel ? buildProjectPath( currentOrganization, @@ -74,7 +100,13 @@ function ServiceProviderDeployLayout({ providerId }: ServiceProviderDeployLayout ) : buildOrgPath(currentOrganization, `/service-provider/${providerId}`); - navigate(overviewPath); + // The API Keys section lives on the overview page, so the consume step + // carries a one-time focus intent that the overview page consumes and + // clears on arrival. + navigate( + overviewPath, + stepId === 'consume' ? { state: { focusApiKeys: true } } : undefined + ); }; const stepBannerRefreshTrigger = useMemo(() => { if (isLoadingDeployments) return 'loading'; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx index 65a186795b..20101d6f4c 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx @@ -186,6 +186,9 @@ function parseOpenApiSpec(text: string): OpenApiSpec | null { type LocationState = { providerAdded?: boolean; + // One-time intent set by the "Consume LLM Provider" step on the deploy page. + // Consumed and cleared on arrival so a refresh doesn't re-trigger the scroll. + focusApiKeys?: boolean; }; type ProxyCreationNavigationState = { @@ -218,6 +221,8 @@ const tabs = [ 'Models', ]; +const API_KEY_HIGHLIGHT_DURATION_MS = 3000; + type RateLimitingDraftActions = { saveDraftChanges: () => Promise; discardDraftChanges: () => void; @@ -539,6 +544,57 @@ function ServiceProviderOverviewContent() { }; const [highlightApiKeySection, setHighlightApiKeySection] = useState(false); + const apiKeysSectionNodeRef = useRef(null); + // Set when a focus is requested before the section is mounted. The section is + // behind both the Overview tab panel (which unmounts inactive tabs) and an + // async `gateways.length > 0` guard, so on a tab switch or a fresh navigation + // the node doesn't exist yet. The callback ref below drains this flag as soon + // as it mounts, so no timeout or polling is needed. + const pendingApiKeysFocusRef = useRef(false); + // Last history entry whose navigation state was consumed, so an effect replay + // (React Strict Mode) can't re-run the snackbar or the scroll for that entry. + const processedNavigationStateKeyRef = useRef(null); + + // Scrolls to the section if it is mounted; otherwise the intent stays pending + // for `registerApiKeysSection` to pick up once it does mount. + const focusApiKeysNode = useCallback(() => { + const node = apiKeysSectionNodeRef.current; + if (!node) return; + // Cleared before scrolling so a single intent can never scroll twice + // (relevant under React Strict Mode's double effect/ref invocation). + pendingApiKeysFocusRef.current = false; + setHighlightApiKeySection(true); + node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, []); + + // Stable callback ref: React invokes it exactly when the section mounts or + // unmounts, making this render-aware rather than time-based. + const registerApiKeysSection = useCallback( + (node: HTMLDivElement | null) => { + apiKeysSectionNodeRef.current = node; + if (node && pendingApiKeysFocusRef.current) { + focusApiKeysNode(); + } + }, + [focusApiKeysNode] + ); + + const focusApiKeysSection = useCallback(() => { + setTabIndex(0); // API Keys live on the Overview tab. + pendingApiKeysFocusRef.current = true; + focusApiKeysNode(); + }, [focusApiKeysNode]); + + // Auto-clears the highlight and owns its own timer cleanup, so an unmount or + // a Strict Mode remount can't leave a stray timeout behind. + useEffect(() => { + if (!highlightApiKeySection) return; + const timer = setTimeout( + () => setHighlightApiKeySection(false), + API_KEY_HIGHLIGHT_DURATION_MS + ); + return () => clearTimeout(timer); + }, [highlightApiKeySection]); const handleDeleteConfirm = async () => { if (!deleteTarget || isDeletingProvider) return; @@ -618,9 +674,7 @@ function ServiceProviderOverviewContent() { ); navigate(deployPath); } else if (stepId === 'consume') { - setTabIndex(0); - setHighlightApiKeySection(true); - setTimeout(() => setHighlightApiKeySection(false), 3000); + focusApiKeysSection(); } }; @@ -653,11 +707,44 @@ function ServiceProviderOverviewContent() { useEffect(() => { const state = location.state as LocationState | null; - if (state?.providerAdded) { + if (!state?.providerAdded && !state?.focusApiKeys) return; + // The replace navigation below is async, so a replayed effect still sees this + // state. Claim the entry first — both runs then resolve to a single pass. + if (processedNavigationStateKeyRef.current === location.key) return; + processedNavigationStateKeyRef.current = location.key; + + if (state.providerAdded) { showSnackbar('Successfully added new service provider.', 'success'); - navigate(location.pathname, { replace: true, state: null }); } - }, [location.pathname, location.state, navigate]); + if (state.focusApiKeys) { + focusApiKeysSection(); + } + + // Drop only the consumed flags so unrelated router state survives, and keep + // search/hash so replacing the entry doesn't discard query parameters. + const remainingState: Record = { ...state }; + delete remainingState.providerAdded; + delete remainingState.focusApiKeys; + navigate( + { + pathname: location.pathname, + search: location.search, + hash: location.hash, + }, + { + replace: true, + state: Object.keys(remainingState).length > 0 ? remainingState : null, + } + ); + }, [ + location.key, + location.pathname, + location.search, + location.hash, + location.state, + navigate, + focusApiKeysSection, + ]); useEffect(() => { if (hasUnsavedChanges) return; @@ -1586,6 +1673,7 @@ function ServiceProviderOverviewContent() { setStepBannerRefreshTrigger((prev) => prev + 1) } highlightApiKeySection={highlightApiKeySection} + apiKeysSectionRef={registerApiKeysSection} onCreateProxy={handleCreateProxyClick} onBlockedNavigation={handleDeployNavigation} /> diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx index 4c7358a361..c229abaedf 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx @@ -16,7 +16,14 @@ * under the License. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Ref, +} from 'react'; import { useNavigate } from 'react-router-dom'; import { Alert, @@ -128,6 +135,7 @@ function buildApiKeyResourceName(displayName: string): string { type ServiceProviderOverviewTabProps = { onApiKeyCreated?: () => void; highlightApiKeySection?: boolean; + apiKeysSectionRef?: Ref; onCreateProxy?: () => void; onBlockedNavigation?: () => void; }; @@ -135,6 +143,7 @@ type ServiceProviderOverviewTabProps = { export default function ServiceProviderOverviewTab({ onApiKeyCreated, highlightApiKeySection, + apiKeysSectionRef, onCreateProxy, onBlockedNavigation, }: ServiceProviderOverviewTabProps) { @@ -852,7 +861,7 @@ export default function ServiceProviderOverviewTab({ - +