diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml
index 31ef9f8068..e7aa79d4f7 100644
--- a/portals/ai-workspace/docker-compose.yaml
+++ b/portals/ai-workspace/docker-compose.yaml
@@ -15,7 +15,7 @@
services:
platform-api:
- image: ghcr.io/wso2/api-platform/platform-api:0.16.0-SNAPSHOT
+ image: ghcr.io/wso2/api-platform/platform-api:0.17.0-SNAPSHOT
restart: unless-stopped
profiles: ["platform-api"]
command: ["-config", "/etc/platform-api/config.toml"]
diff --git a/portals/ai-workspace/src/Components/GatewayDeploy/GatewayDeployMainSection.tsx b/portals/ai-workspace/src/Components/GatewayDeploy/GatewayDeployMainSection.tsx
index 533a7bce6b..4c0083fd04 100644
--- a/portals/ai-workspace/src/Components/GatewayDeploy/GatewayDeployMainSection.tsx
+++ b/portals/ai-workspace/src/Components/GatewayDeploy/GatewayDeployMainSection.tsx
@@ -13,7 +13,13 @@
import React, { useMemo, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
-import { Box, Button, CircularProgress, Typography } from '@wso2/oxygen-ui';
+import {
+ Alert,
+ Box,
+ Button,
+ CircularProgress,
+ Typography,
+} from '@wso2/oxygen-ui';
import { Plus } from '@wso2/oxygen-ui-icons-react';
import { FormattedMessage } from 'react-intl';
import type { Gateway } from '../../apis/gateway/types';
@@ -39,7 +45,8 @@ interface GatewayDeployMainSectionProps {
export default function GatewayDeployMainSection({
showConfigureOption = true,
}: GatewayDeployMainSectionProps = {}) {
- const { gateways, isLoading, error } = useGatewayDeploy();
+ const { gateways, isLoading, error, canDeploy, canViewDeployments } =
+ useGatewayDeploy();
const { currentOrganization } = useAppShell();
const [searchQuery, setSearchQuery] = useState('');
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
@@ -78,6 +85,22 @@ export default function GatewayDeployMainSection({
);
}
+ // Without the deployment-read scope the gateway list is empty by design, not
+ // because the organization has no gateways — say so instead of inviting the
+ // user to create one they wouldn't be able to deploy to or even see.
+ if (!canViewDeployments) {
+ return (
+
+
+
+
+
+ );
+ }
+
if (gateways.length === 0) {
return (
+ {/* The deploy/redeploy/restore/undeploy controls below are all disabled in
+ this case; without this the page would read as broken rather than as a
+ permission boundary. */}
+ {!canDeploy && (
+
+
+
+ )}
))}
diff --git a/portals/ai-workspace/src/Components/common/PartialLoadWarning.tsx b/portals/ai-workspace/src/Components/common/PartialLoadWarning.tsx
new file mode 100644
index 0000000000..7425234370
--- /dev/null
+++ b/portals/ai-workspace/src/Components/common/PartialLoadWarning.tsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Alert, Button } from '@wso2/oxygen-ui';
+import { RefreshCcw } from '@wso2/oxygen-ui-icons-react';
+
+interface PartialLoadWarningProps {
+ /** Short, user-facing note about which source could not be loaded. */
+ message: string;
+ onRetry: () => void;
+ retryLabel?: string;
+}
+
+/**
+ * Non-blocking notice for a list assembled from several independent sources
+ * (for example, Policy Hub policies plus the organization's custom policies).
+ * One source failing must not hide the rest, so the caller keeps rendering
+ * whatever loaded and shows this above it with a retry for the failed source.
+ */
+export default function PartialLoadWarning({
+ message,
+ onRetry,
+ retryLabel = 'Retry',
+}: PartialLoadWarningProps) {
+ return (
+ }
+ onClick={onRetry}
+ >
+ {retryLabel}
+
+ }
+ >
+ {message}
+
+ );
+}
diff --git a/portals/ai-workspace/src/auth/permissions.ts b/portals/ai-workspace/src/auth/permissions.ts
index 19147cbf45..7db037139b 100644
--- a/portals/ai-workspace/src/auth/permissions.ts
+++ b/portals/ai-workspace/src/auth/permissions.ts
@@ -200,6 +200,33 @@ export const SCOPES = {
SECRET_MANAGE: 'ap:secret:manage',
} as const;
+/**
+ * Deployment scopes per deployable AI artifact kind, keyed by the same resource
+ * type `GatewayDeployProvider` takes. Shared by the deploy page/context and the
+ * "Deploy to Gateway" entry points on the overview pages, so one map decides
+ * both whether the deploy actions render and whether the button leading to them
+ * is reachable.
+ */
+export const DEPLOYMENT_SCOPES = {
+ provider: {
+ read: SCOPES.LLM_PROVIDER_DEPLOYMENT_READ,
+ create: SCOPES.LLM_PROVIDER_DEPLOYMENT_CREATE,
+ delete: SCOPES.LLM_PROVIDER_DEPLOYMENT_DELETE,
+ },
+ proxy: {
+ read: SCOPES.LLM_PROXY_DEPLOYMENT_READ,
+ create: SCOPES.LLM_PROXY_DEPLOYMENT_CREATE,
+ delete: SCOPES.LLM_PROXY_DEPLOYMENT_DELETE,
+ },
+ 'mcp-server': {
+ read: SCOPES.MCP_PROXY_DEPLOYMENT_READ,
+ create: SCOPES.MCP_PROXY_DEPLOYMENT_CREATE,
+ delete: SCOPES.MCP_PROXY_DEPLOYMENT_DELETE,
+ },
+} as const;
+
+export type DeployableResourceType = keyof typeof DEPLOYMENT_SCOPES;
+
/**
* Scopes that must be held explicitly and are never derived from a broader
* `:manage`. `ap:api_key:all:manage` is an ownership override (it widens which
diff --git a/portals/ai-workspace/src/contexts/GatewayDeployContext.tsx b/portals/ai-workspace/src/contexts/GatewayDeployContext.tsx
index fd7b062f93..8c839fd722 100644
--- a/portals/ai-workspace/src/contexts/GatewayDeployContext.tsx
+++ b/portals/ai-workspace/src/contexts/GatewayDeployContext.tsx
@@ -23,6 +23,11 @@ import {
} from 'react';
import { logger } from '../utils/logger';
import { useAppShell } from './AppShellContext';
+import { useAppAuth } from './AppAuthContext';
+import {
+ DEPLOYMENT_SCOPES,
+ type DeployableResourceType,
+} from '../auth/permissions';
import { getGateways } from '../apis/gatewayApis';
import {
getLLMProviderDeployments,
@@ -64,7 +69,7 @@ import {
export type { HybridGateway, GatewayDeployment };
-type GatewayDeployResourceType = 'provider' | 'proxy' | 'mcp-server';
+type GatewayDeployResourceType = DeployableResourceType;
const POLL_INTERVAL_MS = 2000;
@@ -173,11 +178,19 @@ interface GatewayDeployContextValue {
isPollingGateway: (gatewayId: string) => boolean;
/**
- * When true, the artifact is read-only (e.g. gateway-originated): its deployment
- * lifecycle is owned by the gateway. The deployments remain viewable, but deploy/
- * redeploy/restore/undeploy actions are disabled.
+ * When true, the artifact's deployment lifecycle is not writable from here —
+ * either because the artifact is gateway-originated (owned by the gateway) or
+ * because the signed-in user lacks the deployment-create scope for this
+ * resource kind. The deployments remain viewable, but deploy/redeploy/restore/
+ * undeploy actions are disabled.
*/
readOnly: boolean;
+ /** True when the user holds the deployment-create scope for this resource kind. */
+ canDeploy: boolean;
+ /** True when the user holds the deployment-read scope for this resource kind. */
+ canViewDeployments: boolean;
+ /** True when the user holds the deployment-delete scope for this resource kind. */
+ canDelete: boolean;
}
const GatewayDeployContext = createContext(
@@ -187,7 +200,11 @@ const GatewayDeployContext = createContext(
interface GatewayDeployProviderProps {
apiId: string;
resourceType?: GatewayDeployResourceType;
- /** Disable deploy/redeploy/restore/undeploy actions while keeping deployments visible. */
+ /**
+ * Disable deploy/redeploy/restore/undeploy actions while keeping deployments
+ * visible. The user's scopes are checked independently and can force this on
+ * even when the caller passes `false`.
+ */
readOnly?: boolean;
children: ReactNode;
}
@@ -199,8 +216,17 @@ export function GatewayDeployProvider({
children,
}: GatewayDeployProviderProps) {
const { currentOrganization } = useAppShell();
+ const { hasPermission } = useAppAuth();
const organizationId = currentOrganization?.uuid ?? '';
+ // Permission is the floor, not an override: a caller passing readOnly={false}
+ // (or omitting it) still cannot get writable actions without the scope, so a
+ // viewer sees the same read-only deploy surface as a gateway-owned artifact.
+ const canViewDeployments = hasPermission(DEPLOYMENT_SCOPES[resourceType].read);
+ const canDeploy = hasPermission(DEPLOYMENT_SCOPES[resourceType].create);
+ const canDelete = hasPermission(DEPLOYMENT_SCOPES[resourceType].delete);
+ const isReadOnly = readOnly || !canDeploy;
+
const [gateways, setGateways] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
@@ -223,6 +249,15 @@ export function GatewayDeployProvider({
const pollingDeploymentsRef = useRef(pollingDeployments);
pollingDeploymentsRef.current = pollingDeployments;
+ /**
+ * Monotonic request tokens for the two loads. Every fetch captures the token it
+ * started with and discards its own result if the token has since moved on, so a
+ * response that lands after access was lost (or after a newer load started)
+ * cannot repopulate gateways/deployments behind an access message.
+ */
+ const gatewaysRequestRef = useRef(0);
+ const deploymentsRequestRef = useRef(0);
+
/**
* Deployments whose poll window expired while still transitional — the gateway's
* acknowledgement never arrived, so they are surfaced as FAILED. The override only
@@ -235,6 +270,11 @@ export function GatewayDeployProvider({
const fetchSingleDeploymentStatus = useCallback(
async (deploymentId: string): Promise => {
+ // No deployment-read scope means no readable status; fail the read here
+ // rather than issuing a call that can only 403.
+ if (!canViewDeployments) {
+ throw new Error('Not permitted to read deployment status');
+ }
if (resourceType === 'proxy') {
return getLLMProxyDeployment(apiId, deploymentId, organizationId, PLATFORM_API_BASE_URL);
} else if (resourceType === 'mcp-server') {
@@ -242,7 +282,7 @@ export function GatewayDeployProvider({
}
return getLLMProviderDeployment(apiId, deploymentId, organizationId, PLATFORM_API_BASE_URL);
},
- [apiId, organizationId, resourceType]
+ [apiId, organizationId, resourceType, canViewDeployments]
);
/**
@@ -290,14 +330,24 @@ export function GatewayDeployProvider({
);
const fetchGateways = useCallback(async () => {
- if (!organizationId) {
+ // A user without the deployment-read scope has no readable deploy surface,
+ // so don't issue the gateway/deployment reads at all — they would only 403.
+ if (!organizationId || !canViewDeployments) {
+ // Bumping the token strands any in-flight load: its result is discarded
+ // instead of repopulating the list behind the access message. The error is
+ // cleared too, so consumers show "no access" rather than a stale failure.
+ gatewaysRequestRef.current += 1;
+ setGateways([]);
+ setError(null);
setIsLoading(false);
return;
}
+ const requestId = (gatewaysRequestRef.current += 1);
setIsLoading(true);
setError(null);
try {
const response = await getGateways(organizationId);
+ if (gatewaysRequestRef.current !== requestId) return;
const fetchedGateways: HybridGateway[] = (response.list || []).map(
(gateway) => ({
...gateway,
@@ -308,25 +358,38 @@ export function GatewayDeployProvider({
);
setGateways(fetchedGateways);
} catch (err) {
+ if (gatewaysRequestRef.current !== requestId) return;
logger.error('Failed to fetch hybrid gateways:', err);
setError(
err instanceof Error ? err : new Error('Failed to fetch gateways')
);
setGateways([]);
} finally {
- setIsLoading(false);
+ if (gatewaysRequestRef.current === requestId) {
+ setIsLoading(false);
+ }
}
- }, [organizationId]);
+ }, [organizationId, canViewDeployments]);
useEffect(() => {
fetchGateways();
}, [fetchGateways]);
const refetchDeployments = useCallback(async () => {
- if (!apiId || !organizationId) {
+ if (!apiId || !organizationId || !canViewDeployments) {
+ // Strand any in-flight load so its continuation can't repopulate the list
+ // or restart polling from stale transitional statuses.
+ deploymentsRequestRef.current += 1;
setDeployments(null);
+ setDeploymentsError(null);
+ setIsLoadingDeployments(false);
+ // Drop any in-flight status watches: without the read scope every poll
+ // would only 403, so stop them instead of retrying until they expire.
+ setPollingDeployments((prev) => (prev.size === 0 ? prev : new Map()));
+ setTimedOutDeployments((prev) => (prev.size === 0 ? prev : new Set()));
return;
}
+ const requestId = (deploymentsRequestRef.current += 1);
setIsLoadingDeployments(true);
setDeploymentsError(null);
try {
@@ -355,6 +418,7 @@ export function GatewayDeployProvider({
);
const deploymentResponses = await Promise.all(deploymentPromises);
+ if (deploymentsRequestRef.current !== requestId) return;
const allDeployments = deploymentResponses.flatMap(
(response) => response.list
);
@@ -366,17 +430,21 @@ export function GatewayDeployProvider({
organizationId,
PLATFORM_API_BASE_URL
);
+ if (deploymentsRequestRef.current !== requestId) return;
setDeployments(result);
}
} catch (err) {
+ if (deploymentsRequestRef.current !== requestId) return;
logger.error(`Failed to fetch LLM ${resourceType} deployments:`, err);
setDeploymentsError(
err instanceof Error ? err : new Error('Failed to fetch deployments')
);
} finally {
- setIsLoadingDeployments(false);
+ if (deploymentsRequestRef.current === requestId) {
+ setIsLoadingDeployments(false);
+ }
}
- }, [apiId, organizationId, resourceType, gateways]);
+ }, [apiId, organizationId, resourceType, gateways, canViewDeployments]);
useEffect(() => {
if (apiId) {
@@ -423,7 +491,7 @@ export function GatewayDeployProvider({
* poll window expires.
*/
useEffect(() => {
- if (pollingDeployments.size === 0) return;
+ if (pollingDeployments.size === 0 || !canViewDeployments) return;
let cancelled = false;
@@ -496,11 +564,20 @@ export function GatewayDeployProvider({
cancelled = true;
clearInterval(intervalId);
};
- }, [pollingDeployments, fetchSingleDeploymentStatus, refetchDeployments]);
+ }, [
+ pollingDeployments,
+ fetchSingleDeploymentStatus,
+ refetchDeployments,
+ canViewDeployments,
+ ]);
const deployToGateway = useCallback(
async (gatewayId: string, host: string): Promise => {
if (!apiId || !organizationId) return false;
+ if (isReadOnly) {
+ logger.warn('Deploy blocked: deployment lifecycle is read-only here.');
+ return false;
+ }
setDeployingGatewayId(gatewayId);
try {
@@ -588,12 +665,17 @@ export function GatewayDeployProvider({
refetchDeployments,
startPolling,
resourceType,
+ isReadOnly,
]
);
const undeployDeployment = useCallback(
async (deploymentId: string, gatewayId: string): Promise => {
if (!apiId || !organizationId || !deploymentId) return false;
+ if (isReadOnly) {
+ logger.warn('Undeploy blocked: deployment lifecycle is read-only here.');
+ return false;
+ }
setDeployingGatewayId(gatewayId);
try {
@@ -657,12 +739,17 @@ export function GatewayDeployProvider({
fetchSingleDeploymentStatus,
startPolling,
resourceType,
+ isReadOnly,
]
);
const redeployDeployment = useCallback(
async (deploymentId: string, gatewayId: string): Promise => {
if (!apiId || !organizationId || !deploymentId) return false;
+ if (isReadOnly) {
+ logger.warn('Redeploy blocked: deployment lifecycle is read-only here.');
+ return false;
+ }
setDeployingGatewayId(gatewayId);
try {
@@ -722,12 +809,19 @@ export function GatewayDeployProvider({
refetchDeployments,
startPolling,
resourceType,
+ isReadOnly,
]
);
const deleteDeployment = useCallback(
async (deploymentId: string): Promise => {
if (!apiId || !organizationId || !deploymentId) return false;
+ if (!canDelete) {
+ logger.warn(
+ 'Deployment delete blocked: missing deployment-delete scope.'
+ );
+ return false;
+ }
// Block deletion when deployment is DEPLOYED
const deployment = deployments?.list.find(
@@ -777,7 +871,14 @@ export function GatewayDeployProvider({
return false;
}
},
- [apiId, organizationId, deployments, refetchDeployments, resourceType]
+ [
+ apiId,
+ organizationId,
+ deployments,
+ refetchDeployments,
+ resourceType,
+ canDelete,
+ ]
);
const value = useMemo(
@@ -797,7 +898,10 @@ export function GatewayDeployProvider({
deployingGatewayId,
isDeployingToGateway,
isPollingGateway,
- readOnly,
+ readOnly: isReadOnly,
+ canDeploy,
+ canViewDeployments,
+ canDelete,
}),
[
gateways,
@@ -815,7 +919,10 @@ export function GatewayDeployProvider({
deployingGatewayId,
isDeployingToGateway,
isPollingGateway,
- readOnly,
+ isReadOnly,
+ canDeploy,
+ canViewDeployments,
+ canDelete,
]
);
diff --git a/portals/ai-workspace/src/contexts/GatewayPoliciesContext.tsx b/portals/ai-workspace/src/contexts/GatewayPoliciesContext.tsx
index ee333294b6..8f74708a4c 100644
--- a/portals/ai-workspace/src/contexts/GatewayPoliciesContext.tsx
+++ b/portals/ai-workspace/src/contexts/GatewayPoliciesContext.tsx
@@ -26,7 +26,12 @@ export type GatewayPolicyRow = {
version: string;
description: string;
policyType: "Policy Hub" | "Custom";
- syncStatus: "N/A" | "Synced" | "Not synced";
+ /**
+ * "Unknown" is used when the organization's custom policies could not be read
+ * (no scope, or a failed request), so whether this policy is synced cannot be
+ * determined — distinct from a policy known to be unsynced.
+ */
+ syncStatus: "N/A" | "Synced" | "Not synced" | "Unknown";
customPolicyId?: string;
};
@@ -34,11 +39,22 @@ type GatewayPoliciesContextValue = {
policies: GatewayPolicyRow[];
isLoading: boolean;
error: Error | null;
+ /**
+ * Non-fatal load failures: the manifest arrived but a supplementary source
+ * (the org's custom policies, or the Policy Hub catalogue) did not. The rows
+ * are still usable, so these are shown as warnings instead of replacing the
+ * table with an error.
+ */
+ warnings: string[];
refresh: () => Promise;
syncPolicy: (policyName: string, version: string) => Promise;
syncingPolicyKey: string | null;
- /** False when the caller lacks the scopes the policy view reads. */
+ /** False when the caller can read neither source the policy view lists. */
canViewPolicies: boolean;
+ /** False when the caller cannot read this gateway's policy manifest. */
+ canViewManifest: boolean;
+ /** False when the caller cannot read the organization's custom policies. */
+ canViewCustomPolicies: boolean;
/** False when the caller may view policies but not sync them into the org. */
canSyncPolicies: boolean;
};
@@ -58,6 +74,19 @@ function mergePolicies(
manifestPolicies: GatewayManifestPolicy[],
customPolicies: GatewayCustomPolicy[],
hubPolicies: PolicyHubPolicy[],
+ options: {
+ /**
+ * True when the org's custom policies could not be read, so sync status is
+ * reported as "Unknown" rather than being inferred from an empty list.
+ */
+ syncStatusUnknown: boolean;
+ /**
+ * True when the manifest could not be read. The org's custom policies then
+ * become the only listable source, so they are surfaced as rows on their own
+ * instead of only enriching manifest rows.
+ */
+ listCustomPoliciesAsRows: boolean;
+ },
): GatewayPolicyRow[] {
const rows = new Map();
const hubPolicyByKey = new Map(
@@ -81,11 +110,36 @@ function mergePolicies(
version: policy.version,
description: policy.description || hubPolicy?.description || "—",
policyType: isCustomPolicy ? "Custom" : "Policy Hub",
- syncStatus: isCustomPolicy ? (syncedPolicy ? "Synced" : "Not synced") : "N/A",
+ syncStatus: !isCustomPolicy
+ ? "N/A"
+ : options.syncStatusUnknown
+ ? "Unknown"
+ : syncedPolicy
+ ? "Synced"
+ : "Not synced",
customPolicyId: syncedPolicy?.uuid,
});
});
+ if (options.listCustomPoliciesAsRows) {
+ customPolicies.forEach((policy) => {
+ const key = policyKey(policy.name, policy.version);
+ if (rows.has(key)) return;
+ const hubPolicy = hubPolicyByKey.get(key);
+ rows.set(key, {
+ key,
+ policyName: policy.name,
+ name: policy.displayName || hubPolicy?.displayName || policy.name,
+ version: policy.version,
+ description: policy.description || hubPolicy?.description || "—",
+ policyType: "Custom",
+ // Present in the organization by definition — that is what this list is.
+ syncStatus: "Synced",
+ customPolicyId: policy.uuid,
+ });
+ });
+ }
+
return [...rows.values()].sort((left, right) => left.name.localeCompare(right.name));
}
@@ -93,45 +147,115 @@ export function GatewayPoliciesProvider({ gatewayId, children }: { gatewayId: st
const [policies, setPolicies] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ const [warnings, setWarnings] = useState([]);
const [syncingPolicyKey, setSyncingPolicyKey] = useState(null);
- // This view reads the gateway manifest and the org's custom policies. Without
- // both scopes the platform API returns 403, so skip the request entirely and
- // let the consumer render a permission notice instead of a load failure.
+ // This view lists two sources: the gateway's policy manifest and the org's
+ // custom policies. They are scoped independently, so each is requested only
+ // when its own scope is held — holding just one still gives a usable (if
+ // partial) list, and the missing half is reported as a warning. Only when
+ // neither is readable does the consumer fall back to a permission notice.
const { hasPermission } = useAppAuth();
- const canViewPolicies =
- hasPermission(SCOPES.GATEWAY_MANIFEST_READ) &&
- hasPermission(SCOPES.GATEWAY_CUSTOM_POLICY_READ);
+ const canViewManifest = hasPermission(SCOPES.GATEWAY_MANIFEST_READ);
+ const canViewCustomPolicies = hasPermission(SCOPES.GATEWAY_CUSTOM_POLICY_READ);
+ const canViewPolicies = canViewManifest || canViewCustomPolicies;
const canSyncPolicies = hasPermission(SCOPES.GATEWAY_CUSTOM_POLICY_CREATE);
const refresh = useCallback(async () => {
if (!gatewayId) return;
if (!canViewPolicies) {
setPolicies([]);
+ setWarnings([]);
+ setError(null);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
- try {
- const [manifest, customResponse, hubResponse] = await Promise.all([
- getGatewayPolicyManifest(gatewayId),
- getGatewayCustomPolicies(),
- getPolicies(),
- ]);
- setPolicies(
- mergePolicies(
- manifest.policies || [],
- customResponse.list || [],
- hubResponse.data || [],
- ),
+ setWarnings([]);
+
+ // Every readable source starts together. The manifest is awaited on its own
+ // so its failure is known without waiting on the supplementary requests;
+ // `supplementary` is an allSettled promise, so it never rejects even when the
+ // manifest fails first. Neither source is fatal by itself — the table is
+ // rendered from whichever ones arrived, and the rest become warnings.
+ const manifestPromise = canViewManifest
+ ? getGatewayPolicyManifest(gatewayId)
+ : null;
+ const supplementary = Promise.allSettled([
+ canViewCustomPolicies ? getGatewayCustomPolicies() : Promise.resolve(null),
+ getPolicies(),
+ ]);
+
+ let manifestPolicies: GatewayManifestPolicy[] | null = null;
+ let manifestFailure: unknown = null;
+ if (manifestPromise) {
+ try {
+ manifestPolicies = (await manifestPromise).policies || [];
+ } catch (cause) {
+ manifestFailure = cause;
+ }
+ }
+ const [customResult, hubResult] = await supplementary;
+
+ const customPolicies =
+ customResult.status === "fulfilled" ? customResult.value?.list || [] : [];
+ const hubPolicies =
+ hubResult.status === "fulfilled" ? hubResult.value.data || [] : [];
+ const customPoliciesUnavailable =
+ !canViewCustomPolicies || customResult.status === "rejected";
+
+ // Nothing listable arrived from either source — that, and only that, is a
+ // full failure. Anything else degrades to a partial list plus a warning.
+ if (manifestPolicies === null && customPoliciesUnavailable) {
+ const cause =
+ manifestFailure ??
+ (customResult.status === "rejected" ? customResult.reason : null);
+ setPolicies([]);
+ setWarnings([]);
+ setError(
+ cause instanceof Error
+ ? cause
+ : new Error("Failed to load gateway policies"),
);
- } catch (cause) {
- setError(cause instanceof Error ? cause : new Error("Failed to load gateway policies"));
- } finally {
setIsLoading(false);
+ return;
}
- }, [gatewayId, canViewPolicies]);
+
+ const nextWarnings: string[] = [];
+ if (!canViewManifest) {
+ nextWarnings.push(
+ "Policies installed on this gateway are not shown — you do not have permission to read the gateway manifest. Only custom policies synced to the organization are listed.",
+ );
+ } else if (manifestFailure) {
+ nextWarnings.push(
+ "The gateway policy manifest could not be loaded, so policies installed on the gateway are not listed.",
+ );
+ }
+ if (!canViewCustomPolicies) {
+ nextWarnings.push(
+ "Sync status is not shown - you do not have permission to read the organization's custom policies.",
+ );
+ } else if (customResult.status === "rejected") {
+ nextWarnings.push(
+ "Custom policies could not be loaded, so sync status is not shown.",
+ );
+ }
+ if (hubResult.status === "rejected") {
+ nextWarnings.push(
+ "Policy Hub details could not be loaded, so some names and descriptions may be missing.",
+ );
+ }
+
+ setWarnings(nextWarnings);
+ setPolicies(
+ mergePolicies(manifestPolicies || [], customPolicies, hubPolicies, {
+ syncStatusUnknown: customPoliciesUnavailable,
+ listCustomPoliciesAsRows: manifestPolicies === null,
+ }),
+ );
+ setIsLoading(false);
+ }, [gatewayId, canViewPolicies, canViewManifest, canViewCustomPolicies]);
useEffect(() => { void refresh(); }, [refresh]);
@@ -160,20 +284,26 @@ export function GatewayPoliciesProvider({ gatewayId, children }: { gatewayId: st
policies,
isLoading,
error,
+ warnings,
refresh,
syncPolicy,
syncingPolicyKey,
canViewPolicies,
+ canViewManifest,
+ canViewCustomPolicies,
canSyncPolicies,
}),
[
policies,
isLoading,
error,
+ warnings,
refresh,
syncPolicy,
syncingPolicyKey,
canViewPolicies,
+ canViewManifest,
+ canViewCustomPolicies,
canSyncPolicies,
],
);
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx
index 5d0f6d91e2..e974438316 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx
@@ -109,7 +109,11 @@ import {
countActiveDeployments,
} from '../../../../utils/artifactDeletion';
import { useAppAuth } from '../../../../contexts/AppAuthContext';
-import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions';
+import {
+ DISABLED_ACTION_SX,
+ NO_PERMISSION_TOOLTIP,
+ SCOPES,
+} from '../../../../auth/permissions';
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
@@ -242,6 +246,8 @@ export default function ExternalServersOverview(): JSX.Element {
const showSnackbar = useAIWorkspaceSnackbar();
const { hasPermission } = useAppAuth();
const canDeleteMcpProxy = hasPermission(SCOPES.MCP_PROXY_DELETE);
+ const canDeployMcpProxy = hasPermission(SCOPES.MCP_PROXY_DEPLOYMENT_CREATE);
+ const canViewDeployments = hasPermission(SCOPES.MCP_PROXY_DEPLOYMENT_READ);
const [server, setServer] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isSavingChanges, setIsSavingChanges] = useState(false);
@@ -1085,27 +1091,36 @@ export default function ExternalServersOverview(): JSX.Element {
alignItems="flex-end"
sx={{ alignSelf: 'stretch' }}
>
- {/* For gateway-created (read-only) proxies the deployments remain viewable
- (deploy/redeploy/restore/undeploy are disabled on the page itself), so
- the button navigates but is relabelled "View Deployments". */}
-
+
+
@@ -115,9 +119,32 @@ export default function GatewayPolicies() {
);
}
if (!policies.length)
- return No gateway manifest received yet.;
+ return (
+ <>
+ {warnings.map((warning) => (
+ void refresh()}
+ />
+ ))}
+
+ {canViewManifest
+ ? "No gateway manifest received yet."
+ : "No custom policies have been synced to this organization yet."}
+
+ >
+ );
return (
+ <>
+ {warnings.map((warning) => (
+ void refresh()}
+ />
+ ))}
@@ -161,6 +188,12 @@ export default function GatewayPolicies() {
N/A
+ ) : policy.syncStatus === "Unknown" ? (
+
+
+ Unknown
+
+
) : policy.syncStatus === "Synced" ? (
+ >
);
}
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDeploymentsCard.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDeploymentsCard.tsx
index 8525a4e4b3..bfe815295d 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDeploymentsCard.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDeploymentsCard.tsx
@@ -56,6 +56,8 @@ import {
DisabledActionTooltip,
GATEWAY_MANAGED_ARTIFACT_TOOLTIP,
} from '../../../../utils/readOnlyArtifacts';
+import { useAppAuth } from '../../../../contexts/AppAuthContext';
+import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions';
interface GatewayWithDeployment extends Gateway {
deployment?: DeploymentResponse;
@@ -72,6 +74,8 @@ export default function LLMProxyDeploymentsCard() {
const [generatedKey, setGeneratedKey] = useState(null);
const [keyError, setKeyError] = useState(null);
const isReadOnlyProxy = Boolean(proxy?.readOnly);
+ const { hasPermission } = useAppAuth();
+ const canCreateProxyApiKey = hasPermission(SCOPES.LLM_PROXY_API_KEY_CREATE);
const apiKeyLocation = proxy?.security?.apiKey?.in ?? 'header';
const apiKeyName = proxy?.security?.apiKey?.key ?? 'X-API-Key';
@@ -377,12 +381,18 @@ export default function LLMProxyDeploymentsCard() {
(['AI']);
const [drawerGuardrails, setDrawerGuardrails] = useState([]);
const [drawerGuardrailsLoading, setDrawerGuardrailsLoading] = useState(false);
+ const [drawerGuardrailsError, setDrawerGuardrailsError] = useState(null);
const [customPolicies, setCustomPolicies] = useState([]);
const [customPoliciesLoading, setCustomPoliciesLoading] = useState(false);
+ const [customPoliciesError, setCustomPoliciesError] = useState(null);
const [draggedGlobalPolicyIndex, setDraggedGlobalPolicyIndex] = useState<
number | null
>(null);
@@ -265,8 +266,12 @@ export default function LLMProxyGuardrailsTab() {
policyIndex: number;
} | null>(null);
+ // The drawer list is assembled from two independent sources. Each records its
+ // own failure so one being down still shows the other's policies, with a
+ // retry for just the failed source.
const fetchDrawerGuardrails = useCallback(async (categories: string[]) => {
setDrawerGuardrailsLoading(true);
+ setDrawerGuardrailsError(null);
try {
const showAll =
categories.length === 0 ||
@@ -275,8 +280,12 @@ export default function LLMProxyGuardrailsTab() {
? await getPolicies()
: await getGuardrails(categories.join(','));
setDrawerGuardrails(response.data);
- } catch {
+ } catch (e) {
+ logger.error('Failed to load Policy Hub guardrails:', e);
setDrawerGuardrails([]);
+ setDrawerGuardrailsError(
+ e instanceof Error ? e : new Error('Failed to load guardrails')
+ );
} finally {
setDrawerGuardrailsLoading(false);
}
@@ -284,12 +293,16 @@ export default function LLMProxyGuardrailsTab() {
const fetchCustomPolicies = useCallback(async () => {
setCustomPoliciesLoading(true);
+ setCustomPoliciesError(null);
try {
const response = await getGatewayCustomPolicies();
setCustomPolicies(response.list || []);
} catch (e) {
logger.error('Failed to load custom policies:', e);
setCustomPolicies([]);
+ setCustomPoliciesError(
+ e instanceof Error ? e : new Error('Failed to load custom policies')
+ );
} finally {
setCustomPoliciesLoading(false);
}
@@ -1394,28 +1407,10 @@ export default function LLMProxyGuardrailsTab() {
- {isLoadingGuardrails ? (
-
-
-
-
-
-
- ) : guardrailsError ? (
-
- {
- void refreshGuardrails();
- }}
- />
-
- ) : (
+ {/* The shared guardrails context only supplies display names, so
+ neither its loading nor its error state gates the drawer — the
+ drawer's own two sources report themselves below. */}
+ {(
<>
{!isDetailView ? (
<>
@@ -1446,6 +1441,23 @@ export default function LLMProxyGuardrailsTab() {
}}
/>
+ {!drawerItemsLoading && drawerGuardrailsError && (
+ {
+ void fetchDrawerGuardrails(selectedCategories);
+ void refreshGuardrails();
+ }}
+ />
+ )}
+ {!drawerItemsLoading && customPoliciesError && (
+ {
+ void fetchCustomPolicies();
+ }}
+ />
+ )}
{drawerItemsLoading ? (
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx
index 064e6ad99c..f11ede546a 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx
@@ -167,6 +167,8 @@ function ProxyOverviewContent() {
const showSnackbar = useAIWorkspaceSnackbar();
const { hasPermission } = useAppAuth();
const canDeleteProxy = hasPermission(SCOPES.LLM_PROXY_DELETE);
+ const canDeployProxy = hasPermission(SCOPES.LLM_PROXY_DEPLOYMENT_CREATE);
+ const canViewDeployments = hasPermission(SCOPES.LLM_PROXY_DEPLOYMENT_READ);
const canUpdateProxy = hasPermission(SCOPES.LLM_PROXY_UPDATE);
const navigate = useNavigate();
const location = useLocation();
@@ -521,16 +523,27 @@ function ProxyOverviewContent() {
alignItems="flex-end"
sx={{ alignSelf: 'stretch' }}
>
- {/* Deployments remain viewable for gateway-created proxies (deploy/
- redeploy/restore/undeploy are disabled on the page itself), so the
- button navigates but is relabelled "View Deployments". */}
-
+
+
{
isMounted = false;
};
- }, [currentOrganization?.uuid, proxy?.id]);
+ }, [currentOrganization?.uuid, proxy?.id, canViewProxyDeployments]);
useEffect(() => {
setLatestGeneratedKey(null);
@@ -327,7 +338,9 @@ export default function LLMProxyOverviewTab() {
useEffect(() => {
const organizationId = currentOrganization?.uuid;
const proxyId = proxy?.id;
- if (!organizationId || !proxyId) {
+ // Without the api-key read scope the list request can only 403, which would
+ // surface as a failure snackbar rather than as a permission boundary.
+ if (!organizationId || !proxyId || !canReadProxyApiKey) {
fetchedApiKeysProxyIdRef.current = null;
fetchingApiKeysProxyIdRef.current = null;
setApiKeys([]);
@@ -377,7 +390,13 @@ export default function LLMProxyOverviewTab() {
setKeysLoading(false);
}
};
- }, [currentOrganization?.uuid, getProxyAPIKeys, proxy?.id, showSnackbar]);
+ }, [
+ currentOrganization?.uuid,
+ getProxyAPIKeys,
+ proxy?.id,
+ showSnackbar,
+ canReadProxyApiKey,
+ ]);
const handleCopyGatewayUrl = async () => {
if (!generatedGatewayUrl) return;
@@ -391,7 +410,8 @@ export default function LLMProxyOverviewTab() {
};
const handleGenerateAPIKey = async () => {
- if (isReadOnlyProxy) return;
+ // Re-checked at submit time: the dialog can be open across a permission change.
+ if (isReadOnlyProxy || !canCreateProxyApiKey) return;
if (!currentOrganization?.uuid || !proxy?.id) {
return;
}
@@ -418,15 +438,19 @@ export default function LLMProxyOverviewTab() {
setGeneratedKey(response.apiKey);
setLatestGeneratedKey(response.apiKey);
setIsApiKeyModalOpen(true);
- try {
- const refreshedApiKeys = await getProxyAPIKeys();
- setApiKeys(refreshedApiKeys.list || []);
- } catch (fetchError) {
- logger.error(
- `Failed to refresh API keys for proxy ${proxy.id}:`,
- fetchError
- );
- showSnackbar('Failed to refresh API keys.', 'error');
+ // A create-only user has no readable key list — skip the refresh instead
+ // of issuing a request that can only 403 and surface a false failure.
+ if (canReadProxyApiKey) {
+ try {
+ const refreshedApiKeys = await getProxyAPIKeys();
+ setApiKeys(refreshedApiKeys.list || []);
+ } catch (fetchError) {
+ logger.error(
+ `Failed to refresh API keys for proxy ${proxy.id}:`,
+ fetchError
+ );
+ showSnackbar('Failed to refresh API keys.', 'error');
+ }
}
} catch (apiKeyError) {
logger.error('Failed to generate API key:', apiKeyError);
@@ -481,7 +505,8 @@ export default function LLMProxyOverviewTab() {
};
const handleDeleteApiKey = async () => {
- if (isReadOnlyProxy || !deleteTargetKeyName || !proxy?.id) {
+ // Re-checked at submit time: the dialog can be open across a permission change.
+ if (isReadOnlyProxy || !canDeleteProxyApiKey || !deleteTargetKeyName || !proxy?.id) {
return;
}
@@ -686,12 +711,18 @@ export default function LLMProxyOverviewTab() {
Generate API Key
@@ -786,12 +819,18 @@ export default function LLMProxyOverviewTab() {
{
void handleDeleteApiKey();
}}
- disabled={isDeletingKey}
+ disabled={isDeletingKey || !canDeleteProxyApiKey}
>
{isDeletingKey ? (
<>
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/GuardrailsSection.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/GuardrailsSection.tsx
index 9061e841c1..7ca85d2226 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/GuardrailsSection.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/GuardrailsSection.tsx
@@ -55,6 +55,7 @@ import { parsePolicyYaml } from '../../../PolicyParameterEditor/yamlParser';
import type { GuardrailSelection } from './serviceProviderTypes';
import { FormattedMessage } from 'react-intl';
import ErrorAlert from '../../../../../Components/common/ErrorAlert';
+import PartialLoadWarning from '../../../../../Components/common/PartialLoadWarning';
import { autoAttachesCostPolicy } from '../../../../../utils/providerTemplateDisplay';
import type { PolicyHubPolicy } from '../../../../../utils/types';
import { logger } from '../../../../../utils/logger';
@@ -167,6 +168,7 @@ export default function GuardrailsSection({
const [isLoadingMoreGuardrails, setIsLoadingMoreGuardrails] = useState(false);
const [customPolicies, setCustomPolicies] = useState([]);
const [customPoliciesLoading, setCustomPoliciesLoading] = useState(false);
+ const [customPoliciesError, setCustomPoliciesError] = useState(null);
const [draggedGuardrailId, setDraggedGuardrailId] = useState(
null
);
@@ -236,12 +238,16 @@ export default function GuardrailsSection({
const fetchCustomPolicies = useCallback(async () => {
setCustomPoliciesLoading(true);
+ setCustomPoliciesError(null);
try {
const response = await getGatewayCustomPolicies();
setCustomPolicies(response.list || []);
} catch (e) {
logger.error('Failed to load custom policies:', e);
setCustomPolicies([]);
+ setCustomPoliciesError(
+ e instanceof Error ? e : new Error('Failed to load custom policies')
+ );
} finally {
setCustomPoliciesLoading(false);
}
@@ -521,15 +527,6 @@ export default function GuardrailsSection({
/>
- ) : drawerGuardrailsError ? (
-
- {
- void fetchDrawerGuardrails(selectedCategories, 0, false);
- }}
- />
-
) : (
+ {/* One source failing leaves the other's policies
+ selectable — warn and offer a retry instead of
+ replacing the whole list with an error. */}
+ {drawerGuardrailsError && (
+ {
+ // Retry reloads the first page, so the pagination
+ // cursor has to go back with it — otherwise the next
+ // "load more" resumes from the pre-failure offset and
+ // skips a page of guardrails.
+ setGuardrailsOffset(0);
+ void fetchDrawerGuardrails(selectedCategories, 0, false);
+ }}
+ />
+ )}
+ {customPoliciesError && (
+ {
+ void fetchCustomPolicies();
+ }}
+ />
+ )}
{drawerItems
.filter((g) => {
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx
index 796145d692..45cd685abe 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx
@@ -74,6 +74,7 @@ import type { AccessControl, PolicyHubPolicy } from '../../../../utils/types';
import { parsePolicyYaml } from '../../PolicyParameterEditor/yamlParser';
import { FormattedMessage } from 'react-intl';
import ErrorAlert from '../../../../Components/common/ErrorAlert';
+import PartialLoadWarning from '../../../../Components/common/PartialLoadWarning';
import { useAppAuth } from '../../../../contexts/AppAuthContext';
import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions';
import {
@@ -230,8 +231,6 @@ export default function ServiceProviderGuardrailsTab() {
const lockedActionTooltip = canEditProvider ? undefined : NO_PERMISSION_TOOLTIP;
const {
guardrails: availableGuardrails = [],
- isLoading: isLoadingGuardrails,
- error: guardrailsError,
refreshGuardrails,
getGuardrailDefinition,
} = useGuardrails();
@@ -263,8 +262,10 @@ export default function ServiceProviderGuardrailsTab() {
const [selectedCategories, setSelectedCategories] = useState(['AI']);
const [drawerGuardrails, setDrawerGuardrails] = useState([]);
const [drawerGuardrailsLoading, setDrawerGuardrailsLoading] = useState(false);
+ const [drawerGuardrailsError, setDrawerGuardrailsError] = useState(null);
const [customPolicies, setCustomPolicies] = useState([]);
const [customPoliciesLoading, setCustomPoliciesLoading] = useState(false);
+ const [customPoliciesError, setCustomPoliciesError] = useState(null);
const [draggedGlobalPolicyIndex, setDraggedGlobalPolicyIndex] = useState<
number | null
>(null);
@@ -282,8 +283,12 @@ export default function ServiceProviderGuardrailsTab() {
const reorderInFlightRef = useRef(false);
const showSnackbar = useAIWorkspaceSnackbar();
+ // The drawer list is assembled from two independent sources. Each records its
+ // own failure so one being down still shows the other's policies, with a
+ // retry for just the failed source.
const fetchDrawerGuardrails = useCallback(async (categories: string[]) => {
setDrawerGuardrailsLoading(true);
+ setDrawerGuardrailsError(null);
try {
const showAll =
categories.length === 0 ||
@@ -292,8 +297,12 @@ export default function ServiceProviderGuardrailsTab() {
? await getPolicies()
: await getGuardrails(categories.join(','));
setDrawerGuardrails(response.data);
- } catch {
+ } catch (e) {
+ logger.error('Failed to load Policy Hub guardrails:', e);
setDrawerGuardrails([]);
+ setDrawerGuardrailsError(
+ e instanceof Error ? e : new Error('Failed to load guardrails')
+ );
} finally {
setDrawerGuardrailsLoading(false);
}
@@ -301,12 +310,16 @@ export default function ServiceProviderGuardrailsTab() {
const fetchCustomPolicies = useCallback(async () => {
setCustomPoliciesLoading(true);
+ setCustomPoliciesError(null);
try {
const response = await getGatewayCustomPolicies();
setCustomPolicies(response.list || []);
} catch (e) {
logger.error('Failed to load custom policies:', e);
setCustomPolicies([]);
+ setCustomPoliciesError(
+ e instanceof Error ? e : new Error('Failed to load custom policies')
+ );
} finally {
setCustomPoliciesLoading(false);
}
@@ -1480,28 +1493,10 @@ export default function ServiceProviderGuardrailsTab() {
- {isLoadingGuardrails ? (
-
-
-
-
-
-
- ) : guardrailsError ? (
-
- {
- void refreshGuardrails();
- }}
- />
-
- ) : (
+ {/* The shared guardrails context only supplies display names, so
+ neither its loading nor its error state gates the drawer — the
+ drawer's own two sources report themselves below. */}
+ {(
<>
{!isDetailView ? (
<>
@@ -1530,6 +1525,23 @@ export default function ServiceProviderGuardrailsTab() {
}}
/>
+ {!drawerItemsLoading && drawerGuardrailsError && (
+ {
+ void fetchDrawerGuardrails(selectedCategories);
+ void refreshGuardrails();
+ }}
+ />
+ )}
+ {!drawerItemsLoading && customPoliciesError && (
+ {
+ void fetchCustomPolicies();
+ }}
+ />
+ )}
{drawerItemsLoading ? (
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 5847b9eb5b..57668b84ea 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx
@@ -95,7 +95,11 @@ import {
} from '../../../../utils/providerTemplateDisplay';
import { useProviderTemplates } from '../../../../contexts/llmProvider/providerTemplate';
import { useAppAuth } from '../../../../contexts/AppAuthContext';
-import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions';
+import {
+ DISABLED_ACTION_SX,
+ NO_PERMISSION_TOOLTIP,
+ SCOPES,
+} from '../../../../auth/permissions';
import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar';
import {
AIEntityProvider,
@@ -568,6 +572,8 @@ function ServiceProviderOverviewContent() {
}, [isReadOnlyProvider, activeDeploymentCount, linkedProxyCount]);
const canCreateProxy = hasPermission(SCOPES.LLM_PROXY_CREATE);
+ const canDeployProvider = hasPermission(SCOPES.LLM_PROVIDER_DEPLOYMENT_CREATE);
+ const canViewDeployments = hasPermission(SCOPES.LLM_PROVIDER_DEPLOYMENT_READ);
const isCreateProxyDisabled =
!provider?.id || isProxyQuotaReached || !canCreateProxy;
const createProxyTooltip = !canCreateProxy
@@ -1622,28 +1628,41 @@ function ServiceProviderOverviewContent() {
}}
>
- {/* For gateway-created (read-only) providers the deployments remain
- viewable (deploy/redeploy/restore/undeploy are disabled on the page
- itself), so the button navigates but is relabelled "View Deployments". */}
-
+
+
(null);
const fetchingApiKeysProviderIdRef = useRef(null);
const [gateways, setGateways] = useState([]);
@@ -889,12 +895,18 @@ export default function ServiceProviderOverviewTab({
/>
-
+
@@ -975,9 +987,11 @@ export default function ServiceProviderOverviewTab({
@@ -989,7 +1003,11 @@ export default function ServiceProviderOverviewTab({
key.id?.trim() || null
)
}
- disabled={!key.id || isDeletingKey}
+ disabled={
+ !canDeleteProviderApiKey ||
+ !key.id ||
+ isDeletingKey
+ }
>